Primer commit

This commit is contained in:
2026-05-30 14:31:19 -06:00
commit a35d26fac0
277 changed files with 265240 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
"""MCP server stdio para MP Manager.
Expone herramientas tipadas para que Claude Code opere el ecosistema
(audits, syncs, búsquedas, scripts) sin pasar por el SPA ni por Bash crudo.
Entry point: `python -m mcp_server`
"""
__version__ = "0.1.0"
+13
View File
@@ -0,0 +1,13 @@
"""Entry point: `python -m mcp_server` arranca el servidor MCP por stdio."""
from .server import build_server
def main():
server = build_server()
# FastMCP.run() expone stdio por defecto, que es lo que Claude Code consume.
server.run()
if __name__ == "__main__":
main()
+227
View File
@@ -0,0 +1,227 @@
"""Adapters: invocan funciones internas del proyecto desde el MCP server.
Filosofía: las tools del MCP llaman funciones Python directamente (no HTTP).
Para scripts que no exportan función pública, usan subprocess con --json y
parsean stdout. Los reportes grandes se vuelcan a `generated/agent/runs/`
y la tool devuelve solo el path + summary para no inflar el contexto LLM.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import uuid
from datetime import datetime
from typing import Any
# Asegurar que la raíz del repo esté en el PYTHONPATH cuando se ejecuta
# `python -m mcp_server` desde el cwd del proyecto.
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
import paths # noqa: E402
SCRIPTS_DIR = os.path.join(_ROOT, "scripts")
PYTHON_EXE = sys.executable
LARGE_PAYLOAD_THRESHOLD = 8000 # chars; sobre esto, volcamos a archivo
def new_run_id() -> str:
return str(uuid.uuid4())
def _agent_run_path(tool_name: str, ext: str = "json") -> str:
os.makedirs(paths.AGENT_RUNS_DIR, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
return os.path.join(paths.AGENT_RUNS_DIR, f"{tool_name}_{ts}.{ext}")
def maybe_offload(tool_name: str, payload: Any) -> dict:
"""Si el payload es grande, lo escribe a disco y devuelve {path, summary}.
Devuelve siempre un dict con la forma {ok, summary, details?, report_path?}.
"""
try:
serialized = json.dumps(payload, ensure_ascii=False)
except (TypeError, ValueError):
serialized = str(payload)
if len(serialized) > LARGE_PAYLOAD_THRESHOLD:
path = _agent_run_path(tool_name)
with open(path, "w", encoding="utf-8") as f:
f.write(serialized)
summary = _summarize(payload)
return {"ok": True, "summary": summary, "report_path": path}
return {"ok": True, "summary": _summarize(payload), "details": payload}
def _summarize(payload: Any) -> dict:
"""Genera un summary compacto: claves top-level, conteos, primeras filas."""
if isinstance(payload, dict):
out = {"type": "dict", "keys": list(payload.keys())[:20]}
if "summary" in payload:
out["summary"] = payload["summary"]
for k, v in payload.items():
if isinstance(v, list):
out[f"{k}_count"] = len(v)
return out
if isinstance(payload, list):
return {"type": "list", "count": len(payload), "first": payload[:3]}
return {"type": type(payload).__name__, "value": str(payload)[:200]}
def run_python_script(
name: str,
args: list[str] | None = None,
timeout: int = 600,
expect_json: bool = False,
) -> dict:
"""Lanza un script de `scripts/` por subprocess.
Devuelve {ok, exit_code, stdout, stderr, parsed?}. Si expect_json=True y el
script imprimió JSON, lo parsea en `parsed` y aplica maybe_offload sobre él.
"""
if not name.endswith(".py"):
name = name + ".py"
script_path = os.path.join(SCRIPTS_DIR, name)
if not os.path.isfile(script_path):
return {"ok": False, "error": f"script not found: {name}"}
cmd = [PYTHON_EXE, script_path] + (args or [])
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
cwd=_ROOT,
)
except subprocess.TimeoutExpired:
return {"ok": False, "error": "timeout", "timeout_sec": timeout}
out: dict[str, Any] = {
"ok": proc.returncode == 0,
"exit_code": proc.returncode,
"command": " ".join(args or []),
}
if expect_json and proc.stdout.strip():
try:
parsed = json.loads(proc.stdout)
offload = maybe_offload(name[:-3], parsed)
out.update(offload)
out["stderr_tail"] = proc.stderr[-500:] if proc.stderr else ""
return out
except json.JSONDecodeError:
pass
out["stdout"] = proc.stdout[-4000:]
out["stderr"] = proc.stderr[-1000:]
return out
# --- Adapters específicos (funciones Python directas) ---
def list_accounts_adapter() -> list[dict]:
"""Devuelve cuentas conocidas desde la DB local."""
import db
return db.get_accounts() or []
def get_account_adapter(location_id: str) -> dict | None:
import db
return db.get_account(location_id)
def search_contacts_adapter(
location_id: str,
query: str | None = None,
limit: int = 50,
offset: int = 0,
without_opp: bool = False,
) -> list[dict]:
import db
return db.get_contacts(location_id, search_query=query, limit=limit, offset=offset, without_opp=without_opp)
def get_contact_adapter(location_id: str, contact_id: str) -> dict | None:
import db
return db.get_contact_by_id(location_id, contact_id)
def get_opportunities_adapter(location_id: str, pipeline_id: str | None = None) -> list[dict]:
import db
return db.get_opportunities(location_id, pipeline_id=pipeline_id)
def get_pipelines_adapter(location_id: str) -> list[dict]:
import db
return db.get_pipelines(location_id)
def get_account_metrics_adapter(location_id: str) -> dict:
import db
return db.get_account_metrics(location_id)
def get_global_metrics_adapter() -> dict:
import db
return db.get_global_metrics()
def get_workflows_adapter(location_id: str | None = None) -> list[dict]:
import db
return db.get_workflows(location_id=location_id)
def get_sync_logs_adapter(limit: int = 20) -> list[dict]:
import db
return db.get_sync_logs(limit=limit)
def get_error_logs_adapter(limit: int = 50) -> list[dict]:
import db
return db.get_error_logs(limit=limit)
def sync_missing_contacts_adapter(
contact_ids: list[str] | None = None,
dry_run: bool = True,
run_id: str | None = None,
) -> dict:
"""Sincroniza contactos faltantes de sucursal → Marca.
Defaults a dry_run=True. Si dry_run=False, requiere run_id (lo genera si no
se pasa) y queda registrado en script_audit para rollback.
"""
from scripts import sync_missing_contacts_to_brand as mod
if not dry_run and not run_id:
run_id = new_run_id()
result = mod.run_sync(contact_ids=contact_ids, dry_run=dry_run, run_id=run_id)
return {"run_id": run_id, "dry_run": dry_run, "result": result}
def sync_missing_opps_adapter(
opp_ids: list[str] | None = None,
dry_run: bool = True,
run_id: str | None = None,
) -> dict:
"""Sincroniza oportunidades faltantes de sucursal → Marca."""
from scripts import sync_missing_opps_to_brand as mod
if not dry_run and not run_id:
run_id = new_run_id()
result = mod.run_sync(opp_ids=opp_ids, dry_run=dry_run, run_id=run_id)
return {"run_id": run_id, "dry_run": dry_run, "result": result}
# --- Catálogo de scripts disponibles ---
def script_catalog() -> dict:
"""Devuelve el inventario completo de scripts con su estado de auditoría."""
if not os.path.exists(paths.AGENT_AUDIT_REPORT):
return {"error": "audit_report.json no existe — corre scripts/audit_agent_readiness.py"}
with open(paths.AGENT_AUDIT_REPORT, "r", encoding="utf-8") as f:
return json.load(f)
+97
View File
@@ -0,0 +1,97 @@
"""Genera `generated/agent/tools_manifest.json`.
Fuente única de verdad navegable para LLM/humanos: lista de tools MCP,
scripts disponibles, endpoints FastAPI y su estado de cumplimiento.
Se ejecuta automáticamente al arrancar el server. También puede invocarse
manual: python -m mcp_server.manifest
"""
from __future__ import annotations
import json
import os
import sys
from datetime import datetime, timezone
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
import paths # noqa: E402
# Catálogo declarativo de tools MCP (mantener sincronizado con server.py).
TOOLS = [
{"name": "list_accounts", "category": "accounts", "mutates": False, "desc": "Lista cuentas Marca+sucursales."},
{"name": "get_account", "category": "accounts", "mutates": False, "desc": "Detalle de una cuenta."},
{"name": "get_global_metrics", "category": "metrics", "mutates": False, "desc": "Métricas globales."},
{"name": "get_account_metrics", "category": "metrics", "mutates": False, "desc": "Métricas por sucursal."},
{"name": "search_contacts", "category": "contacts", "mutates": False, "desc": "Busca contactos en cache local."},
{"name": "get_contact", "category": "contacts", "mutates": False, "desc": "Detalle de contacto."},
{"name": "get_opportunities", "category": "opps", "mutates": False, "desc": "Oportunidades por location."},
{"name": "get_pipelines", "category": "opps", "mutates": False, "desc": "Pipelines/etapas por location."},
{"name": "get_workflows", "category": "workflows", "mutates": False, "desc": "Workflows por location."},
{"name": "sync_missing_contacts", "category": "sync", "mutates": True, "desc": "Sucursal→Marca contactos faltantes (dry-run default)."},
{"name": "sync_missing_opps", "category": "sync", "mutates": True, "desc": "Sucursal→Marca opps faltantes (dry-run default)."},
{"name": "sync_logs", "category": "ops", "mutates": False, "desc": "Logs de sincronización."},
{"name": "error_logs", "category": "ops", "mutates": False, "desc": "Errores recientes."},
{"name": "agent_audit_report", "category": "ops", "mutates": False, "desc": "Reporte de salud agentica."},
{"name": "script_catalog", "category": "ops", "mutates": False, "desc": "Manifest navegable (este archivo)."},
{"name": "run_script", "category": "advanced", "mutates": True, "desc": "Ejecuta script arbitrario de scripts/."},
]
def _load_audit_report() -> dict | None:
if not os.path.exists(paths.AGENT_AUDIT_REPORT):
return None
try:
with open(paths.AGENT_AUDIT_REPORT, "r", encoding="utf-8") as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return None
def build_manifest() -> dict:
audit = _load_audit_report()
scripts: list[dict] = []
endpoints: list[dict] = []
if audit:
for s in audit.get("scripts", []):
scripts.append({
"name": s["name"],
"category": s["category"],
"is_mutator": s["is_mutator"],
"registered_in_metadata": s["registered_in_metadata"],
"has_json": s["has_json_flag"],
"has_apply": s["has_apply_flag"],
"has_run_id": s["has_run_id_flag"],
"issues": s["issues"],
"suggestion": s["suggestion"],
"docstring": s["docstring"],
})
endpoints = audit.get("endpoints", [])
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
"tools": TOOLS,
"scripts": scripts,
"endpoints": endpoints,
"notes": {
"apply_confirm_token": "I-HAVE-USER-CONFIRMATION",
"dry_run_default": True,
"audit_source": "generated/agent/audit_report.json",
"refresh_audit": "python scripts/audit_agent_readiness.py",
},
}
def write_manifest() -> str:
os.makedirs(paths.AGENT_DIR, exist_ok=True)
manifest = build_manifest()
with open(paths.AGENT_MANIFEST_PATH, "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2, ensure_ascii=False)
return paths.AGENT_MANIFEST_PATH
if __name__ == "__main__":
path = write_manifest()
print(f"manifest written: {path}")
+225
View File
@@ -0,0 +1,225 @@
"""Construcción del servidor FastMCP y registro de tools.
Tools expuestas (ver docs/AGENT_TOOLS.md para el catálogo completo):
Cuentas / metadatos:
list_accounts, get_account, get_global_metrics, get_account_metrics
Datos cacheados (SQLite local — refresca con sync_all_accounts):
search_contacts, get_contact, get_opportunities, get_pipelines, get_workflows
Operaciones (con dry_run por defecto, requieren confirm_token para aplicar):
sync_missing_contacts, sync_missing_opps
Operacional / observabilidad:
sync_logs, error_logs, agent_audit_report, script_catalog
Genéricas (cola larga — uso avanzado):
run_script(name, args, expect_json)
"""
from __future__ import annotations
import json
import os
from typing import Any
from mcp.server.fastmcp import FastMCP
from . import adapters
from .manifest import write_manifest
# Token literal que el LLM debe pasar como `confirm_token` para aplicar
# cambios reales. Documentado en docs/AGENT_TOOLS.md y en la descripción
# de cada tool mutadora. La idea es que el LLM lo solicite explícitamente
# al usuario antes de invocar con apply=True.
APPLY_CONFIRM_TOKEN = "I-HAVE-USER-CONFIRMATION"
def _require_confirm(apply: bool, confirm_token: str | None) -> str | None:
if not apply:
return None
if confirm_token != APPLY_CONFIRM_TOKEN:
return (
f"apply=True requiere confirm_token='{APPLY_CONFIRM_TOKEN}'. "
"Pide confirmación al usuario antes de pasarlo. Default es dry-run."
)
return None
def build_server() -> FastMCP:
mcp = FastMCP("mp-manager")
# Regenerar manifest al arrancar para que siempre refleje el estado actual.
try:
write_manifest()
except Exception:
# No bloqueamos el arranque si el manifest falla; el server sigue siendo útil.
pass
# ---------- Cuentas / metadatos ----------
@mcp.tool(description="Lista todas las cuentas (Marca + 49 sucursales) desde SQLite local. Read-only.")
def list_accounts() -> dict:
accounts = adapters.list_accounts_adapter()
return adapters.maybe_offload("list_accounts", accounts)
@mcp.tool(description="Detalle de una cuenta por location_id desde SQLite local.")
def get_account(location_id: str) -> dict:
acc = adapters.get_account_adapter(location_id)
return {"ok": acc is not None, "account": acc}
@mcp.tool(description="Métricas globales: total contactos, opps, sucursales activas. Read-only desde SQLite.")
def get_global_metrics() -> dict:
return {"ok": True, "metrics": adapters.get_global_metrics_adapter()}
@mcp.tool(description="Métricas de una sucursal (contactos, opps por status, pipelines). Read-only desde SQLite.")
def get_account_metrics(location_id: str) -> dict:
return {"ok": True, "metrics": adapters.get_account_metrics_adapter(location_id)}
# ---------- Datos cacheados ----------
@mcp.tool(
description=(
"Busca contactos en una location (cache SQLite). "
"query matchea nombre/email/teléfono. "
"without_opp=True filtra contactos sin oportunidad."
)
)
def search_contacts(
location_id: str,
query: str | None = None,
limit: int = 50,
offset: int = 0,
without_opp: bool = False,
) -> dict:
rows = adapters.search_contacts_adapter(
location_id, query=query, limit=limit, offset=offset, without_opp=without_opp
)
return adapters.maybe_offload("search_contacts", rows)
@mcp.tool(description="Detalle completo de un contacto por (location_id, contact_id) desde SQLite.")
def get_contact(location_id: str, contact_id: str) -> dict:
c = adapters.get_contact_adapter(location_id, contact_id)
return {"ok": c is not None, "contact": c}
@mcp.tool(description="Oportunidades de una location (opcionalmente filtra por pipeline_id). Cache SQLite.")
def get_opportunities(location_id: str, pipeline_id: str | None = None) -> dict:
rows = adapters.get_opportunities_adapter(location_id, pipeline_id=pipeline_id)
return adapters.maybe_offload("get_opportunities", rows)
@mcp.tool(description="Pipelines y etapas de una location. Cache SQLite.")
def get_pipelines(location_id: str) -> dict:
rows = adapters.get_pipelines_adapter(location_id)
return {"ok": True, "pipelines": rows}
@mcp.tool(description="Workflows de una location (o de todas si se omite location_id). Cache SQLite.")
def get_workflows(location_id: str | None = None) -> dict:
rows = adapters.get_workflows_adapter(location_id=location_id)
return adapters.maybe_offload("get_workflows", rows)
# ---------- Operaciones (mutadoras con guard) ----------
@mcp.tool(
description=(
"Sincroniza contactos faltantes de sucursales hacia Marca. "
"Default dry_run=True (planifica, no aplica). "
"Para apply=True debes pasar confirm_token='I-HAVE-USER-CONFIRMATION' tras pedir confirmación al usuario. "
"Genera run_id automático y registra en script_audit para rollback."
)
)
def sync_missing_contacts(
contact_ids: list[str] | None = None,
apply: bool = False,
confirm_token: str | None = None,
) -> dict:
err = _require_confirm(apply, confirm_token)
if err:
return {"ok": False, "error": err}
result = adapters.sync_missing_contacts_adapter(
contact_ids=contact_ids, dry_run=not apply
)
return adapters.maybe_offload("sync_missing_contacts", result)
@mcp.tool(
description=(
"Sincroniza oportunidades faltantes de sucursales hacia Marca. "
"Default dry_run=True. Para apply=True requiere confirm_token. "
"Genera run_id y registra en script_audit para rollback."
)
)
def sync_missing_opps(
opp_ids: list[str] | None = None,
apply: bool = False,
confirm_token: str | None = None,
) -> dict:
err = _require_confirm(apply, confirm_token)
if err:
return {"ok": False, "error": err}
result = adapters.sync_missing_opps_adapter(
opp_ids=opp_ids, dry_run=not apply
)
return adapters.maybe_offload("sync_missing_opps", result)
# ---------- Observabilidad ----------
@mcp.tool(description="Logs recientes de sincronización (última corrida por sucursal). Read-only.")
def sync_logs(limit: int = 20) -> dict:
return {"ok": True, "logs": adapters.get_sync_logs_adapter(limit=limit)}
@mcp.tool(description="Errores recientes (tabla error_log + errors.jsonl). Read-only.")
def error_logs(limit: int = 50) -> dict:
return adapters.maybe_offload("error_logs", adapters.get_error_logs_adapter(limit=limit))
@mcp.tool(
description=(
"Reporte de salud agentica: scripts, endpoints, issues. "
"Regenera si stale_seconds>0 o si no existe. Lectura del JSON generado por scripts/audit_agent_readiness.py."
)
)
def agent_audit_report() -> dict:
report = adapters.script_catalog()
return adapters.maybe_offload("agent_audit_report", report)
@mcp.tool(
description=(
"Catálogo de tools y scripts disponibles (lectura del tools_manifest.json). "
"Útil al inicio de una sesión para saber qué herramientas existen."
)
)
def script_catalog() -> dict:
path = os.path.join(os.path.dirname(__file__), "..", "generated", "agent", "tools_manifest.json")
path = os.path.abspath(path)
try:
with open(path, "r", encoding="utf-8") as f:
manifest = json.load(f)
except (OSError, json.JSONDecodeError) as e:
return {"ok": False, "error": str(e)}
return adapters.maybe_offload("script_catalog", manifest)
# ---------- Genérica (cola larga) ----------
@mcp.tool(
description=(
"Ejecuta cualquier script de scripts/ por subprocess. "
"Para scripts mutadores debes pasar apply=True + confirm_token, y los args correctos del script (típicamente --apply --run-id ...). "
"Si expect_json=True intenta parsear stdout como JSON y vuelca a generated/agent/runs/ si es grande. "
"Lista de scripts disponibles en `script_catalog` / `agent_audit_report`."
)
)
def run_script(
name: str,
args: list[str] | None = None,
expect_json: bool = False,
apply: bool = False,
confirm_token: str | None = None,
timeout_sec: int = 600,
) -> dict:
err = _require_confirm(apply, confirm_token)
if err:
return {"ok": False, "error": err}
return adapters.run_python_script(
name, args=args, timeout=timeout_sec, expect_json=expect_json
)
return mcp