t-261010093054: MCP портала кооператива как стандартный тул харнеса — portal_mcp (booking_slots/booking_create, реестры, казна, лента), guardrail на деньги-тулы, 4 вызова/тик, трейс PortalMcpCalled, cli mcp-call, тест 18 PASS
This commit is contained in:
parent
57a0f97318
commit
dfa3b2d8a8
7 changed files with 523 additions and 3 deletions
23
README.md
23
README.md
|
|
@ -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
19
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("--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(
|
||||||
|
|
|
||||||
|
|
@ -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 ушли пустыми).
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
227
harness/portal_mcp.py
Normal 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
175
tests/test_portal_mcp.py
Normal 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)
|
||||||
Loading…
Reference in a new issue