"""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])/member_remove(member_id," "reason) — реестр членов, создание аккаунта с magic-link и " "безопасное удаление с бэкапом и аудитом (create/remove — только " "агентам председателя); projects_list/list_agents/treasury_status/list_events — " "реестры и казна; whoami/report_task/report_event/read_context — " "идентичность и лента портала.")