Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

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#

flowchart LR Client[MCP client<br/>Claude Desktop<br/>Cursor<br/>Custom agent] subgraph SG [ScaiGrid process] Mount[Starlette Mount] Mgr[StreamableHTTPSession<br/>Manager stateless] Server[mcp.server.Server<br/>list_tools handler<br/>call_tool handler] Reg[ToolRegistry +<br/>module.get_mcp_tools<br/>+ ScaiLink remote agg.] Svc[Services<br/>inference, models,<br/>sessions, ...] end Client -- POST /mcp<br/>Streamable HTTP --> Mount Mount --> Mgr Mgr --> Server Server --> Reg Reg --> Svc Mgr -- JSON-RPC frames<br/>tools / results --> Client

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#

  1. The MCP client connects to /mcp with an Authorization: Bearer ... header — the same header REST clients use.
  2. The MCP server receives the Starlette Request via request_context.request.
  3. resolve_mcp_user calls app.api.deps.get_current_user(request, session) — identical to the REST path, so JWT and sgk_ API keys both work.
  4. The resulting CurrentUser carries the user's roles, permissions, tenant, and partner.
  5. Every list_tools and call_tool reads 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:

  1. Core tools. Iterate ToolRegistry.all_tools(). Drop tools whose module_id points at a module not enabled for the caller's tenant. Drop tools whose required_permission the caller doesn't hold.
  2. Module-contributed tools. For each enabled module, call module.get_mcp_tools() and apply the same permission filter.
  3. Cloud MCP registry (via ScaiLink). If ScaiLink is loaded and the caller has scailink:remote.use, call list_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#

  1. Resolve user — same as list_tools.
  2. Dispatch by prefix. Tool names starting with remote. route to the ScaiLink remote aggregator; everything else goes through the local registry.
  3. Look up tool. Core registry first, then per-module get_mcp_tools(). Unknown name returns {"error": true, "code": "TOOL_NOT_FOUND"}.
  4. Permission check. The tool's required_permission (if any) is checked against the CurrentUser. Core enum names like models:use resolve through Permission; module-permission keys are checked via user.has_module_permission.
  5. Module access check. If the tool has a module_id, the caller's tenant must have that module enabled.
  6. Handler. A fresh async DB session is opened, a ToolContext is 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.
  7. Render. The result is JSON-encoded and wrapped in a single TextContent block.

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:

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def get_mcp_tools(self) -> list[McpToolDef]:
    return [
        McpToolDef(
            name="mymodule_action",
            description="Does X.",
            input_schema={"type": "object", "properties": {...}},
            handler=self._handle_action,
            required_permission="mymodule:execute",
        ),
    ]

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:

  1. Parses the name to extract scope (user / tenant), slug, and tool.
  2. Verifies the caller is allowed to invoke this registration (personal: must be the registrar; tenant: must hold scailink:remote.use).
  3. Opens (or reuses) an outbound MCP session to the upstream server.
  4. Forwards the call, optionally injecting the caller's user id depending on the registration's forward_user_id setting.
  5. 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 toolTOOL_NOT_FOUND. Most commonly a typo or a module that isn't enabled for the caller.
  • Permission deniedPERMISSION_DENIED. Caller authenticated but lacks the tool's required permission.
  • Module disabledMODULE_NOT_ENABLED. Tool exists in the registry but the caller's tenant doesn't have its module on.
  • Underlying service error → the service's ScaiGridError is serialised with its own code and message. Same codes you see from REST.
  • Remote MCP errorRemoteClientError (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.

Updated 2026-05-18 15:01:30 View source (.md) rev 12