Files
ten31-database/backend/mcp/server.py
T
Keysat c7ce44d963 Phase 0 foundation: canonical schema, ingest pipeline, CRM MCP server
Workstream A–C substrate for the Ten31 agentic system:
- A1: docs/crm-overview.md; CLAUDE.md conventions + guardrail #9
- A2: additive/reversible core migration (canonical_entities, entity_links,
  interaction_log, relationship_edges, soft-delete) + ledgered runner
- B1/B3: chunking + deterministic entity resolution (backend/ingest)
- B2: dense (bge-m3) + BM25 sparse ingest to Qdrant crm_chunks
- C: CRM MCP server (reads, retrieval modes, logged writes) — no outbound tools
- docs: redaction/re-hydration, Gmail enablement runbook
- synthetic test data; .env.example; housekeeping (.gitignore, untrack crm.db,
  drop legacy files + start9/0.3.5)

Verified end-to-end on synthetic data + live Sparks (hybrid > dense on entity
queries). Real backfill runs on Ten31 infra; index holds synthetic data only.
Branch snapshot also captures pre-existing working-tree changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 08:13:35 -05:00

89 lines
3.4 KiB
Python

#!/usr/bin/env python3
"""Ten31 CRM MCP server (Workstream C).
Exposes CRM reads, retrieval modes, and logged writes to the Claude Agent SDK
over MCP (stdio). All logic lives in crm_tools.py (tested independently); this
file is the thin transport wrapper.
Run:
pip install mcp # one-time (MCP Python SDK)
CRM_DB_PATH=/data/crm.db python3 backend/mcp/server.py
Register with the Agent SDK / Claude Code as an stdio MCP server pointing at this
script. NO outbound/contact tools are exposed — that capability is gated to
Phase 3 behind the compliance review (CLAUDE.md guardrails #4, #6).
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import crm_tools as t # noqa: E402
from mcp.server.fastmcp import FastMCP # noqa: E402
mcp = FastMCP("ten31-crm")
# ── reads ──
@mcp.tool()
def get_entity(lp_id: str) -> dict:
"""Fetch a canonical LP/organization/person entity by id, with its linked
source records and interaction count."""
return t.get_entity(lp_id)
@mcp.tool()
def search_records(query: str = "", entity_kind: str = "", limit: int = 20) -> dict:
"""Structured search over canonical entities by name substring and kind
('lp' | 'organization' | 'person')."""
return t.search_records(query=query or None, entity_kind=entity_kind or None, limit=limit)
@mcp.tool()
def get_interaction_history(lp_id: str, limit: int = 20) -> dict:
"""Merged, dated interaction history (communications + fundraising grid notes)
for a canonical entity."""
return t.get_interaction_history(lp_id, limit=limit)
# ── retrieval modes ──
@mcp.tool()
def hybrid_search(query: str, top_k: int = 8, lp_id: str = "", doc_type: str = "",
date_from: int = 0, date_to: int = 0) -> dict:
"""Dense + BM25 + rerank retrieval (default; best for entity-heavy queries).
Optional filters: lp_id, doc_type, date_from/date_to (epoch seconds)."""
return t.hybrid_search(query, top_k=top_k, lp_id=lp_id or None, doc_type=doc_type or None,
date_from=date_from or None, date_to=date_to or None)
@mcp.tool()
def semantic_search(query: str, top_k: int = 8, lp_id: str = "", doc_type: str = "") -> dict:
"""Dense-only retrieval (high recall)."""
return t.semantic_search(query, top_k=top_k, lp_id=lp_id or None, doc_type=doc_type or None)
@mcp.tool()
def keyword_search(query: str, top_k: int = 8, lp_id: str = "", doc_type: str = "") -> dict:
"""High-precision lexical retrieval (sparse leg + rerank)."""
return t.keyword_search(query, top_k=top_k, lp_id=lp_id or None, doc_type=doc_type or None)
# ── writes (logged) ──
@mcp.tool()
def log_interaction(action: str, actor_type: str = "agent", actor_id: str = "",
target_id: str = "", payload: dict = None, source: str = "mcp") -> dict:
"""Append an entry to the append-only interaction log (guardrail #5)."""
return t.log_interaction(action, actor_type=actor_type, actor_id=actor_id or None,
target_id=target_id or None, payload=payload, source=source)
@mcp.tool()
def set_entity_enrichment(lp_id: str, fields: dict, actor_id: str = "analyst") -> dict:
"""One-way enrichment write into a canonical entity (thesis_fit, segment,
warmth_score, accreditation_status, etc.). Logged automatically."""
return t.set_entity_enrichment(lp_id, fields, actor_id=actor_id)
if __name__ == "__main__":
mcp.run()