feat(org): model grants, org memory, MCP gateway (v2 phases A1-3..5)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mjalmoudy94
2026-10-14 15:51:44 +03:30
co-authored by Claude Sonnet 5.5
parent c93b25741e
commit dee2f72c46
39 changed files with 9060 additions and 95 deletions
+21 -1
View File
@@ -1 +1,21 @@
"""MCP gateway (owned by Agent 1)."""
"""The MCP gateway package (owned by Agent 1, PLAN-v2 §6).
``gateway`` holds the tools and their rules; ``server`` binds them to the
official SDK. ``python -m app.interfaces.mcp.server`` starts the gateway.
"""
from app.interfaces.mcp.gateway import (
TOOL_SPECS,
McpCallResult,
McpError,
McpGateway,
build_gateway,
)
__all__ = [
"TOOL_SPECS",
"McpCallResult",
"McpError",
"McpGateway",
"build_gateway",
]
+619
View File
@@ -0,0 +1,619 @@
"""The MCP tools themselves — framework-free, so they can be called directly.
``app.interfaces.mcp.server`` adapts these to the official SDK; this module
holds the actual work and is what the tests exercise. Four rules are enforced
here, once, for every tool:
1. **The organization comes from the token, never from the arguments.** A tool
body receives an authenticated ``TokenIdentity`` and uses its
``organization_id``; an ``organization_id`` in the arguments is ignored, so an
agent cannot ask about a tenant it was not issued for.
2. **A scope is checked before the work starts.** Not after.
3. **Every call is audited** (``mcp.tool_call`` with the token prefix, the tool,
and whether it succeeded), rate-limited, and its output redacted.
4. **An error is an answer, not a crash**: the agent gets a plain message, and
the exception never reaches the transport.
"""
from __future__ import annotations
import json
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from app.application.container_org import (
build_memory_service,
build_model_grant_service,
)
from app.application.services.mcp_service import McpService, TokenIdentity
from app.core.config import settings
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
from app.core.redact import redact
from app.domain.memory.models import MemoryKind
from app.infrastructure.error_log import record_error
@dataclass(frozen=True, slots=True)
class ToolSpec:
"""One tool: its name, the scope it needs, and its JSON schema."""
name: str
scope: str
description: str
parameters: dict[str, Any]
TOOL_SPECS: tuple[ToolSpec, ...] = (
ToolSpec(
name="app_get_profile",
scope="app:read",
description="پروفایل اپلیکیشن این سازمان: نام، نوع، زبان، فریم‌ورک، "
"دستورهای ساخت و تست، پورت و مسیر سلامت.",
parameters={
"type": "object",
"properties": {},
"additionalProperties": False,
},
),
ToolSpec(
name="tasks_list",
scope="tasks:read",
description="فهرست کارهای این سازمان، به ترتیب جدیدترین.",
parameters={
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "وضعیت اختیاری برای فیلتر (new، running، done، …)",
},
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 25},
},
"additionalProperties": False,
},
),
ToolSpec(
name="tasks_get",
scope="tasks:read",
description="یک کار بر اساس شناسهٔ آن، در همین سازمان.",
parameters={
"type": "object",
"properties": {
"task_id": {"type": "string", "description": "شناسهٔ کار"},
},
"required": ["task_id"],
"additionalProperties": False,
},
),
ToolSpec(
name="tasks_create",
scope="tasks:write",
description="ثبت یک کار تازه برای مدیر سازمان؛ مدیر خودش آن را تقسیم "
"و به کارمندان می‌سپارد.",
parameters={
"type": "object",
"properties": {
"title": {"type": "string", "description": "عنوان کوتاه کار"},
"description": {"type": "string", "description": "شرح کامل درخواست"},
},
"required": ["title"],
"additionalProperties": False,
},
),
ToolSpec(
name="memory_search",
scope="memory:read",
description="جست‌وجو در حافظهٔ سازمان: قوانین، ترجیحات، درس‌ها و نقشهٔ کد.",
parameters={
"type": "object",
"properties": {
"query": {"type": "string", "description": "واژه‌های جست‌وجو"},
"kind": {
"type": "string",
"description": "نوع اختیاری: rule، preference، lesson، fact، code_map",
},
"limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 10},
},
"additionalProperties": False,
},
),
ToolSpec(
name="memory_write",
scope="memory:write",
description="یادآوری یک واقعیت یا ترجیح به سازمان. گذرواژه و کلید "
"هرگز ذخیره نمی‌شود.",
parameters={
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["fact", "preference"],
"description": "فقط «fact» یا «preference»",
},
"content": {"type": "string", "description": "یک جملهٔ کوتاه"},
},
"required": ["kind", "content"],
"additionalProperties": False,
},
),
ToolSpec(
name="knowledge_search",
scope="memory:read",
description="جست‌وجو در منابع دانش سازمان (اسناد بلند).",
parameters={
"type": "object",
"properties": {
"query": {"type": "string", "description": "واژه‌های جست‌وجو"},
"limit": {"type": "integer", "minimum": 1, "maximum": 20, "default": 5},
},
"required": ["query"],
"additionalProperties": False,
},
),
ToolSpec(
name="pipeline_status",
scope="delivery:read",
description="وضعیت خط تحویل (استقرار و دمو). در این نسخه در دسترس نیست.",
parameters={
"type": "object",
"properties": {},
"additionalProperties": False,
},
),
)
#: The only kinds an external agent may write. A rule is the organization's own
#: to set through the UI, and a lesson is the engine's to learn.
MCP_WRITABLE_KINDS = frozenset({MemoryKind.FACT, MemoryKind.PREFERENCE})
class McpError(Exception):
"""A refusal the agent can read. Never carries a secret."""
def __init__(self, message: str, *, code: str = "mcp_error") -> None:
super().__init__(message)
self.code = code
@dataclass(slots=True)
class McpCallResult:
"""What one tool call produced: a redacted payload, or a readable refusal."""
ok: bool
payload: dict[str, Any]
error: str | None = None
code: str = "mcp_error"
def as_dict(self) -> dict[str, Any]:
if self.ok:
return self.payload
return {
"error": {"code": self.code, "message": self.error or "عملیات ناموفق بود."}
}
def to_json(self) -> str:
"""The JSON text an agent receives. Always redacted, always short."""
return json.dumps(redact(self.as_dict()), ensure_ascii=False, default=str)
class McpGateway:
"""Executes the tools for one authenticated token.
``session_factory`` is injectable so the tests can point the gateway at their
in-memory database; production passes nothing and gets the application's own
factory, exactly like the workers.
"""
def __init__(self, *, session_factory=None, rate_limiter=None) -> None:
from app.application.services.mcp_service import TokenRateLimiter
self._session_factory = session_factory
self._limiter = rate_limiter or TokenRateLimiter()
# -- public API ----------------------------------------------------------
async def call(
self,
*,
tool: str,
arguments: dict[str, Any] | None = None,
token: str,
identity: TokenIdentity | None = None,
) -> McpCallResult:
"""Run one tool for one token. Every failure mode is an answer."""
spec = _SPECS_BY_NAME.get(tool)
if spec is None:
return McpCallResult(ok=False, payload={}, error=f"ابزار ناشناخته: {tool}")
args = arguments or {}
if not settings.mcp_gateway_enabled:
# The owner's kill switch. Nothing is attributable yet, so this lands
# in the error ledger rather than in one organization's audit trail.
await _note("mcp.gateway_disabled", f"tool={spec.name}")
return self._fail(
"دسترسی MCP از سوی مالک سامانه غیرفعال شده است.",
code="mcp_disabled",
)
factory = self._session_factory or _default_factory()
async with factory() as session:
service = McpService(session)
identity = identity or await service.verify(token)
if identity is None:
# A token that does not exist, was revoked, or expired is one
# answer — and it is unattributable, so it goes to the error
# ledger where a human can see the pattern.
await _note("mcp.invalid_token", f"tool={spec.name}")
return self._fail(
"توکن نامعتبر یا منقضی است.", code="invalid_token"
)
async def refuse(message: str, code: str) -> McpCallResult:
"""Every refusal from here on is audited against this organization.
The audit row is committed inside the refusal: a denial nobody can
see afterwards is not an audit trail, it is a hope.
"""
await self._audit(
session, identity=identity, spec=spec, ok=False, error=message
)
await session.commit()
return McpCallResult(ok=False, payload={}, error=message, code=code)
if not identity.allows(spec.scope):
return await refuse(
"این توکن به این ابزار دسترسی ندارد.", "scope_denied"
)
policy = await build_model_grant_service(session).get_org_policy(
identity.organization_id
)
if not policy.mcp_enabled:
return await refuse(
"دسترسی MCP این سازمان غیرفعال است.", "org_disabled"
)
if not self._limiter.allow(identity.token_id):
return await refuse(
"تعداد درخواست‌ها بیش از حد مجاز است؛ کمی بعد تلاش کنید.",
"rate_limited",
)
handler = _HANDLERS[spec.name]
try:
payload = await handler(self, session=session, identity=identity, **args)
except (NotFoundError, PermissionDeniedError, ValidationError, McpError) as exc:
message = str(getattr(exc, "message", exc))
await self._audit(
session, identity=identity, spec=spec, ok=False, error=message
)
await session.commit()
return McpCallResult(ok=False, payload={}, error=message, code="tool_failed")
except TypeError:
# An argument shape the tool did not expect: the agent's fault,
# not the platform's.
return await refuse(
"آرگومان‌های ابزار نامعتبر است.", "bad_arguments"
)
except Exception as exc: # noqa: BLE001 - an outage is an answer too
# Anything else is infrastructure, and its message may contain a
# DSN or a heap address. The agent gets a plain sentence; the
# operator gets the real error in the error console.
await record_error(
kind="mcp.tool_crashed",
message=str(exc),
exc=exc,
organization_id=identity.organization_id,
metadata={"tool": spec.name, "prefix": identity.prefix},
)
await self._audit(
session, identity=identity, spec=spec, ok=False, error="internal"
)
await session.commit()
return self._fail(
"خطای داخلی سامانه؛ این خطا ثبت شد.", code="internal_error"
)
await service.touch(identity.token_id)
await self._audit(session, identity=identity, spec=spec, ok=True)
await session.commit()
return McpCallResult(ok=True, payload=payload)
# -- helpers -------------------------------------------------------------
@staticmethod
def _fail(message: str, *, code: str) -> McpCallResult:
"""A refusal is a normal answer: the agent gets a message it can act on."""
return McpCallResult(ok=False, payload={}, error=message, code=code)
async def _audit(
self,
session,
*,
identity: TokenIdentity,
spec: ToolSpec,
ok: bool,
error: str = "",
) -> None:
"""One audit row per call. Never allowed to fail the call itself."""
from app.application.services.audit_service import record_audit
try:
await record_audit(
session,
action="mcp.tool_call",
organization_id=identity.organization_id,
actor_user_id=None,
target_type="mcp_tool",
target_id=spec.name,
metadata={
"prefix": identity.prefix,
"scope": spec.scope,
"ok": ok,
"error": error[:200],
},
ip="mcp",
user_agent=f"mcp/{identity.prefix}",
)
except Exception: # noqa: BLE001 - auditing must not fail a tool call
return
# ---------------------------------------------------------------------------
# Tool bodies
# ---------------------------------------------------------------------------
async def _app_profile(
gateway: McpGateway, *, session, identity: TokenIdentity, **_ignored: Any
) -> dict[str, Any]:
from app.infrastructure.repositories.app_profile import AppProfileRepository
from app.infrastructure.repositories.projects import ProjectRepository
profile = await AppProfileRepository(session).get_for_org(identity.organization_id)
if profile is None:
raise NotFoundError("اپلیکیشنی برای این سازمان یافت نشد.")
project = await ProjectRepository(session).get(profile.project_id)
return {
"project_name": project.name if project else "",
"project_slug": project.slug if project else "",
"app_type": profile.app_type,
"language": profile.language,
"framework": profile.framework,
"runtime_version": profile.runtime_version,
"package_manager": profile.package_manager,
"setup_command": profile.setup_command,
"build_command": profile.build_command,
"test_command": profile.test_command,
"start_command": profile.start_command,
"app_port": profile.app_port,
"healthcheck_path": profile.healthcheck_path,
"default_branch": profile.default_branch,
"git_source": profile.git_source,
"repo_url": profile.external_repo_url,
}
async def _tasks_list(
gateway: McpGateway,
*,
session,
identity: TokenIdentity,
status: str | None = None,
limit: int = 25,
**_ignored: Any,
) -> dict[str, Any]:
from app.application.container import build_workforce_service
from app.domain.workforce.models import TaskStatus
service = build_workforce_service(session)
parsed: TaskStatus | None = None
if status:
try:
parsed = TaskStatus(str(status).strip().lower())
except ValueError as exc:
raise ValidationError("وضعیت کار نامعتبر است.") from exc
tasks = await service.list_tasks(
identity.organization_id, status=parsed, limit=max(1, min(int(limit), 100))
)
return {
"tasks": [
{
"id": task.id,
"title": task.title,
"status": str(task.status),
"created_at": task.created_at.isoformat() if task.created_at else None,
"assigned_employee_id": task.assigned_employee_id,
}
for task in tasks
]
}
async def _tasks_get(
gateway: McpGateway, *, session, identity: TokenIdentity, task_id: str, **_ignored: Any
) -> dict[str, Any]:
from app.application.container import build_workforce_service
if not task_id:
raise ValidationError("شناسهٔ کار لازم است.")
service = build_workforce_service(session)
task = await service.get_task_or_404(
organization_id=identity.organization_id, task_id=task_id
)
return {
"task": {
"id": task.id,
"title": task.title,
"description": task.description,
"status": str(task.status),
"created_at": task.created_at.isoformat() if task.created_at else None,
"project_id": task.project_id,
}
}
async def _tasks_create(
gateway: McpGateway,
*,
session,
identity: TokenIdentity,
title: str,
description: str = "",
**_ignored: Any,
) -> dict[str, Any]:
"""A user order to the manager: the current public signature, untouched."""
from app.application.container import build_workforce_service
if not title or not title.strip():
raise ValidationError("عنوان کار لازم است.")
creator = await McpService(session).creator_of(identity.token_id)
service = build_workforce_service(session)
task = await service.create_task(
organization_id=identity.organization_id,
created_by_user_id=creator or "",
title=title,
description=description,
)
return {"task": {"id": task.id, "title": task.title, "status": str(task.status)}}
async def _memory_search(
gateway: McpGateway,
*,
session,
identity: TokenIdentity,
query: str = "",
kind: str | None = None,
limit: int = 10,
**_ignored: Any,
) -> dict[str, Any]:
service = build_memory_service(session)
items = await service.list(
identity.organization_id, kind=kind, search=query or None
)
return {
"memories": [
{
"id": memory.id,
"kind": str(memory.kind),
"key": memory.key,
"content": memory.content,
"pinned": memory.pinned,
"confidence": memory.confidence,
}
for memory in items[: max(1, min(int(limit), 50))]
]
}
async def _memory_write(
gateway: McpGateway,
*,
session,
identity: TokenIdentity,
kind: str,
content: str,
**_ignored: Any,
) -> dict[str, Any]:
from app.domain.memory.models import coerce_kind
entry_kind = coerce_kind(kind)
if entry_kind not in MCP_WRITABLE_KINDS:
raise McpError("با توکن فقط «fact» یا «preference» قابل نوشتن است.")
service = build_memory_service(session)
memory_id = await service.record(
organization_id=identity.organization_id,
kind=str(entry_kind),
content=content,
source="user",
)
if memory_id is None: # pragma: no cover - the task cap cannot apply here
raise McpError("یادداشت ذخیره نشد.")
return {"memory": {"id": memory_id, "kind": str(entry_kind)}}
async def _knowledge_search(
gateway: McpGateway,
*,
session,
identity: TokenIdentity,
query: str,
limit: int = 5,
**_ignored: Any,
) -> dict[str, Any]:
from app.application.container import build_knowledge_service
if not query:
raise ValidationError("عبارت جست‌وجو لازم است.")
service = build_knowledge_service(session)
hits = await service.search(
organization_id=identity.organization_id,
query=query,
limit=max(1, min(int(limit), 20)),
)
return {
"results": [
{
"content": hit.get("content", ""),
"source_name": hit.get("source_name", ""),
"document_title": hit.get("document_title", ""),
}
for hit in hits
]
}
async def _pipeline_status(
gateway: McpGateway, *, session, identity: TokenIdentity, **_ignored: Any
) -> dict[str, Any]:
"""Delivery is another workstream's table; the tool exists and says so."""
return {"available": False, "message": "خط تحویل در این نسخه در دسترس نیست."}
_HANDLERS: dict[str, Callable[..., Awaitable[dict[str, Any]]]] = {
"app_get_profile": _app_profile,
"tasks_list": _tasks_list,
"tasks_get": _tasks_get,
"tasks_create": _tasks_create,
"memory_search": _memory_search,
"memory_write": _memory_write,
"knowledge_search": _knowledge_search,
"pipeline_status": _pipeline_status,
}
_SPECS_BY_NAME: dict[str, ToolSpec] = {spec.name: spec for spec in TOOL_SPECS}
def _default_factory():
from app.core.database import SessionFactory
return SessionFactory
async def _note(kind: str, detail: str) -> None:
"""Record an unattributable attempt in the error ledger, best-effort.
A bad token and a disabled gateway happen before the caller is known, so they
belong to nobody's audit trail — but they are exactly the patterns an operator
needs to see, which is what the error console is for.
"""
from app.infrastructure.error_log import record_error
try:
await record_error(kind=kind, message=detail, source="mcp")
except Exception: # noqa: BLE001 - telemetry must never fail a call
return
def build_gateway(*, session_factory=None) -> McpGateway:
"""The gateway the server and the tests both use.
Each gateway owns its own in-memory rate limiter: one process, one budget.
"""
return McpGateway(session_factory=session_factory)
__all__ = [
"MCP_WRITABLE_KINDS",
"TOOL_SPECS",
"McpCallResult",
"McpError",
"McpGateway",
"build_gateway",
]
+170 -6
View File
@@ -1,14 +1,178 @@
"""MCP gateway entry point (owned by Agent 1). Foundation stub."""
"""The MCP gateway server: the official SDK, bound to our tools.
This module is the *adapter*, not the logic. The tools live in
``app.interfaces.mcp.gateway`` (framework-free and directly testable); what
happens here is transport only:
* a ``MCPServer`` (streamable HTTP) with the eight tools registered, each with
the argument schema the gateway validates;
* every call resolves the caller's token from the ``Authorization: Bearer``
header — never from the arguments — and hands it to the gateway;
* a refusal raises ``ToolError``, which the transport reports as an ``isError``
tool result: an agent sees "this token does not have that scope" instead of a
stack trace;
* ``main()`` binds ``MCP_GATEWAY_HOST:MCP_GATEWAY_PORT`` — what ``pnpm mcp:dev``
runs. Putting Caddy in front of it is the owner's job (see
``services/mcp-gateway/README.md``).
"""
from __future__ import annotations
from typing import Any
def main() -> None:
raise SystemExit(
"MCP gateway is not implemented yet "
"(see docs/plan/TODO-agent-1-org-platform.md, Phase 5)"
from mcp.server.mcpserver.context import Context
from mcp.server.mcpserver.exceptions import ToolError
from app.core.config import settings
from app.core.redact import redact
from app.interfaces.mcp.gateway import McpCallResult, McpGateway, build_gateway
#: How a client authenticates. One header, one token, no query parameter: a
#: token in a URL ends up in proxy logs and browser history.
BEARER_PREFIX = "bearer "
def _bearer_token(ctx: Any) -> str:
"""The bearer token of this request, or an empty string.
``ctx.headers`` raises when there is no request at all (a stdio transport, or
a direct call in a test), so the read is defensive by design: no request
means no token, which means no tool runs.
"""
try:
headers = getattr(ctx, "headers", None) if ctx is not None else None
except ValueError: # pragma: no cover - "Context not available"
return ""
if not headers:
return ""
raw = headers.get("authorization") or headers.get("Authorization") or ""
if isinstance(raw, bytes): # pragma: no cover - transports hand us str
raw = raw.decode("utf-8", "ignore")
if raw.lower().startswith(BEARER_PREFIX):
return raw[len(BEARER_PREFIX) :].strip()
return raw.strip()
def _build_server(gateway: McpGateway) -> Any:
"""Build the SDK server with our eight tools registered."""
from mcp.server.mcpserver import MCPServer
server: Any = MCPServer(
name="smart-org-gateway",
version="1.0.0",
instructions=(
"دسترسی به یک سازمان در سامانه سازمان هوشمند. سازمان از توکن خوانده "
"می‌شود، نه از آرگومان‌ها؛ توکنی که محدودهٔ لازم را نداشته باشد رد "
"می‌شود."
),
)
async def _dispatch(tool: str, ctx: Any, **arguments: Any) -> dict[str, Any]:
result: McpCallResult = await gateway.call(
tool=tool, arguments=arguments, token=_bearer_token(ctx)
)
if not result.ok:
raise ToolError(result.error or "عملیات ناموفق بود.")
# Redacted on the way out as well as on the way in: a tool may return a
# stored value that predates a redaction rule, and the agent is outside
# the platform's trust boundary.
payload: dict[str, Any] = redact(result.payload)
return payload
async def app_get_profile(ctx: Context | None = None) -> dict[str, Any]:
"""پروفایل اپلیکیشن سازمان: نام، نوع، زبان، فریم‌ورک، دستورها، پورت."""
return await _dispatch("app_get_profile", ctx)
async def tasks_list(
status: str | None = None, limit: int = 25, ctx: Context | None = None
) -> dict[str, Any]:
"""فهرست کارهای سازمان، به ترتیب جدیدترین."""
return await _dispatch("tasks_list", ctx, status=status, limit=limit)
async def tasks_get(task_id: str, ctx: Context | None = None) -> dict[str, Any]:
"""یک کار بر اساس شناسهٔ آن."""
return await _dispatch("tasks_get", ctx, task_id=task_id)
async def tasks_create(
title: str, description: str = "", ctx: Context | None = None
) -> dict[str, Any]:
"""ثبت یک کار تازه برای مدیر سازمان."""
return await _dispatch("tasks_create", ctx, title=title, description=description)
async def memory_search(
query: str = "", kind: str | None = None, limit: int = 10,
ctx: Context | None = None,
) -> dict[str, Any]:
"""جست‌وجو در حافظهٔ سازمان: قوانین، ترجیحات، درس‌ها و نقشهٔ کد."""
return await _dispatch(
"memory_search", ctx, query=query, kind=kind, limit=limit
)
async def memory_write(
kind: str, content: str, ctx: Context | None = None
) -> dict[str, Any]:
"""یادآوری یک واقعیت یا ترجیح به سازمان."""
return await _dispatch("memory_write", ctx, kind=kind, content=content)
async def knowledge_search(
query: str, limit: int = 5, ctx: Context | None = None
) -> dict[str, Any]:
"""جست‌وجو در منابع دانش سازمان."""
return await _dispatch("knowledge_search", ctx, query=query, limit=limit)
async def pipeline_status(ctx: Context | None = None) -> dict[str, Any]:
"""وضعیت خط تحویل (استقرار و دمو)."""
return await _dispatch("pipeline_status", ctx)
for tool in (
app_get_profile,
tasks_list,
tasks_get,
tasks_create,
memory_search,
memory_write,
knowledge_search,
pipeline_status,
):
server.add_tool(
tool, name=tool.__name__, description=(tool.__doc__ or "").strip()
)
return server
def build_app(gateway: McpGateway | None = None) -> Any:
"""The ASGI app (streamable HTTP) for a uvicorn-style deployment."""
server = _build_server(gateway or build_gateway())
return server.streamable_http_app(
streamable_http_path="/mcp", host=settings.mcp_gateway_host
)
if __name__ == "__main__":
def main(argv: list[str] | None = None) -> None:
"""Run the gateway: ``python -m app.interfaces.mcp.server``."""
import sys
import uvicorn
host, port = settings.mcp_gateway_host, settings.mcp_gateway_port
if not settings.mcp_gateway_enabled:
# Starting anyway is right: the switch is the owner's, and the process
# answering "disabled" is more diagnosable than a missing port.
print(
"mcp gateway: MCP_GATEWAY_ENABLED=false — every call will be refused",
file=sys.stderr,
flush=True,
)
print(f"mcp gateway: http://{host}:{port}/mcp", file=sys.stderr, flush=True)
uvicorn.run(
_build_server(build_gateway()).streamable_http_app(
streamable_http_path="/mcp", host=host
),
host=host,
port=port,
log_level="info",
)
if __name__ == "__main__": # pragma: no cover - the entry point
main()