@cesium-ai/mcp-tools
Server-only Model Context Protocol (MCP) client bridge for the AI SDK. Connects to one or more MCP servers over SSE or streamable HTTP (stdio — spawning a local executable — is deliberately unsupported, see the Security model table below), namespaces and allowlist-filters their tools, and merges them into a plain AI SDK ToolSet — the same shape @cesium-ai/tools-schemas's createCesiumTools() returns, so a host app composes them the same way:
import { createMcpTools } from "@cesium-ai/mcp-tools";
import { createCesiumTools } from "@cesium-ai/tools-schemas";
const mcp = await createMcpTools({
servers: [
{
name: "docs",
transport: { type: "http", url: "https://example.com/mcp" },
allowedTools: ["search"],
},
],
});
const tools = { ...createCesiumTools(), ...mcp.tools };
// ... run the agent loop with `tools` ...
// on shutdown
await mcp.close();
This package has no dependency on @cesium-ai/server or @cesium-ai/tools-schemas and is entirely optional — an app that never configures an MCP server never imports it.
Why this is a separate package
MCP tools are architecturally different from this repo's other tool groups:
- They run entirely server-side. Unlike
flyTo(streamed to the browser, executed against the liveViewer), an MCP tool'sexecute()talks to the MCP server directly from Node and its result is the real, final outcome — never streamed as a client tool call. See the root README's architecture section anddocs/architectures/architecturefor the split-execution model this follows. - The tool registry is third-party, dynamic content.
flyTo's schema is hand-authored and reviewed; an MCP server can add, remove, or reword its tools at any time. That's a materially different trust boundary, so it gets its own package rather than living in@cesium-ai/tools-schemas(which is scoped to this repo's own hand-authored viewer tools) or@cesium-ai/server(model-/tool-agnostic, and shouldn't gain an MCP SDK dependency just to support an optional feature).
Security model
MCP tool calls run arbitrary code you don't control, so this package is deliberately conservative by default:
| Risk | Mitigation |
|---|---|
| Config is attacker-influenceable — a server URL controls what code runs. | McpServerConfig[] is a plain, host-supplied argument, exactly like the LLM API key — it must come from trusted operator config (e.g. an mcp.config.json file, see the backend's env.ts), never from a chat request. |
| A locally-spawned process is a larger attack surface than a URL. | stdio transport (spawning an arbitrary local executable) is deliberately unsupported — McpTransportConfig only allows sse/http, and McpServerConfigsSchema rejects any other type at parse time. Only network transports this app merely calls are supported. |
Tool poisoning / silent "rug pull" — an MCP server can change a tool's name/description (which the model reads to decide what to call) at any time, including after your app has been reviewed against the original wording. |
Every discovered tool's name + description is logged via the logger option (createConsoleMcpToolsLogger("info") or higher) at connect time — review these logs whenever an MCP server updates. Prefer allowedTools (an explicit per-server allowlist) over accepting a server's full, unreviewed tool catalogue. |
| Name collisions across servers. | Every tool is namespaced mcp__<serverName>__<toolName> before merging, so two servers can never silently shadow each other's tools. |
| A stalled or malicious server hangs the agent loop. | Every tool call is wrapped with a timeout (timeoutMs, default {@link DEFAULT_MCP_TOOL_TIMEOUT_MS} = 30s) that rejects the call — it can't block the request indefinitely. |
| One bad server takes the whole app down. | Each server connects independently — a connection failure is recorded in McpToolsHandle.servers and logged, but never thrown; the other servers (and the rest of the app) start normally. Check servers at startup / in /health. |
| Credentials/URLs leaking to the browser. | McpServerConfig (which may carry auth headers) is consumed entirely server-side — never serialize it into any response sent to the client. |
| Per-user OAuth credentials must not leak across users or persist unexpectedly. | Session OAuth creates one in-memory provider per browser session. Tokens never reach the browser or disk and are discarded when the connection/session is closed or the process restarts. |
| A model calling an MCP tool without a human in the loop. | This package doesn't gate approval itself (that's a streamText/toolApproval concern — see @cesium-ai/server), but the host app should default every MCP tool to "user-approval" (this repo's backend/src/app.ts does exactly that for every tool createMcpTools returns). |
| A rendered MCP App widget calling tools/reading resources without a human in the loop. | Every widget-initiated tools/call bridge request requires an explicit inline Approve/Reject decision in the frontend BEFORE the backend is ever called (see McpAppWidget); the backend independently re-validates the (server, toolName) pair against that request's own resolved tool registry, and resources/read is restricted to ui:// URIs only. |
API
createMcpTools(options)
interface CreateMcpToolsOptions {
servers: readonly McpServerConfig[];
timeoutMs?: number; // default 30_000
logger?: McpToolsLogger; // default: no-op
}
interface McpToolsHandle {
tools: ToolSet; // merged, namespaced — spread into your registry. A tool with an MCP Apps widget carries it as its own `mcpApp` property (see "MCP Apps widgets" below)
servers: readonly McpServerStatus[]; // per-server connect outcome
authRequiredServers: readonly McpServerConfig[]; // auto-detected as needing per-user OAuth
getClient(serverName: string): MCPClient | undefined; // live client for one connected server — needed to serve widget bridge requests
close(): Promise<void>; // closes every underlying MCP client
}
createMcpTools is async (connecting + discovering tools is inherently async), unlike createCesiumTools(). Resolve it once at process startup — not per-request — and pass the resulting tools into your registry and close into your shutdown handler.
McpServerConfig
One array, one schema, no manual "does this need OAuth" flag:
interface McpServerConfig {
name: string; // unique; used for namespacing + logs
transport: {
type: "sse" | "http";
url: string;
headers?: Record<string, string>;
oauth?: McpOAuthConfig; // optional overrides, only consulted if auth turns out to be needed
};
allowedTools?: readonly string[]; // omit = expose every tool the server advertises
}
createMcpTools attempts every server the same way, with no authProvider attached. Per server, the outcome is one of:
- Connects successfully — shared by every visitor from then on (tools merged into
McpToolsHandle.tools). - Fails with a 401 — auto-detected as needing per-user authentication (
isUnauthorizedMcpError, seesrc/connection/mcp-error.ts) and collected intoMcpToolsHandle.authRequiredServersinstead of a hard failure. Feed that list intocreateSessionMcpManager(below) to offer it through an interactive "Connect" flow. - Fails any other way (network unreachable, bad URL, ...) — recorded as a genuine failure in
McpToolsHandle.servers, isolated from other servers.
const mcp = await createMcpTools({ servers });
const sessionMcp =
mcp.authRequiredServers.length > 0
? createSessionMcpManager({
servers: mcp.authRequiredServers,
buildRedirectUrl: () => "https://app.example.com/api/mcp/callback",
})
: undefined;
Session-scoped OAuth
Interactive OAuth is intentionally handled by a separate function from startup-connected servers, even though both share one config type. Use createSessionMcpManager for user-initiated connections (pass it the servers createMcpTools reported in authRequiredServers, above) so each browser session gets its own identity and in-memory credentials:
const sessionMcp = createSessionMcpManager({
servers: [
{
name: "ion",
transport: {
type: "http",
url: "http://localhost:3000/mcp/",
oauth: {
clientId: "<optional default client_id>",
},
},
},
],
buildRedirectUrl: () => "https://app.example.com/api/mcp/callback",
});
oauth is an optional override bag with clientId, clientSecret, clientName, and scope — omit clientId to use RFC 7591 dynamic client registration. Scope is normally discovered from RFC 9728 Protected Resource Metadata; configure scope when a provider requires it but omits scopes_supported (Cesium ion currently does). buildRedirectUrl returns ONE shared URL for every server — an operator registers a single redirect URI per third-party OAuth app, since the callback routes to the right in-flight flow via the OAuth state parameter, not the URL. The host owns the session/callback HTTP routes; this repo's implementation is @cesium-ai/server/mcp's mcp-session-router.ts (one GET /api/mcp/callback route, not one per server).
@ai-sdk/mcp handles dynamic client registration, PKCE (S256), authorization-code exchange, and refresh. This package keeps provider state in memory only — the host must call disconnect, disconnectSession, and closeAll at the appropriate lifecycle boundaries.
McpServerConfigsSchema
A zod schema validating a full McpServerConfig[] list (e.g. JSON.parse()'d from an mcp.config.json file) — checks transport shape and rejects duplicate server names. Use McpServerConfigsSchema.parse(value) (throws on violation) or .safeParse(value) (returns {success, data | error}); see backend/src/utils/mcp-servers-config.ts for the latter.
Logging
noopMcpToolsLogger (default) and createConsoleMcpToolsLogger(level) ("debug" | "info" | "warn" | "error" | "silent", [mcp-tools]-prefixed) are exported. "info" or louder is recommended in any environment where you want to catch tool-poisoning-style changes — it logs every discovered tool's name and description at connect time.
MCP Apps widgets
Both createMcpTools and createSessionMcpManager always advertise @ai-sdk/mcp's mcpAppClientCapabilities during connect — the "MCP Apps" extension that lets a tool declare an interactive ui:// HTML widget resource (via _meta.ui.resourceUri) instead of/alongside a plain JSON result. This package only discovers that metadata (getMcpAppToolMeta) and attaches it directly onto the discovered tool as tool.mcpApp (see McpTool) — no separate map to look up alongside the tool registry — plus a way to reach the underlying live MCPClient (getClient / getSessionClient). It does not fetch resources or call tools on a widget's behalf. That's the host's job:
@cesium-ai/server/mcp'smcp-app-router.tsexposes boundedGET /api/mcp-app/resourceandPOST /api/mcp-app/tool-callroutes. It returns rawMCPClient.readResourceresults expected byAppRenderer, validates tool calls against the request's resolved tool set, and applies the configured MCP timeout to both operations.packages/chat-element/src/components/McpAppWidget.tsxuses@mcp-ui/client'sAppRenderer, which implements the MCP Apps JSON-RPC/postMessage protocol and isolates widget HTML through a host-served double-iframe sandbox proxy. This repo serves that proxy fromfrontend/public/sandbox_proxy.html; another host can pass its URL throughAiChatPanel'smcpAppSandboxUrlprop. Widget-initiated tool calls remain approval-gated.
Request flows
Three flows this package participates in. All are simplified — @ai-sdk/mcp's own auth() may perform additional metadata-discovery round trips not shown here.
Startup: connecting operator-configured servers
Every server is attempted the same way, with no authProvider attached — a protected server's plain 401 is what routes it to authRequiredServers instead of a hard failure:
sequenceDiagram
participant Backend as Backend process (index.ts)
participant MCP as MCP Server
Note over Backend: createMcpTools({ servers }) at startup
loop for each configured server
Backend->>MCP: connect (no authProvider attached)
alt succeeds
MCP-->>Backend: 200 OK
Backend->>MCP: tools()
MCP-->>Backend: tool list
Note over Backend: merged into McpToolsHandle.tools —<br/>shared by every visitor from then on
else 401 Unauthorized
MCP-->>Backend: 401
Note over Backend: isUnauthorizedMcpError() → true<br/>added to authRequiredServers instead of a hard failure
else other failure
MCP-->>Backend: network error / non-200
Note over Backend: recorded as a failed server in McpToolsHandle.servers,<br/>isolated from the rest
end
end
Session-scoped interactive OAuth connect
Triggered by a user clicking "Connect" for a server createMcpTools reported in authRequiredServers. Routes shown are @cesium-ai/server/mcp's own mcp-session-router.ts:
sequenceDiagram
actor User
participant Browser
participant Backend as Backend (mcp-session-router.ts)
participant MCP as MCP Server (protected resource)
participant AuthServer as Authorization Server
User->>Browser: clicks "Connect" for a server
Browser->>Backend: POST /api/mcp/:server/connect
Backend->>MCP: GET .well-known/oauth-protected-resource<path> (RFC 9728)
MCP-->>Backend: metadata (optional scopes_supported)
Backend->>AuthServer: discover metadata (.well-known/openid-configuration or oauth-authorization-server)
AuthServer-->>Backend: authorization_endpoint, token_endpoint, registration_endpoint, ...
opt no clientId configured for this server
Backend->>AuthServer: POST registration_endpoint (RFC 7591 dynamic client registration)
AuthServer-->>Backend: client_id (+ client_secret)
end
Note over Backend: builds a PKCE (S256) verifier/challenge,<br/>generates + stores an OAuth "state",<br/>stores a PendingEntry keyed by that state
Backend-->>Browser: { authorizationUrl }
Browser->>User: opens a popup at authorizationUrl
User->>AuthServer: logs in / grants consent
AuthServer-->>Browser: redirects the popup to /api/mcp/callback?code=...&state=...
Browser->>Backend: GET /api/mcp/callback?code=...&state=...
Note over Backend: looks up the pending flow by "state" alone —<br/>the callback route is server-name-agnostic
Backend->>AuthServer: POST token_endpoint (code + code_verifier + client_id)
AuthServer-->>Backend: access_token (+ refresh_token)
Backend->>MCP: connect with "Authorization: Bearer <access_token>"
MCP-->>Backend: 200 OK
Backend->>MCP: tools()
MCP-->>Backend: tool list
Note over Backend: namespaced (mcp__<server>__<tool>) + timeout-wrapped,<br/>stored keyed by this browser session's sessionId
Backend-->>Browser: 200 OK, plain HTML result page rendered directly<br/>by the backend (no redirect — this backend may be shared by more<br/>than one frontend origin, so there's no single one to bounce back to)
Browser->>Browser: that page pushes {server, connected, error?} to<br/>window.opener via postMessage, then window.close()
Browser->>Browser: McpConnect.tsx, listening since the popup opened,<br/>matches the message against the exact popup Window reference<br/>it opened, sees connected:true and refetches /api/tools
Calling a connected MCP tool during chat
Applies equally to an operator-configured server's tool and a session-connected one — the only difference is where getSessionTools merges its tools into the request's tool registry:
sequenceDiagram
actor User
participant Browser
participant Backend as Backend (/api/chat)
participant MCP as MCP Server
User->>Browser: sends a chat message
Browser->>Backend: POST /api/chat
Note over Backend: tool registry = static tools<br/>+ sessionMcp.getSessionTools(sessionID)
Backend-->>Browser: stream: tool-input-available + tool-approval-request
Note over Browser: every MCP tool defaults to<br/>toolApproval: "user-approval"
Browser->>User: shows Approve / Reject
User->>Browser: clicks Approve
Browser->>Backend: POST /api/chat (approval-responded)
Backend->>MCP: call the underlying MCP tool<br/>(Bearer token attached for a session-connected server)
MCP-->>Backend: tool result
Backend-->>Browser: stream: tool-output-available
Browser->>User: renders the result
Limitations / follow-ups
This package only calls the MCP client's tools() method — the one capability this app's streamText agent loop can consume. A few other things @ai-sdk/mcp's client exposes are deliberately not wired up:
- Elicitation (
client.onElicitationRequest(...)) — a server can ask the client to gather more input mid tool-call. No handler is registered, so a bridged tool that triggers elicitation fails rather than surfacing a UI prompt; this app's MCP calls run headless, server-side, with no live "ask the user and wait" channel. - Resources / Prompts (
listResources,readResource,experimental_listPrompts,experimental_getPrompt) — not fetched at all. A server whose real value is resources/prompts rather than tools connects successfully but contributes zero tools (toolNames: []inMcpToolsHandle.servers). - Per-tool-call timeout wraps
execute()in aPromise.race; it can't cancel work already in flight on the MCP server, only stop waiting for its result. - Requires Node.js ≥ 22 (
@ai-sdk/mcp's own requirement) — higher than this repo's overall>=20floor. Only relevant if you configure an MCP server. SessionMcpManagerkeeps all state in memory in the process that created it — connectedMCPClientinstances, in-flight OAuth state/PKCE verifiers, pending-connection bookkeeping. A live MCP client connection can only exist in one process, so running more than one backend instance requires routing a given browser session consistently to the SAME instance (sticky sessions / instance affinity) — swapping just the session-ID store does not make this multi-instance-safe. There's no idle-connection sweep either; a connected session'sMCPClientstays open untildisconnect/disconnectSession/closeAllis called or the process exits.
This package has no opinion on how the host establishes a sessionId — createSessionMcpManager just takes one as a plain string, so any session-identity mechanism (an express-session cookie, a signed JWT, etc.) works.
Multi-instance deployment:
createSessionMcpManager's default in-memory repositories are fine for a single backend
instance (this starter app's own default — it does not wire in Redis or any external
store). If you do scale to more than one instance, SessionMcpManagerOptions has two
purpose-built extension points for this, both optional:
connectedDescriptorRepository?: McpConnectionRepository<ConnectedMcpConnectionDescriptor>pendingDescriptorRepository?: McpConnectionRepository<PendingMcpConnectionDescriptor>
These are written to ALONGSIDE (never instead of) the real in-memory
connectedRepository/pendingRepository — they only ever receive a plain-data
descriptor ({sessionId, serverName, toolNames, connectedAt} /
{sessionId, serverName, state, startedAt}), never the live MCPClient/OAuth provider,
which genuinely cannot be serialized (see the doc comments on ConnectedMcpConnection/
PendingMcpConnection in storage/models.ts). That makes them safe to back with an
external store like Redis (or Azure Table, DynamoDB, etc.) purely for cross-instance
status observability — e.g. answering "is this session connected to ion, and since
when" from any instance, not just the one that created the connection.
Both are just the standard McpConnectionRepository<T> interface
(findById/save/delete/listAll, each T | Promise<T>) already used by the in-memory
default (storage/in-memory-repositories.ts) — this package doesn't ship a Redis-specific
implementation itself (no redis/ioredis dependency), so switching is writing a small
adapter for whichever store you pick, following that same shape:
import { createSessionMcpManager, type McpConnectionRepository } from "@cesium-ai/mcp-tools";
// Sketch only — swap in your own store's client/calls. Every method just
// needs to round-trip a plain-data descriptor (already JSON-serializable)
// under a key you choose, e.g. `${keyPrefix}${id}`.
function createExternalConnectionRepository<T>(/* your store's client, keyPrefix, etc. */) {
return {
findById: async (id) => {
/* store.get(key) → JSON.parse or undefined */
},
save: async (id, entry) => {
/* store.set(key, JSON.stringify(entry)) */
},
delete: async (id) => {
/* store.delete(key) */
},
listAll: async () => {
/* list/scan all entries under keyPrefix, JSON.parse each */
},
} satisfies McpConnectionRepository<T>;
}
const sessionMcp = createSessionMcpManager({
servers: authRequiredServers,
buildRedirectUrl: () => new URL("/api/mcp/callback", env.PUBLIC_URL).href,
connectedDescriptorRepository: createExternalConnectionRepository(/* ... */),
pendingDescriptorRepository: createExternalConnectionRepository(/* ... */),
// connectedRepository/pendingRepository (the LIVE ones) stay the built-in in-memory
// default — they can never be backed by an external store, see above.
});
Important caveat, worth restating: this only fixes cross-instance status visibility.
It does not make a session's actual MCP tool calls reachable from a different instance
than the one holding the live connection — getSessionTools/getSessionClient still only
ever look at the LOCAL in-memory connectedRepository. Making tool calls themselves
multi-instance-safe still requires routing a given browser session consistently to the same
instance (sticky sessions/instance affinity), regardless of whether you've also plugged in
an external store for the descriptor repositories. See backend/README.md's note on this
for how it applies to this repo's own starter backend.