"""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 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 ) 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()