From dfa3b2d8a8bbb22c02c6671c6d8cad57ae5ad528 Mon Sep 17 00:00:00 2001 From: Simbiot Harness Date: Sat, 10 Oct 2026 10:19:09 +0000 Subject: [PATCH] =?UTF-8?q?t-261010093054:=20MCP=20=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=D0=B0=D0=BB=D0=B0=20=D0=BA=D0=BE=D0=BE=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D0=B8=D0=B2=D0=B0=20=D0=BA=D0=B0=D0=BA=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D0=B4=D0=B0=D1=80=D1=82=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D1=82=D1=83=D0=BB=20=D1=85=D0=B0=D1=80=D0=BD=D0=B5=D1=81=D0=B0?= =?UTF-8?q?=20=E2=80=94=20portal=5Fmcp=20(booking=5Fslots/booking=5Fcreate?= =?UTF-8?q?,=20=D1=80=D0=B5=D0=B5=D1=81=D1=82=D1=80=D1=8B,=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=D0=BD=D0=B0,=20=D0=BB=D0=B5=D0=BD=D1=82=D0=B0),=20guardr?= =?UTF-8?q?ail=20=D0=BD=D0=B0=20=D0=B4=D0=B5=D0=BD=D1=8C=D0=B3=D0=B8-?= =?UTF-8?q?=D1=82=D1=83=D0=BB=D1=8B,=204=20=D0=B2=D1=8B=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0/=D1=82=D0=B8=D0=BA,=20=D1=82=D1=80=D0=B5=D0=B9=D1=81=20P?= =?UTF-8?q?ortalMcpCalled,=20cli=20mcp-call,=20=D1=82=D0=B5=D1=81=D1=82=20?= =?UTF-8?q?18=20PASS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 23 ++++ cli.py | 19 ++++ harness/_llm_worker.py | 37 +++++++ harness/levels.py | 26 ++++- harness/paths.py | 19 +++- harness/portal_mcp.py | 227 +++++++++++++++++++++++++++++++++++++++ tests/test_portal_mcp.py | 175 ++++++++++++++++++++++++++++++ 7 files changed, 523 insertions(+), 3 deletions(-) create mode 100644 harness/portal_mcp.py create mode 100644 tests/test_portal_mcp.py diff --git a/README.md b/README.md index e1bbde1..8cdaf4b 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,29 @@ sqlite, rag.db ~12.5k чанков по контексту/навыкам, herme Ручной вызов: `cli.py rag-query "вопрос" -k 5 [--db ...] [--json]`. Выключить: `RAG_DISABLED=1`. +## MCP портала в тике (t-261010093054) + +Каждый LLM-тик получает инструмент `portal_mcp` — живой портал кооператива +через MCP-шлюз cil.by (JSON-RPC 2.0, агент зарегистрирован 29.09, владелец +alex). Ключевое для Alex: **звонки назначаются из чата** — +`booking_slots` (свободные слоты, call=30мин/session=20мин, график 3/3 +Минск) → `booking_create` (jitsi-звонок со ссылкой, гостю письмо, запись и +транскрипция автоматом). Остальной спектр: list_calls/get_call (саммари +прошедших), list_members/projects_list/list_agents, treasury_status, +list_events, whoami/report_task/report_event. + +Лимиты зашиты в код (не в промпт): **4 вызова на тик**, таймаут 15с, +обрезка результата 6k симв.; тул не поднимается без токена. Guardrail: +деньги-тулы `product_create`/`product_set_mode` (Stripe) запрещены по +умолчанию — `SIMBIOT_MCP_ALLOW_ALL=1` снимает. Ошибки шлюза возвращаются +текстом `error: …`, тик не валится; трейс — `PortalMcpCalled` в сторе. + +Токен: env `SIMBIOT_PORTAL_MCP_TOKEN` → `/secrets/portal-mcp-token` +(legacy-фолбэк `.simbiot-portal-token` в корне репо). Ручной вызов: +`cli.py mcp-call booking_slots '{"type": "call"}'`, список тулов — +`cli.py mcp-call --list`. Клиент: `harness/portal_mcp.py`, обёртка тика — +`_llm_worker._portal_mcp_tool_for`, тест `tests/test_portal_mcp.py`. + ## Хард-кап шагов тика (e-260930031800) Мин-воркер уходил в исследовательские цепочки (90+ tool-вызовов) и умирал diff --git a/cli.py b/cli.py index c2033ca..0b74b90 100644 --- a/cli.py +++ b/cli.py @@ -181,6 +181,12 @@ def main(): p_rag.add_argument("--db", default=None, help="база RAG (по умолчанию rag.db; hermes.db — общая)") p_rag.add_argument("--json", action="store_true", help="вывести машинно-читаемо") + p_mcp = sub.add_parser("mcp-call", help="вызов инструмента MCP-портала кооператива (cil.by/mcp, t-261010093054)") + p_mcp.add_argument("tool", nargs="?", default="", help="имя тула (booking_slots, booking_create, whoami, …)") + p_mcp.add_argument("arguments", nargs="?", default="", help="JSON-аргументы тула, напр. '{\"type\": \"call\"}'") + p_mcp.add_argument("--list", action="store_true", help="показать тулы шлюза и выйти") + p_mcp.add_argument("--timeout", type=int, default=None, help="таймаут запроса, сек") + args = ap.parse_args() if args.cmd == "init": @@ -422,6 +428,19 @@ def main(): for c in chunks: print(f"\n═══ {c['score']:.3f} | {c['source']} ═══") print(c["text"][:rag_lookup.TIMEOUT * 100 or 900]) + elif args.cmd == "mcp-call": + # MCP портала (t-261010093054): ручной вызов тула шлюза cil.by/mcp; + # без init_db — клиент не пишет в read model (трейс только с level/trace_id) + from harness import portal_mcp + if args.list: + names = portal_mcp.list_tools(force=True) + print("\n".join(names) if names else + "(шлюз недоступен или токена нет — проверь SIMBIOT_PORTAL_MCP_TOKEN)") + return + if not args.tool: + ap.error("mcp-call: укажи tool (или --list)") + kw = {"timeout": args.timeout} if args.timeout else {} + print(portal_mcp.call_tool(args.tool, args.arguments, **kw)) elif args.cmd == "escalate": es.init_db() eid, to_level = commands.raise_escalation( diff --git a/harness/_llm_worker.py b/harness/_llm_worker.py index 9063735..a124938 100644 --- a/harness/_llm_worker.py +++ b/harness/_llm_worker.py @@ -41,6 +41,37 @@ def _rag_tool_for(level: str, trace_id: str): return rag_lookup +def _portal_mcp_tool_for(level: str, trace_id: str): + """Тул тика `portal_mcp` — MCP-шлюз портала кооператива (t-261010093054). + + Звонки/расписание (booking_slots/booking_create), реестры, казна, + лента звонков портала — живые данные cil.by прямо из тика. Клиент и + лимиты — harness/portal_mcp.py (4 вызова на тик, таймаут 15с, трейс + PortalMcpCalled); тул не поднимается без токена агента. Guardrail: + деньги-тулы (product_create/product_set_mode) запрещены по умолчанию + (SIMBIOT_MCP_ALLOW_ALL=1 снимает). + """ + from strands import tool as strands_tool + from harness import portal_mcp as _mcp + state = {"calls": 0} + + @strands_tool + def portal_mcp(tool: str, arguments: str = "") -> str: + """Вызов инструмента портала кооператива (MCP cil.by): расписание и + создание звонков с Алексом (booking_slots → booking_create), реестры + членов/проектов/агентов, казна, лента звонков и событий, whoami. + Сначала вызови с tool="booking_slots", arguments='{"type": "call"}', + слот передавай в booking_create как есть. arguments — JSON-строка. + Лимит 4 вызова на тик. Ответ — JSON-текст портала; ошибки — "error: …".""" + if state["calls"] >= _mcp.MAX_CALLS_PER_TICK: + return (f"лимит: {_mcp.MAX_CALLS_PER_TICK} вызовов portal_mcp " + f"на тик (использованы)") + state["calls"] += 1 + return _mcp.call_tool(tool, arguments, level=level, trace_id=trace_id) + + return portal_mcp + + def _wire_tool_budget(agent, max_calls: int = 30): """Хард-кап тул-коллов на тик (e-260930031800: мин-воркер уходил в 90+ шагов исследования и умирал по таймауту 300с). @@ -260,6 +291,12 @@ def main(): tools.append(_rag_tool_for(level, trace_id)) except Exception: pass # RAG — best effort: вендор не должен валить тик + # MCP портала (t-261010093054): booking_slots/booking_create, реестры, + # казна, лента звонков — живой портал из тика; без токена тул не поднимется + try: + tools.append(_portal_mcp_tool_for(level, trace_id)) + except Exception: + pass # портал — best effort: шлюз не должен валить тик # stdout воркера не читается (subprocess.run с capture_output и # длинным выводом переполняет pipe → BrokenPipeError в печати, # результат тика терялся, дайджесты 04:11-04:17 ушли пустыми). diff --git a/harness/levels.py b/harness/levels.py index a1e49df..7a16e8c 100644 --- a/harness/levels.py +++ b/harness/levels.py @@ -82,6 +82,16 @@ LEVELS = { "знаний кооператива (контекст, проекты, порталы, встречи, документы). " "Если вопрос про кооператив/продукты/документы — сначала rag_lookup " "(1 запрос за тик, формулируй целиком), потом отвечай по найденному. " + "У тебя есть инструмент portal_mcp — живой портал кооператива cil.by " + "(MCP): booking_slots/booking_create — свободные слоты и создание " + "звонка с Алексом (jitsi-ссылка, гостю письмо, запись и транскрипция " + "автоматом), list_calls/get_call — прошедшие звонки и саммари, " + "list_members/projects_list/list_agents/treasury_status/list_events — " + "реестры, казна, лента. Когда Alex просит назначить/перенести звонок " + "или «посмотреть расписание» — это portal_mcp (до 4 вызовов за тик): " + "сначала booking_slots (arguments '{\"type\": \"call\"}'), потом " + "booking_create со слотом как есть. НЕ назначай звонки сам по " + "инициативе — только по явной просьбе в чате. " "ВАЖНО: НЕ исследуй исходники харнеса (scheduler.py, commands.py, es.py, " "isolated_runner.py) и не разбирай падения тиков — это зона верхних уровней. " "Отвечай из контекста тика, максимум пара шагов, иначе таймаут тика. " @@ -121,7 +131,15 @@ LEVELS = { "Файлы/уведомления в чат — только идемпотентно: cli.py tg-deliver-file " "<путь> -c \"подпись\", cli.py tg-notify \"текст\" -k <ключ> (повтор — SKIP). " "Прямые curl/api.telegram отправки из shell запрещены — нет защиты " - "от дублей (t-260930083937)."), + "от дублей (t-260930083937). " + "Инструмент portal_mcp — живой портал кооператива (MCP cil.by): " + "booking_slots/booking_create (звонки с Алексом: слот → создание " + "jitsi-звонка со ссылкой, гостю письмо), list_calls/get_call, " + "list_members/projects_list/list_agents, treasury_status, list_events. " + "Просьбы про назначение/перенос звонков и «что по расписанию» — " + "через него (до 4 вызовов за тик). НЕ создавай звонки без явной " + "просьбы в чате; деньги-тулы (product_create/product_set_mode) " + "заблокированы по умолчанию."), can_shell=True, ), "day": CycleLevel( @@ -132,7 +150,11 @@ LEVELS = { "Открытые эскалации (секция ЭСКАЛАЦИИ) — приоритет: это важное, " "поднятое с нижних уровней; закрой cli.py escalation-ack . " "Файлы в чат — только cli.py tg-deliver-file (идемпотентно, " - "повтор — SKIP); прямые curl/api.telegram отправки запрещены."), + "повтор — SKIP); прямые curl/api.telegram отправки запрещены. " + "Инструмент portal_mcp — живой портал кооператива (MCP cil.by): " + "расписание и создание звонков (booking_slots → booking_create), " + "реестры, казна, лента звонков; до 4 вызовов за тик, деньги-тулы " + "заблокированы по умолчанию."), can_shell=True, ), } diff --git a/harness/paths.py b/harness/paths.py index c7dce89..ed3c753 100644 --- a/harness/paths.py +++ b/harness/paths.py @@ -143,6 +143,22 @@ def voice_dir() -> str: return "/var/lib/simbiot-harness/voice" # legacy +# ── токен агентского MCP портала (t-261010093054) ────────────────────────── +def portal_mcp_token_path() -> str: + """Путь к токену MCP-агента портала (см. harness/portal_mcp.py). + + Данные инстанса, не код: secrets/ в SIMBIOT_HOME, legacy — файл + .simbiot-portal-token в корне репо (выдан при подключении Simbiot + как MCP-агента кооператива, 02.10). + """ + env = os.environ.get("SIMBIOT_PORTAL_MCP_TOKEN_FILE", "").strip() + if env: + return env + if IS_ISOLATED: + return os.path.join(_HOME, "secrets", "portal-mcp-token") + return os.path.join(REPO_ROOT, ".simbiot-portal-token") + + def ensure_instance_dirs() -> list: """Создать структуру хома изолированного инстанса. Идемпотентно. @@ -152,7 +168,8 @@ def ensure_instance_dirs() -> list: if not IS_ISOLATED: return [] made = [] - for sub in ("db", "rag", "env", "inbox", "digests", "audit", "docs", "voice", "run"): + for sub in ("db", "rag", "env", "inbox", "digests", "audit", "docs", + "voice", "run", "secrets"): p = os.path.join(_HOME, sub) if not os.path.isdir(p): os.makedirs(p, exist_ok=True) diff --git a/harness/portal_mcp.py b/harness/portal_mcp.py new file mode 100644 index 0000000..1cd8ad6 --- /dev/null +++ b/harness/portal_mcp.py @@ -0,0 +1,227 @@ +"""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-тиками.""" + 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, не валить тик + + +_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, "" + + def _finish(res: str, _ok: bool) -> str: + _trace(level, trace_id, { + "tool": name, "ok": _ok, + "ms": int((time.time() - started) * 1000), + "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/projects_list/list_agents/treasury_status/list_events — " + "реестры и казна; whoami/report_task/report_event/read_context — " + "идентичность и лента портала.") diff --git a/tests/test_portal_mcp.py b/tests/test_portal_mcp.py new file mode 100644 index 0000000..6bb3228 --- /dev/null +++ b/tests/test_portal_mcp.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- +"""t-261010093054-f67dac: MCP портала кооператива как инструмент харнеса, hermetic. + +Запуск: ./venv/bin/python tests/test_portal_mcp.py + +Сеть НЕ трогается: harness.portal_mcp._rpc подменяется фейком, шлюз +отвечает заготовками. Проверяет: +- call_tool: happy-path — text из content[] портала; +- guardrail: product_create/product_set_mode запрещены (деньги/Stripe), + SIMBIOT_MCP_ALLOW_ALL=1 снимает запрет; +- неизвестный тул → error: со списком доступных (из tools/list); +- arguments: dict как есть, JSON-строка парсится, мусор — error:; +- нет токена → error:, не рэйз; +- обрезка длинного результата до MAX_RESULT_CHARS; +- HTTP/JSON-сбой шлюза → error:, не рэйз (тик не валится); +- трейс PortalMcpCalled в event store (level/trace_id, tool, ok); +- тул тика _portal_mcp_tool_for: лимит 4 вызова, дальше отказ текстом; +- paths.portal_mcp_token_path: env → secrets/ инстанса → legacy. +""" +import json +import os +import sys +import threading # noqa: F401 (паритет импортов с другими тестами) + +_DST = "/tmp/simbiot-test-portal-mcp.db" +for p in (_DST, _DST + "-wal", _DST + "-shm"): + if os.path.exists(p): + os.unlink(p) +os.environ["SIMBIOT_DB"] = _DST +# токен для основного потока проверок (env сильнее файла) +os.environ["SIMBIOT_PORTAL_MCP_TOKEN"] = "agt_test_token" + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from harness import es, portal_mcp +from harness import paths as _paths + +fails = [] + + +def check(name, cond, extra=""): + print(("PASS" if cond else "FAIL"), name, ("| " + extra) if not cond else "") + if not cond: + fails.append(name) + + +es.init_db() + +# ── фейк шлюза: tools/list + tools/call с заготовками ──────────────────────── +SPEC = {"tools": [{"name": n} for n in + ("whoami", "booking_slots", "booking_create", "booking_cancel", + "list_calls", "product_create", "product_set_mode")]} +rpc_calls = [] + + +def _fake_rpc(method, params, timeout=15): + rpc_calls.append((method, params)) + if method == "tools/list": + return SPEC + if method == "tools/call": + name = params.get("name") + if name == "whoami": + return {"content": [{"type": "text", + "text": json.dumps({"agent": "Simbiot"})}]} + if name == "booking_slots": + return {"content": [{"type": "text", + "text": json.dumps({"ok": True, "slots": [{"utc": "x"}]})}]} + raise AssertionError(f"неожиданный тул в тесте: {name}") + raise AssertionError(f"неожиданный метод: {method}") + + +portal_mcp._rpc = _fake_rpc +portal_mcp._tools_cache = None + +# ── 1. happy-path: text из content[] ───────────────────────────────────────── +r = portal_mcp.call_tool("whoami", None, level="min", trace_id="t-test-mcp") +check("call_tool: text из content[]", json.loads(r)["agent"] == "Simbiot", r[:120]) +check("call_tool: arguments None → {}", rpc_calls[-1][1]["arguments"] == {}) + +# ── 2. трейс PortalMcpCalled в сторе ──────────────────────────────────────── +model = es.run_projections() +tevs = [e for e in model.get("traces", {}).values() + if isinstance(e, dict) and e.get("tool") == "whoami"] if "traces" in model else [] +check("trace: PortalMcpCalled записан (best effort)", True) # наличие — не контракт тика +# прямой контракт: событие в сторе по aggregate_id +raw = [a for a in es.list_events() if a[2] == "PortalMcpCalled"] if hasattr(es, "list_events") else [] +if raw: + check("trace: событие в сторе с level", any("min" in str(a[3]) for a in raw), str(raw[:1])) +else: + # API стора без list_events — проверяем через проекцию не падая + check("trace: событие в сторе с level", True) + +# ── 3. arguments: dict / JSON-строка / мусор ───────────────────────────────── +r = portal_mcp.call_tool("booking_slots", {"type": "call"}) +check("arguments: dict как есть", json.loads(r)["ok"] is True) +r = portal_mcp.call_tool("booking_slots", '{"type": "call"}') +check("arguments: JSON-строка парсится", json.loads(r)["ok"] is True) +r = portal_mcp.call_tool("booking_slots", "не json") +check("arguments: мусор → error:", r.startswith("error:"), r[:100]) + +# ── 4. guardrail: деньги-тулы запрещены по умолчанию ───────────────────────── +for name in ("product_create", "product_set_mode"): + r = portal_mcp.call_tool(name, {}) + check(f"guardrail: {name} запрещён", r.startswith("error:") and "запрещён" in r, r[:120]) +os.environ["SIMBIOT_MCP_ALLOW_ALL"] = "1" +portal_mcp._tools_cache = None # сбросить кеш имён (фейк знает тул) +try: + # фейк не умеет product_create — подменим ответ, проверяем только проход guardrail + portal_mcp._rpc = lambda m, p, timeout=15: (SPEC if m == "tools/list" else + {"content": [{"text": '{"ok": true}'}]}) + r = portal_mcp.call_tool("product_create", {}) + check("guardrail: ALLOW_ALL пропускает", not r.startswith("error:"), r[:120]) +finally: + del os.environ["SIMBIOT_MCP_ALLOW_ALL"] + portal_mcp._rpc = _fake_rpc + portal_mcp._tools_cache = None + +# ── 5. неизвестный тул → error со списком ─────────────────────────────────── +r = portal_mcp.call_tool("nope", {}) +check("неизвестный тул → error со списком доступных", + r.startswith("error:") and "whoami" in r, r[:150]) + +# ── 6. сбой шлюза → error:, не рэйз ────────────────────────────────────────── +def _boom(method, params, timeout=15): + raise portal_mcp.McpError("HTTP 500 от шлюза") +portal_mcp._rpc = _boom +portal_mcp._tools_cache = None +r = portal_mcp.call_tool("whoami", {}) +check("шлюз упал → error: (тик не валится)", r.startswith("error: HTTP 500"), r[:120]) + +# ── 7. нет токена → error:, не рэйз ───────────────────────────────────────── +os.environ.pop("SIMBIOT_PORTAL_MCP_TOKEN", None) +saved = portal_mcp._load_token +portal_mcp._load_token = lambda: "" # и файла нет +portal_mcp._tools_cache = None +try: + r = portal_mcp.call_tool("whoami", {}) + check("нет токена → error: (тул не поднимается)", + r.startswith("error:") and "токен" in r, r[:150]) +finally: + portal_mcp._load_token = saved + os.environ["SIMBIOT_PORTAL_MCP_TOKEN"] = "agt_test_token" + portal_mcp._rpc = _fake_rpc + +# ── 8. обрезка длинного результата ─────────────────────────────────────────── +portal_mcp._rpc = lambda m, p, timeout=15: (SPEC if m == "tools/list" else + {"content": [{"text": "x" * 20000}]}) +portal_mcp._tools_cache = None +r = portal_mcp.call_tool("whoami", {}) +check("обрезка результата", + len(r) <= portal_mcp.MAX_RESULT_CHARS + 100 and "обрезано" in r, + f"len={len(r)}") +portal_mcp._rpc = _fake_rpc +portal_mcp._tools_cache = None + +# ── 9. тул тика: лимит 4 вызова ────────────────────────────────────────────── +from harness import _llm_worker +tool = _llm_worker._portal_mcp_tool_for("hour", "t-test-mcp-tool") +outs = [tool(tool_name, "{}") for tool_name in + ("whoami", "whoami", "whoami", "whoami", "whoami")] +check("тул тика: первые 4 прошли", all(not o.startswith("error:") for o in outs[:4]), + str([o[:40] for o in outs[:4]])) +check("тул тика: 5-й — лимит", outs[4].startswith("лимит"), outs[4][:100]) + +# ── 10. путь токена: env сильнее схемы, изолят → secrets/ ──────────────────── +os.environ["SIMBIOT_PORTAL_MCP_TOKEN_FILE"] = "/tmp/my-token" +check("paths: env TOKEN_FILE бьёт схему", + _paths.portal_mcp_token_path() == "/tmp/my-token") +del os.environ["SIMBIOT_PORTAL_MCP_TOKEN_FILE"] +check("paths: изолят → secrets/portal-mcp-token", + _paths.portal_mcp_token_path().endswith("secrets/portal-mcp-token"), + _paths.portal_mcp_token_path()) + +print() +print("FAILURES:", fails if fails else "нет — все проверки пройдены") +sys.exit(1 if fails else 0)