t-261010093054: MCP портала кооператива как стандартный тул харнеса — portal_mcp (booking_slots/booking_create, реестры, казна, лента), guardrail на деньги-тулы, 4 вызова/тик, трейс PortalMcpCalled, cli mcp-call, тест 18 PASS

This commit is contained in:
Simbiot Harness 2026-10-10 10:19:09 +00:00
parent 57a0f97318
commit dfa3b2d8a8
7 changed files with 523 additions and 3 deletions

View file

@ -275,6 +275,29 @@ sqlite, rag.db ~12.5k чанков по контексту/навыкам, herme
Ручной вызов: `cli.py rag-query "вопрос" -k 5 [--db ...] [--json]`. Ручной вызов: `cli.py rag-query "вопрос" -k 5 [--db ...] [--json]`.
Выключить: `RAG_DISABLED=1`. Выключить: `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` → `<home>/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) ## Хард-кап шагов тика (e-260930031800)
Мин-воркер уходил в исследовательские цепочки (90+ tool-вызовов) и умирал Мин-воркер уходил в исследовательские цепочки (90+ tool-вызовов) и умирал

19
cli.py
View file

@ -181,6 +181,12 @@ def main():
p_rag.add_argument("--db", default=None, help="база RAG (по умолчанию rag.db; hermes.db — общая)") p_rag.add_argument("--db", default=None, help="база RAG (по умолчанию rag.db; hermes.db — общая)")
p_rag.add_argument("--json", action="store_true", help="вывести машинно-читаемо") 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() args = ap.parse_args()
if args.cmd == "init": if args.cmd == "init":
@ -422,6 +428,19 @@ def main():
for c in chunks: for c in chunks:
print(f"\n═══ {c['score']:.3f} | {c['source']} ═══") print(f"\n═══ {c['score']:.3f} | {c['source']} ═══")
print(c["text"][:rag_lookup.TIMEOUT * 100 or 900]) 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": elif args.cmd == "escalate":
es.init_db() es.init_db()
eid, to_level = commands.raise_escalation( eid, to_level = commands.raise_escalation(

View file

@ -41,6 +41,37 @@ def _rag_tool_for(level: str, trace_id: str):
return rag_lookup 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): def _wire_tool_budget(agent, max_calls: int = 30):
"""Хард-кап тул-коллов на тик (e-260930031800: мин-воркер уходил в """Хард-кап тул-коллов на тик (e-260930031800: мин-воркер уходил в
90+ шагов исследования и умирал по таймауту 300с). 90+ шагов исследования и умирал по таймауту 300с).
@ -260,6 +291,12 @@ def main():
tools.append(_rag_tool_for(level, trace_id)) tools.append(_rag_tool_for(level, trace_id))
except Exception: except Exception:
pass # RAG — best effort: вендор не должен валить тик 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 и # stdout воркера не читается (subprocess.run с capture_output и
# длинным выводом переполняет pipe → BrokenPipeError в печати, # длинным выводом переполняет pipe → BrokenPipeError в печати,
# результат тика терялся, дайджесты 04:11-04:17 ушли пустыми). # результат тика терялся, дайджесты 04:11-04:17 ушли пустыми).

View file

@ -82,6 +82,16 @@ LEVELS = {
"знаний кооператива (контекст, проекты, порталы, встречи, документы). " "знаний кооператива (контекст, проекты, порталы, встречи, документы). "
"Если вопрос про кооператив/продукты/документы — сначала rag_lookup " "Если вопрос про кооператив/продукты/документы — сначала rag_lookup "
"(1 запрос за тик, формулируй целиком), потом отвечай по найденному. " "(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, " "ВАЖНО: НЕ исследуй исходники харнеса (scheduler.py, commands.py, es.py, "
"isolated_runner.py) и не разбирай падения тиков — это зона верхних уровней. " "isolated_runner.py) и не разбирай падения тиков — это зона верхних уровней. "
"Отвечай из контекста тика, максимум пара шагов, иначе таймаут тика. " "Отвечай из контекста тика, максимум пара шагов, иначе таймаут тика. "
@ -121,7 +131,15 @@ LEVELS = {
"Файлы/уведомления в чат — только идемпотентно: cli.py tg-deliver-file " "Файлы/уведомления в чат — только идемпотентно: cli.py tg-deliver-file "
"<путь> -c \"подпись\", cli.py tg-notify \"текст\" -k <ключ> (повтор — SKIP). " "<путь> -c \"подпись\", cli.py tg-notify \"текст\" -k <ключ> (повтор — SKIP). "
"Прямые curl/api.telegram отправки из shell запрещены — нет защиты " "Прямые 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, can_shell=True,
), ),
"day": CycleLevel( "day": CycleLevel(
@ -132,7 +150,11 @@ LEVELS = {
"Открытые эскалации (секция ЭСКАЛАЦИИ) — приоритет: это важное, " "Открытые эскалации (секция ЭСКАЛАЦИИ) — приоритет: это важное, "
"поднятое с нижних уровней; закрой cli.py escalation-ack <id>. " "поднятое с нижних уровней; закрой cli.py escalation-ack <id>. "
"Файлы в чат — только cli.py tg-deliver-file (идемпотентно, " "Файлы в чат — только 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, can_shell=True,
), ),
} }

View file

@ -143,6 +143,22 @@ def voice_dir() -> str:
return "/var/lib/simbiot-harness/voice" # legacy 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: def ensure_instance_dirs() -> list:
"""Создать структуру хома изолированного инстанса. Идемпотентно. """Создать структуру хома изолированного инстанса. Идемпотентно.
@ -152,7 +168,8 @@ def ensure_instance_dirs() -> list:
if not IS_ISOLATED: if not IS_ISOLATED:
return [] return []
made = [] 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) p = os.path.join(_HOME, sub)
if not os.path.isdir(p): if not os.path.isdir(p):
os.makedirs(p, exist_ok=True) os.makedirs(p, exist_ok=True)

227
harness/portal_mcp.py Normal file
View file

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

175
tests/test_portal_mcp.py Normal file
View file

@ -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)