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