simbiot-harness/harness/portal_mcp.py

251 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Simbiot Harness — MCP-клиент портала кооператива (t-261010093054).
Портал cil.by — хаб кооператива: звонки/встречи (booking_slots /
booking_create), лента звонков (list_calls / get_call), реестры
(members/projects/agents), казна, заявки, события. Всё это доступно
агентам через MCP-шлюз https://cil.by/mcp (Streamable HTTP, JSON-RPC 2.0,
см. /root/coop/MCP_CONNECT.md) — но тики харнеса раньше имели только
rag_lookup по локальной базе знаний: живого портала у LLM-тиков не было,
звонки Alex создавал руками через UI.
Этот модуль — тонкий клиент шлюза + материал для тула тика `portal_mcp`
(обёртка в _llm_worker._portal_mcp_tool_for):
- токен агента: env SIMBIOT_PORTAL_MCP_TOKEN → файл secrets/portal-mcp-token
инстанса (legacy — .simbiot-portal-token в корне репо). Токена нет →
тул не поднимается, тик живёт как раньше;
- лимиты зашиты в код (не в промпт): 4 вызова на тик (каждый тик — новый
процесс воркера), таймаут 15с на запрос, tools/list кешируется на процесс;
- guardrail: деньги-тулы (product_create / product_set_mode — создание
Stripe-объектов и переключение test↔live) по умолчанию ЗАПРЕЩЕНЫ;
разблокировать — SIMBIOT_MCP_ALLOW_ALL=1. Остальное — стандартный
спектр портала: booking_* / list_* / get_* / whoami / report_*;
- ошибки шлюза НЕ валят тик: возвращаются текстом "error: …";
- трейс PortalMcpCalled в event store (тул/ок/мс) — наблюдаемость как у
RagQueried: часовой уровень видит в событиях, что тик ходил в портал.
Клиент — чистый urllib (зависимостей нет), id запроса монотонный в рамках
процесса. Шлюз идемпотентен по своей природе: tools/list — чтение,
booking_create на занятый слот честно отвечает 409.
"""
import json
import os
import time
import urllib.error
import urllib.request
from . import paths as _paths # t-261004113351: секреты/данные инстанса
# ── конфиг ──────────────────────────────────────────────────────────────────
DEFAULT_URL = "https://cil.by/mcp"
TIMEOUT = 15 # сек на запрос: шлюз локальный, но пишет в Stripe/SMTP
MAX_CALLS_PER_TICK = 4 # звонки/расписание не требуют длинных цепочек
MAX_RESULT_CHARS = 6000 # выжимка для тика: транскрипции бывают большими
# тулы, создающие/переключающие Stripe-объекты (деньги) — руками Alex;
# SIMBIOT_MCP_ALLOW_ALL=1 снимает запрет (осознанный выбор инстанса)
_BLOCKED_TOOLS = {"product_create", "product_set_mode"}
_req_seq = 0
_tools_cache = None # [имена] — tools/list на процесс (каждый тик — новый)
def _load_token() -> str:
"""Токен агентского MCP: env сильнее файла; home-инстанс без своего
файла (secrets/portal-mcp-token) откатывается к legacy-токену репо —
миграции секретов не должны отключать уже подключённого агента."""
tok = os.environ.get("SIMBIOT_PORTAL_MCP_TOKEN", "").strip()
if tok:
return tok
p = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
".simbiot-portal-token")
try:
v = open(p).read().strip()
if v:
return v
except Exception:
pass
try:
with open(_paths.portal_mcp_token_path()) as f:
return f.read().strip()
except Exception:
return ""
def _url() -> str:
return os.environ.get("SIMBIOT_PORTAL_MCP_URL", "").strip() or DEFAULT_URL
class McpError(Exception):
"""Сбой запроса к шлюзу (сеть/HTTP/JSON) — тику вернётся текстом, не рэйзом."""
def _rpc(method: str, params: dict, timeout: int = TIMEOUT) -> dict:
"""Один JSON-RPC запрос к шлюзу. Возвращает result (без envelope)."""
global _req_seq
_req_seq += 1
body = json.dumps({"jsonrpc": "2.0", "id": _req_seq,
"method": method, "params": params or {}}).encode()
req = urllib.request.Request(
_url(), data=body,
headers={"Content-Type": "application/json",
"Authorization": f"Bearer {_load_token()}"})
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
envelope = json.loads(r.read().decode())
except urllib.error.HTTPError as e:
raise McpError(f"HTTP {e.code} от {_url()}: {e.read()[:300]!r}")
except Exception as e:
raise McpError(f"шлюз недоступен ({e})")
if not isinstance(envelope, dict):
raise McpError(f"шлюз вернул не-JSON-RPC ответ: {str(envelope)[:200]}")
if "error" in envelope and envelope["error"]:
err = envelope["error"]
raise McpError(f"{err.get('message') or err} (code={err.get('code')})")
return envelope.get("result") or {}
def _known_tools() -> list:
"""Имена тулов шлюза (кеш на процесс). Сбой — пустой список (см. call)."""
global _tools_cache
if _tools_cache is None:
try:
spec = _rpc("tools/list", {})
_tools_cache = [t.get("name") for t in (spec.get("tools") or [])
if t.get("name")]
except Exception:
_tools_cache = []
return _tools_cache
def _trace(level, trace_id, payload: dict):
"""Трейс MCP-вызова в стор — spend/observability едины с LLM-тиками."""
_log_jsonl(level, trace_id, payload)
if not (level and trace_id):
return
try:
from . import es
seq = len(_trace_log) + 1
es.append_event("trace", f"{trace_id}:mcp{seq}", "PortalMcpCalled", {
**payload, "level": level,
})
_trace_log.append(seq)
except Exception:
pass # трейс — best effort, не валить тик
# t-261015091108: журнал всех MCP-вызовов (кто/что/аргументы/время) — JSONL
def _log_jsonl(level, trace_id, payload: dict):
import datetime
try:
path = os.path.join(_paths.home(), "mcp_calls.log")
rec = {"ts": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
"level": level or "unknown", "trace_id": trace_id or "",
**payload}
with open(path, "a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
except Exception:
pass # журнал — best effort, не валить тик
_trace_log: list = []
def _coerce_arguments(arguments) -> dict:
"""Аргументы тулу: dict как есть; строка — JSON (модель так и шлёт)."""
if arguments is None or arguments == "":
return {}
if isinstance(arguments, dict):
return arguments
try:
v = json.loads(arguments)
return v if isinstance(v, dict) else {"value": v}
except Exception:
raise McpError("arguments — невалидный JSON; передавайте объект из "
"описания тула (например {\"type\": \"call\"})")
def call_tool(name: str, arguments=None, level: str = None,
trace_id: str = None, timeout: int = TIMEOUT) -> str:
"""Вызвать тул портала. ВСЕГДА возвращает строку (тик не валится).
Ошибки (сеть, guardrail, 409 занятого слота) — текстом «error: …»:
LLM-тик читает их как обычный результат и реагирует по смыслу.
"""
started = time.time()
ok, result = False, ""
_log_args = {"raw": str(arguments)[:500]} # t-261015091108: аргументы в лог
try:
args = _coerce_arguments(arguments)
_log_args = args
except Exception:
pass
def _finish(res: str, _ok: bool) -> str:
_trace(level, trace_id, {
"tool": name, "ok": _ok,
"ms": int((time.time() - started) * 1000),
"arguments": _log_args,
"result_preview": res[:300],
})
return res
token = _load_token()
if not token:
return _finish(
"error: MCP портала не настроен — нет токена "
"(SIMBIOT_PORTAL_MCP_TOKEN или secrets/portal-mcp-token)", False)
if name not in _BLOCKED_TOOLS or os.environ.get("SIMBIOT_MCP_ALLOW_ALL"):
pass # разрешено
else:
return _finish(
f"error: тул {name} запрещён на этом харнесе по умолчанию "
f"(деньги/Stripe: создаёт или переключает платёжные объекты — "
f"руками Alex; разблокировка SIMBIOT_MCP_ALLOW_ALL=1)", False)
try:
args = _coerce_arguments(arguments)
except McpError as e:
return _finish(f"error: {e}", False)
known = _known_tools()
if known and name not in known:
return _finish(
f"error: тула {name} нет на шлюзе; доступные: "
f"{', '.join(known)}", False)
try:
res = _rpc("tools/call", {"name": name, "arguments": args}, timeout)
except McpError as e:
return _finish(f"error: {e}", False)
ok = True
try:
# MCP content-массив: шлюз портала отдаёт [{type: "text", "text": ...}]
content = res.get("content") or []
texts = [c.get("text", "") for c in content if isinstance(c, dict)]
result = "\n".join(t for t in texts if t).strip() or json.dumps(res, ensure_ascii=False)
except Exception:
result = json.dumps(res, ensure_ascii=False)
if len(result) > MAX_RESULT_CHARS:
result = result[:MAX_RESULT_CHARS] + f"\n…(обрезано, всего {len(result)} симв.)"
return _finish(result, ok)
def list_tools(force: bool = False) -> list:
"""Имена тулов шлюза (для CLI и подсказок в промпте)."""
global _tools_cache
if force:
_tools_cache = None
return _known_tools()
def describe_tools(max_names: int = 12) -> str:
"""Короткая шпаргалка по тулам портала для промптов уровней."""
return ("booking_slots(type='call'|'session') — свободные слоты звонка с "
"Алексом; booking_create(start_utc из слота, guest_name, guest_email, "
"kind='call'|'session') — создать jitsi-звонок со ссылкой (гостю "
"письмо, Алексу — в TG, запись+транскрипция автоматом); booking_cancel"
"(meeting_id); list_calls/get_call — прошедшие звонки и их саммари; "
"list_members/member_create(email[,name]) — реестр членов и создание "
"аккаунта с magic-link письмом (member_create — только агентам "
"председателя); projects_list/list_agents/treasury_status/list_events — "
"реестры и казна; whoami/report_task/report_event/read_context — "
"идентичность и лента портала.")