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:
co-authored by
Claude Sonnet 5.5
parent
c93b25741e
commit
dee2f72c46
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user