c7ce44d963
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>
89 lines
3.4 KiB
Python
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()
|