Architecture
ScaiMCP is a thin module on top of ScaiGrid's existing services. There is no separate process and no separate auth — an MCP client is just another caller on the same FastAPI application.
Components#
The MCP server is mounted as a Starlette Mount at /mcp. The mount points at the streamable-HTTP session manager from the mcp package, which feeds JSON-RPC frames to a Server("scaigrid") that registers two handlers — list_tools and call_tool.
Auth flow#
- The MCP client connects to
/mcpwith anAuthorization: Bearer ...header — the same header REST clients use. - The MCP server receives the Starlette
Requestviarequest_context.request. resolve_mcp_usercallsapp.api.deps.get_current_user(request, session)— identical to the REST path, so JWT andsgk_API keys both work.- The resulting
CurrentUsercarries the user's roles, permissions, tenant, and partner. - Every
list_toolsandcall_toolreads this object to decide what to expose and whether to allow the call.
There is no MCP-specific token type. If you can call the REST API with a credential, you can call MCP with it.
list_tools pipeline#
For every catalog request, the server runs three passes in order:
- Core tools. Iterate
ToolRegistry.all_tools(). Drop tools whosemodule_idpoints at a module not enabled for the caller's tenant. Drop tools whoserequired_permissionthe caller doesn't hold. - Module-contributed tools. For each enabled module, call
module.get_mcp_tools()and apply the same permission filter. - Cloud MCP registry (via ScaiLink). If ScaiLink is loaded and the caller has
scailink:remote.use, calllist_remote_tools_for(user_id, tenant_id)and append the user's personal + tenant-shared registered tools.
The returned list is therefore caller-specific. Two users on the same tenant see different catalogs if their roles differ.
call_tool pipeline#
- Resolve user — same as
list_tools. - Dispatch by prefix. Tool names starting with
remote.route to the ScaiLink remote aggregator; everything else goes through the local registry. - Look up tool. Core registry first, then per-module
get_mcp_tools(). Unknown name returns{"error": true, "code": "TOOL_NOT_FOUND"}. - Permission check. The tool's
required_permission(if any) is checked against theCurrentUser. Core enum names likemodels:useresolve throughPermission; module-permission keys are checked viauser.has_module_permission. - Module access check. If the tool has a
module_id, the caller's tenant must have that module enabled. - Handler. A fresh async DB session is opened, a
ToolContextis built (user, session, FastAPI app, request id) and passed to the tool's handler. The handler returns a JSON-serialisable result. The session is committed on success. - Render. The result is JSON-encoded and wrapped in a single
TextContentblock.
Errors are caught and serialised: ScaiGridError becomes {"error": true, "code": ..., "message": ...}. Cloud MCP errors get the same shape via RemoteClientError.
Tool registration model#
A tool is an McpToolDef carrying:
name— globally unique within the catalog.description— what the model reads.input_schema— JSON Schema for the arguments.handler— async function(ToolContext, dict) -> Any.required_permission— string key (models:use,webhooks:manage, ...). Empty string means "any authenticated user".module_id— optional. If set, the tool only appears when that module is enabled.
Core tools are registered into ToolRegistry at module init by build_core_registry(). Module-contributed tools are returned per-call from module.get_mcp_tools().
State#
- No MCP-specific state. The transport is configured stateless — every request resolves the user and the catalog from scratch.
- No persisted tool state. Tools call existing services; whatever they persist lives where it always did.
- Cloud MCP sessions. ScaiLink maintains an outbound MCP client pool for cloud servers; ScaiMCP just calls into it. See ScaiLink's docs.
How it differs from calling REST#
| Concern | REST | MCP |
|---|---|---|
| Transport | HTTP request/response | JSON-RPC over streamable HTTP |
| Auth | Authorization header |
Same |
| Permission model | Same | Same |
| Accounting | Same | Same |
| Audit | Same | Same |
| Discovery | OpenAPI / docs | list_tools at runtime |
| Argument shape | URL + JSON body | JSON object matching input schema |
| Best for | Service code | Agent clients |
For an agent that uses an LLM to decide which tool to call, MCP is the right surface — runtime discovery and schema-typed arguments are exactly what the LLM needs. For everything else, call REST.
Where the trust boundary is#
The MCP transport authenticates the caller (user or service identity), not the tool. The catalog the caller sees is exactly the set of operations their identity is authorised to perform — so a leaked token grants exactly the permissions of the owning identity, nothing more. Treat MCP credentials like API keys: rotate them, scope them, audit them.
Statelessness and concurrency#
The StreamableHTTPSessionManager is configured stateless=True. That means:
- Two concurrent requests on the same client connection don't share any server-side context.
- A request that fails leaves nothing to clean up.
- Horizontal scaling works without sticky sessions — any pod can handle any request.
The downside is no server-pushed notifications between requests; everything is request-driven. That suits agent workloads (call, wait, call again) better than long-lived streaming subscriptions.
How modules contribute tools#
A module exposes tools by implementing get_mcp_tools() on its ScaiGridModule subclass:
1 2 3 4 5 6 7 8 9 10 | |
The module registry collects these at module load. ScaiMCP's list_tools iterates them per-request — there's no static registration, so a module that wants to gate its tool list by config or feature flags can do so at call time.
How the cloud-MCP path differs#
For remote.* names, the request never touches the local handler. ScaiMCP imports modules.scailink.services.remote_aggregator.invoke_remote_tool, which:
- Parses the name to extract scope (user / tenant), slug, and tool.
- Verifies the caller is allowed to invoke this registration (personal: must be the registrar; tenant: must hold
scailink:remote.use). - Opens (or reuses) an outbound MCP session to the upstream server.
- Forwards the call, optionally injecting the caller's user id depending on the registration's
forward_user_idsetting. - Returns the upstream
CallToolResult, which ScaiMCP flattens to text content.
The local handler sees nothing — the upstream owns schema, behaviour, and errors. That keeps cloud-MCP integration loosely coupled and means upstream changes don't require redeploying ScaiGrid.
Failure modes#
- Unknown tool →
TOOL_NOT_FOUND. Most commonly a typo or a module that isn't enabled for the caller. - Permission denied →
PERMISSION_DENIED. Caller authenticated but lacks the tool's required permission. - Module disabled →
MODULE_NOT_ENABLED. Tool exists in the registry but the caller's tenant doesn't have its module on. - Underlying service error → the service's
ScaiGridErroris serialised with its owncodeandmessage. Same codes you see from REST. - Remote MCP error →
RemoteClientError(or subclass) with the upstream message.
Network and transport errors propagate to the MCP client as JSON-RPC errors — they don't appear in the tool-result envelope.