---
summary: How the MCP transport, the tool registry, the permission filter, and the
  cloud-MCP aggregator fit together inside ScaiGrid.
title: Architecture
path: concepts/architecture
status: published
---

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

```mermaid
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
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 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 `ScaiGridError` is serialised with its own `code` and `message`. 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.
