# -*- 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"] # герметичность: поведение изолята не зависит от того, на каком инстансе # запущен тест (legacy-хост без SIMBIOT_HOME иначе даёт ложный FAIL) — # патчим _HOME/IS_ISOLATED как это делают остальные тесты схемы путей _saved = (_paths._HOME, _paths.IS_ISOLATED) _paths._HOME = "/tmp/simbiot-test-home" _paths.IS_ISOLATED = True try: check("paths: изолят → secrets/portal-mcp-token", _paths.portal_mcp_token_path().endswith("secrets/portal-mcp-token"), _paths.portal_mcp_token_path()) finally: _paths._HOME, _paths.IS_ISOLATED = _saved print() print("FAILURES:", fails if fails else "нет — все проверки пройдены") sys.exit(1 if fails else 0)