Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
179 lines
7.0 KiB
Python
179 lines
7.0 KiB
Python
"""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()
|