diff --git a/app/mcp/tools.py b/app/mcp/tools.py index b58a37a..d1b357f 100644 --- a/app/mcp/tools.py +++ b/app/mcp/tools.py @@ -72,6 +72,20 @@ return _run(fn) +def _list_run(out: Any) -> Any: + """Результат тула с аннотацией list[dict]: ошибку (dict) отдать как ToolError. + + FastMCP валидирует structured output по аннотации, поэтому dict ошибки в + list-туле превращался у агента в «1 validation error for ...Output: Input + should be a valid list» — причина ошибки терялась. Текст ToolError несёт + тот же JSON {"error": …, "detail": …}, что и у dict-тулов (mcp.md: ошибки + — данные, агент читает причину). + """ + if isinstance(out, dict) and "error" in out: + raise ToolError(json.dumps(out, ensure_ascii=False)) + return out + + def _patch(model_cls: type[BaseModel], **kwargs) -> BaseModel: """PATCH-схема только из переданных полей: None = «не менять» (exclude_unset в route-функции видит незаданные поля незаданными).""" @@ -124,7 +138,7 @@ or ql in (r.get("description") or "").lower() ] return out[: max(1, min(limit, LIMIT_CAP))] - return _admin_run(fn) + return _list_run(_admin_run(fn)) @mcp.tool() def source_create(name: str, label: str | None = None, description: str | None = None) -> dict: @@ -162,7 +176,7 @@ def fn(db): keys = admin_routes.list_keys(source_id, user=current_mcp_user(), db=db) return [k.model_dump(mode="json") for k in keys] - return _admin_run(fn) + return _list_run(_admin_run(fn)) @mcp.tool() def key_issue(source_id: int, name: str) -> dict: @@ -211,7 +225,7 @@ or ql in (r.get("description") or "").lower() ] return out[: max(1, min(limit, LIMIT_CAP))] - return _admin_run(fn) + return _list_run(_admin_run(fn)) @mcp.tool() def type_register( @@ -271,7 +285,7 @@ if enabled is not None: out = [r for r in out if r["enabled"] == enabled] return out[: max(1, min(limit, LIMIT_CAP))] - return _admin_run(fn) + return _list_run(_admin_run(fn)) @mcp.tool() def target_create( @@ -335,7 +349,7 @@ if enabled is not None: out = [r for r in out if r["enabled"] == enabled] return out[: max(1, min(limit, LIMIT_CAP))] - return _admin_run(fn) + return _list_run(_admin_run(fn)) @mcp.tool() def rule_create( @@ -416,13 +430,21 @@ user=current_mcp_user(), db=db, limit=limit, status_filter=status, source_id=source_id ) return [r.model_dump(mode="json") for r in rows] - return _admin_run(fn) + return _list_run(_admin_run(fn)) @mcp.tool() def event_get(event_id: str) -> dict: """Событие по id: конверт, статусы и доставки в каналы.""" def fn(db): - ev = admin_routes.get_event(uuid.UUID(event_id), user=current_mcp_user(), db=db) + try: + # _run ловит только HTTPException/ValidationError: сырой ValueError + # уходил агенту как «badly formed hexadecimal UUID string». + parsed_id = uuid.UUID(event_id) + except ValueError: + raise HTTPException( + status_code=422, detail=f"Некорректный id события: {event_id}" + ) from None + ev = admin_routes.get_event(parsed_id, user=current_mcp_user(), db=db) return ev.model_dump(mode="json") return _admin_run(fn) @@ -437,7 +459,7 @@ user=current_mcp_user(), db=db, limit=limit, status_filter=status, channel=channel ) return [r.model_dump(mode="json") for r in rows] - return _admin_run(fn) + return _list_run(_admin_run(fn)) @mcp.tool() def push_subscriptions_list(user_id: str | None = None, limit: int = 100) -> list[dict]: @@ -455,7 +477,7 @@ } for s in rows ] - return _admin_run(fn) + return _list_run(_admin_run(fn)) @mcp.tool() def push_subscription_delete(subscription_id: int) -> dict: @@ -480,7 +502,7 @@ def fn(db): rows = admin_routes.settings_registry(db) return [r.model_dump(mode="json") for r in rows] - return _admin_run(fn) + return _list_run(_admin_run(fn)) @mcp.tool() def settings_put(values: dict) -> dict: @@ -513,7 +535,7 @@ def fn(db): rows = my_events(user=current_mcp_user(), db=db, limit=max(1, min(limit, 200))) return [r.model_dump(mode="json") for r in rows] - return _run(fn) + return _list_run(_run(fn)) @mcp.tool() def my_push_subscriptions_list() -> list[dict]: @@ -523,7 +545,7 @@ def fn(db): rows = my_subscriptions(user=current_mcp_user(), db=db) return [r.model_dump(mode="json") for r in rows] - return _run(fn) + return _list_run(_run(fn)) @mcp.tool() def my_push_subscription_delete(subscription_id: int) -> dict: @@ -564,6 +586,14 @@ ) event = accept_event(db, src, envelope) db.commit() - celery_app.send_task("synapse.ingest", args=[str(event.id)]) + try: + celery_app.send_task("synapse.ingest", args=[str(event.id)]) + except Exception: # noqa: BLE001 — недоступная очередь не должна + # отдавать агенту сырой текст kombu («Retry limit exceeded…»): + # контрольный прогон не состоялся, говорим об этом прямо. + raise HTTPException( + status_code=503, + detail=f"Очередь недоступна: событие {event.id} создано, но воркеру не передано", + ) from None return {"id": str(event.id), "status": event.status, "source": source_name} return _admin_run(fn) \ No newline at end of file