251 lines
12 KiB
Python
251 lines
12 KiB
Python
"""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 — "
|
||
"идентичность и лента портала.")
|