Documentation source
Documentation source
Documentation source
The platform MCP surface for external API-key agents, downstream MCP connections, Apps UI bundles, and elicitation handoffs.
# MCP
## Overview
The MCP module lives in `features/mcp/`. It provides two related surfaces:
- An Amble MCP server, created by `createAmbleMcpServer()` in `features/mcp/amble-server.ts`, that exposes tenant-scoped resources and platform tools to external API-key callers.
- Gateway tools, created by `createMcpGatewayTools()` in `features/mcp/gateway-tools.ts`, that let Amble agents discover and call active downstream MCP server connections.
The external server is part of the same platform tool system described in [Tool System](/docs/features/tool-system), and it authenticates through the API-key model described in [API Keys](/docs/features/api-keys). It is not a browser request surface: external callers arrive with an `ApiKeyAuthContext`, not cookies or a user session.
## Key Concepts
### External Amble MCP Server
`features/mcp/amble-server.ts` constructs the external server with the name `Amble`, version `0.1.0`, and a tenant-brain instruction block. It registers the `amble://setup/agent-instructions` resource for every connecting client, then gates entity and integration resources behind the `tools:execute` API-key scope.
The resource templates registered for executing callers are:
| Resource | Registered in | Purpose |
| ---------------------------------- | ------------------------ | ------------------------------------------------------------------- |
| `amble://setup/agent-instructions` | `registerMcpResources()` | Tenant-specific setup block generated by `resolveAgentSetupBlock()` |
| `entity://{typeSlug}/{id}` | `registerMcpResources()` | Tenant-scoped entity record by entity type slug and id |
| `integration://{slug}/{resource}` | `registerMcpResources()` | Tenant-scoped page of synced integration records |
When the key has `tools:execute`, `createAmbleMcpServer()` resolves the connection's **tool projection** once and registers its typed band with the MCP SDK. Tool execution goes back through `executeApiKeyTool()`, preserving the API-key permission path instead of directly invoking tool code.
### Tool profiles — the capability boundary
There is ONE catalog. `listExternalToolEntriesForTenant()` in `features/tools/server/effective-catalog.ts` resolves every tool the tenant can run right now as canonical `EffectiveToolDescriptor`s — code-registry tools and tenant-authored `tools` rows alike — and drops anything that is not externally surfaceable.
`features/mcp/tool-projection.ts` SELECTS from that catalog for one connection's profile (`api_keys.tool_profile`, `NULL` meaning the default). It never builds a second one. The projection has two bands:
| Band | Cost | Reached by |
| ----------- | --------------------------------------------------- | ------------------------------------------------------- |
| **typed** | Prompt prefix on every turn, capped by `maxTypedTools` | `tools/list` |
| **gateway** | Nothing until asked | `ambleFindTools` → `ambleDescribeTool` → `ambleRunTool` |
`projection.executableSlugs` (typed ∪ gateway) is the connection's **capability boundary, not a menu**. Every execution path refuses a slug outside it — the SDK route's `tools/call`, the gateway's `ambleRunTool`, the legacy JSON-RPC route, and API-key REST at `POST /api/tools/[slug]/run` — because they all go through `executeApiKeyTool()`, which returns `null` (byte-identical to an unknown slug) for anything outside the set. There is no legacy full-catalog mode.
`ambleDescribeCapabilities` reports the profile name, the exact typed slugs, the gateway count, and the serialized `tools/list` byte size, all measured off the same projection object the server registered from — plus `catalog.listedTools`, the full slug list this connection registered across every band.
### Tool Visibility
ADR-0014 defines the external MCP visibility rule: `ToolDefinition.visibility` defaults to external, while `visibility: "internal"` hides a tool from the API-key MCP surface. The rule is implemented in `isExternallySurfaceable()` in `features/tools/server/effective-catalog.ts`, which filters out internal tools before tenant tool disables are considered.
Internal tools are still available to in-process platform callers through the normal registry paths. They are not shown to external MCP/API-key callers. The MCP server has one explicit exception path for Apps publishing: `publish_view` and `amble_submit_block_response` are internal tools, but `createAmbleMcpServer()` surfaces them only when the key has `tools:execute` and `isPublishingEnabled(tenantId)` returns true.
### Schema Conformance
External MCP tool schemas are guarded by tests in `features/mcp/tool-schema-conformance.test.ts` and `features/mcp/tool-schema-strict-mode.test.ts`.
`tool-schema-conformance.test.ts` creates an in-process MCP server and client, lists the externally visible tools, and fails if exposed schemas use unsupported shapes such as `oneOf`, `discriminator`, or top-level schemas without object properties outside the allowlist. `tool-schema-strict-mode.test.ts` tracks `additionalProperties: {}` counts against `features/mcp/strict-mode-baseline.json`, and only accepts new counts when `UPDATE_STRICT_MODE_SNAPSHOT=1` is used deliberately.
### Discovery gateway (inbound)
`features/mcp/amble-gateway-tools.ts` registers the three meta-tools that make the gateway band reachable: `ambleFindTools` (ranked, paginated search over the band), `ambleDescribeTool` (the canonical descriptor projection, including `definitionHash`), and `ambleRunTool`.
`ambleRunTool` REQUIRES `expectedDefinitionHash` back. If the tool's definition moved since the caller described it — a redeploy, a tenant edit, a schema bump — the call is refused with a stale-contract error instead of running arguments shaped for a contract that no longer exists. Its optional `idempotencyKey` reaches the canonical invocation service, where a partial unique index makes a retry name the existing run rather than executing twice.
A tool whose input schema has no JSON-Schema representation is NOT gateway-runnable: it is excluded from the band rather than offered with a null schema.
`GATEWAY_TOOL_DEFINITIONS` is the single declaration table. The SDK route registers from it and the legacy JSON-RPC route serializes and dispatches from it, so the two surfaces cannot advertise or validate different contracts.
### Outbound MCP gateway
`features/mcp/resolve-configs.ts` reads active `agent_connections` rows where `connection_type = "mcp"` and `status = "active"`, refreshes OAuth if needed, and converts each row to an `McpServerConfig`. The resolver creates stable `connectionSlug` values from connection names, resolves headers and URLs through `lib/connections/resolve-headers`, carries optional `allowlist`, `requiredPermission`, and `bridge` values, and caches results for one week with tags that `invalidateMcpServerConfigs()` can clear.
`createMcpGatewayTools()` turns those configs into two agent tools:
- `listMcpTools`, which discovers downstream tools with `discoverMcpTools()` and renders a namespaced catalog.
- `callMcpTool`, which requires namespaced tool names such as `mcp:<connectionSlug>:<toolName>`, enforces allowlists and permissions, calls `callMcpTool()`, and writes an audit event through `appendSessionEvent()` when a `sessionId` is available.
### Apps UI
The Apps UI code under `features/mcp/apps-ui/` supports renderable MCP UI bundles:
- `publisher.ts` signs time-limited view tokens with `MCP_APPS_SIGNING_SECRET`, verifies tokens, renders view bundles, and checks `isPublishingEnabled(tenantId)` through the `mcp_apps_publish_enabled` tenant setting.
- `consumer.ts` parses `ui://` pointers and resolves them to renderable `McpAppDescriptor` values by reading MCP resources; non-HTTPS bundle URLs fail closed.
- `bridge.ts` implements the host/widget `postMessage` bridge. The host pins both origin and iframe source, caps concurrent tool calls, caps argument payload size, and returns `ui/notifications/tool-result` messages.
For opted-in tenants, the external server also registers the stable `ui://amble/view-v1.html` resource. `publish_view` is the dedicated render tool: its descriptor carries `_meta.ui.resourceUri` plus the ChatGPT compatibility `openai/outputTemplate` key. Its model-visible structured content is a compact view summary; signed render URLs, invocation tokens, tenant/author identity, and registry hydration stay out of model context. The component reads the private envelope from result `_meta`, renders responsive registry blocks/tables/entity graphs, builds inputs from the response JSON Schema, and submits through the app-only response tool. Current public app connections share the canonical `https://app.sprinter.ai` component origin and use tenant-specific MCP resource paths.
The official Amble submission uses OpenAI's Template MCP URL
`https://app.sprinter.ai/api/mcp/t/{tenant}/server` with a concrete review
tenant. Praxium uses the fixed
`https://app.sprinter.ai/api/mcp/t/praxium/server` resource. One connector
instance authorizes exactly one tenant; multi-tenant users configure separate
named connections instead of passing tenant slugs to tools or switching tenant
state behind one bearer token. The global `/api/mcp/server` endpoint remains a
backwards-compatible developer connection that chooses one tenant at consent,
not the public directory endpoint.
OAuth clients should receive durable authorization. The MCP endpoint's `WWW-Authenticate` challenge requests the focused operational scope set plus `offline_access`; access tokens remain short-lived while rotating refresh tokens keep Codex and ChatGPT connections usable. A concurrent rotation loser returns a retryable response without minting a sibling or revoking the winner's chain. A replay inside the bounded grace window also returns a retryable response; because replacement token plaintext is not persisted, a client that truly lost the winning response must reauthorize rather than fork the refresh family. Existing custom apps created before this metadata was present must be refreshed/recreated and reauthorized because OpenAI snapshots tool and OAuth metadata.
### Elicitation
The elicitation code under `features/mcp/elicitation/` handles human-input handoffs for MCP callers:
- `outbound.ts` serializes an `ElicitationPayload` into an MCP `elicitation/create` JSON-RPC request with `buildElicitationRequest()`.
- `tool-result.ts` defines the tool-side waiting shape `{ status: "waiting_human", elicitation }` and parses it with `parseToolWaitingHuman()`.
- `dispatch.ts` sends outbound elicitations only when a session is an MCP-originated waiting session and a transport is registered.
- `inbound.ts` parses incoming MCP elicitation payloads into form or URL shapes.
Tool authors should return the structured waiting shape from tool output. They should not call `buildElicitationRequest()` directly from tool logic; the session executor owns persistence and dispatch.
## Architecture / Data Flow
External callers enter through API-key authenticated routes that construct an `ApiKeyAuthContext` and call `createAmbleMcpServer()`.
1. `registerMcpResources()` always registers `amble://setup/agent-instructions`.
2. `ambleDescribeCapabilities` is registered ahead of every scope gate — see below.
3. Callers without `tools:execute` do not receive entity or integration resource templates.
4. Callers with `tools:execute` receive the typed band of `resolveMcpProjection()`, projected from `listExternalToolEntriesForTenant()`, which has already filtered tenant visibility, system-tool enablement, disabled tools, `permissionPolicy: "always_ask"`, and `visibility: "internal"`.
5. Tool calls execute through `executeApiKeyTool()`, which refuses any slug outside the same projection's `executableSlugs`.
6. ViewSpec-shaped tool outputs may receive `_meta.ui` from `maybeAttachUiMeta()` when Apps publishing is enabled.
7. Waiting-human tool outputs are parsed by the elicitation subsystem so MCP transports can receive `elicitation/create` requests.
### `ambleDescribeCapabilities` — why a tool is missing
A connection can be missing a tool for reasons invisible from the client: the credential was
minted without the scope that registers that family, the creator's role does not carry the
permission, or the tenant disabled the tool. All the caller observes is a `tools/list` that
does not contain what it expected, and the default inference — "this capability does not
exist here" — is wrong in every one of those cases.
`ambleDescribeCapabilities` (`features/mcp/describe-capabilities.ts`) is registered on every
authenticated connection, deliberately behind **no** scope gate: the connection missing a
scope is precisely the one that needs to ask. It returns the caller's own granted scopes,
which tool families each unlocks, the exact slug list this connection registered, and one
`recommendedActions` entry per missing member of the recommended set
(`tools:execute` + `skills:read` + `views:read`, the `MCP_RECOMMENDED_KEY_SCOPES` constant the
API-key setup copy also renders from). Every action carries `reconnectRequired: true` —
scopes are fixed when the credential is issued, so no in-session call can widen them.
It also reports `auth.hasUserIdentity`. An API key whose creator is null carries the
`ANON_TOOL_USER_ID` sentinel rather than a user id, so anything that must authorize per
record as a person cannot run — `getContextPack`'s document leg is reported `degraded` on
every call. That is correct fail-closed behavior, but without a `no_user_identity`
recommended action the cause would be stated nowhere.
`catalog.listedTools` is every slug in **this connection's** `tools/list`, in registration
order: the typed band, this diagnostic, the hand-registered skill and view tools, and the
gateway trio. It is not recomputed — `resolveMcpRegisteredToolSlugs()`
(`features/mcp/registered-tools.ts`) produces the array once, the server's registration gates
read it, and the report echoes the same array, so the two cannot disagree about what shipped.
Slugs rather than a count on purpose: a count a client cannot check against what it is
holding was how a per-connection regression (11 tools where 21 were expected) survived long
enough to need a live stress test to find. The typed band is still reported for what it is,
as `catalog.profile.typedToolCount`, and stays `null` without `tools:execute`.
Every capability field is computed against the connection's own effective catalog, never the
tenant's. The same intersection governs skill eligibility: `isSkillVisibleExternally` is
handed the connection's tool slugs, so a `skills:read`-only credential is not offered a skill
whose required tools are absent from its own `tools/list`.
If the tenant lookup itself fails, the tool returns an **error**, not a report. A blank
tenant identity paired with `tools: true` and a global-only count would be a confident answer
assembled from a read that did not happen — the same absence-vs-failure conflation this tool
exists to remove, one layer up.
It reports grants only. Permission enums, role names, and key material are never included.
`server.buildSha` is withheld from a credential holding no scopes at all; `grantedScopes` is
sorted so two credentials with the same grants serialize identically.
Downstream MCP connections flow the other direction: the agent runtime resolves tenant connections with `getMcpServerConfigs()`, creates `listMcpTools` and `callMcpTool` with `createMcpGatewayTools()`, and routes downstream discovery/calls through `features/mcp/client.ts` or a configured local bridge.
## Key APIs
```typescript
export async function createAmbleMcpServer(
apiKeyCtx: ApiKeyAuthContext,
options: CreateAmbleMcpServerOptions = {},
);
```
Creates the external Amble MCP server, registers resources, registers API-key-accessible tools, and attaches Apps UI metadata when eligible.
```typescript
export async function maybeAttachUiMeta(
result: McpToolResult,
output: unknown,
tenantId: string,
parentOrigin?: string,
): Promise<McpToolResult>;
```
Adds `_meta.ui` to a tool result when the output is ViewSpec-shaped and Apps publishing is enabled.
```typescript
export function createMcpGatewayTools(
configs: McpServerConfig[],
options: McpGatewayToolOptions = {},
): ToolSet;
```
Creates the `listMcpTools` and `callMcpTool` agent tools for downstream MCP connections.
```typescript
export async function getMcpServerConfigs(
tenantId: string,
): Promise<McpServerConfig[]>;
```
Reads active tenant MCP connections from `agent_connections` and returns normalized configs for gateway tool assembly.
```typescript
export function buildAgentSetupBlock(input: AgentSetupBlockInput): string;
```
Builds the managed instructions block served from `amble://setup/agent-instructions`.
```typescript
export function buildElicitationRequest(
payload: ElicitationPayload,
): Record<string, unknown>;
```
Serializes form or URL elicitation payloads into MCP `elicitation/create` JSON-RPC requests.
```typescript
export function parseToolWaitingHuman(
structuredContent: unknown,
): ToolWaitingHuman | null;
```
Parses a tool result's structured content into the waiting-human handoff shape.
## Agent Instructions
When adding an externally exposed Amble tool, define it in the platform tool system and let the effective catalog expose it. A new tool is reachable through the gateway immediately; it enters the byte-budgeted typed band only when a profile kit or `typedTools` names it. Do not register external tools directly in `features/mcp/amble-server.ts` unless the change is part of the MCP server contract itself.
Use `visibility: "internal"` for admin repair, schema surgery, registry maintenance, or platform-only tools. Internal tools remain available to in-process callers but are excluded from external MCP and API-key tool listing by ADR-0014.
Keep MCP-facing input schemas object-shaped and strict-mode safe. Run the conformance tests after changing exposed tool schemas:
```bash
pnpm exec vitest run features/mcp/tool-schema-conformance.test.ts features/mcp/tool-schema-strict-mode.test.ts
```
For downstream MCP connections, store connection config in `agent_connections` with `connection_type = "mcp"` and let `getMcpServerConfigs()` assemble the runtime configs. Prefer namespaced calls through `mcp:<connectionSlug>:<toolName>` so duplicate server names do not misroute calls.
For Apps UI, use the signed publisher and bridge helpers. Do not embed unsigned bundles or ad hoc `postMessage` handlers; the bridge pins origins and sources and enforces payload and concurrency limits.
For elicitation, tools should return structured content with `status: "waiting_human"` and an `elicitation` payload. The session executor and MCP transport handle persistence and dispatch.