@cesium-ai/server
Express router that mounts the AI SDK chat endpoint (POST /api/chat). Runs the streamText agent loop server-side — the LLM API key never reaches the browser. Model-agnostic: the host app owns provider selection, SDK instantiation, and API keys.
Usage
import express from "express";
import { createChatRouter } from "@cesium-ai/server";
import { createCesiumTools } from "@cesium-ai/tools-schemas";
import { createModel } from "./providers.js";
const app = express();
app.use(express.json());
app.use(
createChatRouter({
model: createModel(),
tools: createCesiumTools(),
}),
);
When model is undefined, /api/chat responds 400 { error: "NOT_CONFIGURED" } instead of throwing.
Options
createChatRouter accepts a ChatRouterOptions object:
| Option | Default | Description |
|---|---|---|
model |
— | Required to enable chat. Omit to run with /api/chat returning NOT_CONFIGURED. |
tools |
— | Required. The tool registry exposed to the agent loop, e.g. createCesiumTools(). |
system |
DEFAULT_SYSTEM_PROMPT (see below) |
System prompt override. |
maxSteps |
DEFAULT_MAX_STEPS = 5 |
Max agent-loop iterations (model call → tool call → model call) per request. |
maxMessages |
DEFAULT_MAX_MESSAGES = 100 |
Max messages accepted in a single request body; requests over the cap get 400 INVALID_REQUEST. |
toolApproval |
— | Per-tool human-in-the-loop approval gating, passed straight through to streamText. |
stopAfterTools |
— | Tool names to end the agent loop after, instead of letting the model reply in the same turn (see below). |
logger |
noopServerLogger (silent) |
Structured logger for agent-loop failures. Pass your own ServerLogger (e.g. wired into your app's OTEL telemetry) to observe them. |
system, maxSteps, toolApproval, and stopAfterTools are forwarded to runAgent (see below); maxMessages is enforced only at the router's request-validation layer.
Overriding the system prompt
The package exports its default so you can extend rather than replace it:
import { DEFAULT_SYSTEM_PROMPT } from "@cesium-ai/server";
createChatRouter({
model,
tools: createCesiumTools(),
system: `${DEFAULT_SYSTEM_PROMPT}\nAlways answer in French.`,
});
Using runAgent directly
createChatRouter({
model,
tools: createCesiumTools(),
maxSteps: 10, // allow longer tool-calling chains
maxMessages: 50, // tighten the per-request history cap
});
Deferring the model's reply for tools with a delayed real outcome
Some tools only report an intermediate result server-side (e.g. "the generated code passed
verification") while their real, final outcome (e.g. "it actually ran without error in the
browser") is only known later and reported back via a separate follow-up request. Left alone, the
agent loop would let the model reply immediately after the intermediate result — often producing a
confident "I did X" before the action has actually been confirmed to succeed. Pass such tool names
via stopAfterTools to end the loop right after that tool's result instead, so the model only gets
a chance to reply once a follow-up request (starting a fresh agent loop) reports the real outcome:
createChatRouter({
model,
tools: { ...createCesiumTools(), executeCesiumCode },
stopAfterTools: ["executeCesiumCode"],
});
Without stopAfterTools, the same-turn loop replies immediately after the tool result — before
the real outcome is known:
%%{init: {"themeVariables": {"fontSize": "18px"}, "sequence": {"actorFontSize": 17, "messageFontSize": 16, "noteFontSize": 15, "actorMargin": 70, "boxMargin": 12, "diagramMarginX": 30, "diagramMarginY": 15}}}%%
sequenceDiagram
participant U as User
participant M as Model
participant T as executeCesiumCode (tool)
U->>M: "Fly to Paris" (request 1)
M->>T: call executeCesiumCode
T-->>M: intermediate result (code generated/verified)
M-->>U: "Done! Flew to Paris." ⚠️ premature — not yet confirmed
With stopAfterTools: ["executeCesiumCode"], stopWhen ends the loop right after that tool's
result, so the model waits for a follow-up request carrying the real outcome before commenting:
%%{init: {"themeVariables": {"fontSize": "18px"}, "sequence": {"actorFontSize": 17, "messageFontSize": 16, "noteFontSize": 15, "actorMargin": 70, "boxMargin": 12, "diagramMarginX": 30, "diagramMarginY": 15}}}%%
sequenceDiagram
participant U as User
participant M as Model
participant T as executeCesiumCode (tool)
participant B as Browser sandbox
U->>M: "Fly to Paris" (request 1)
M->>T: call executeCesiumCode
T-->>M: intermediate result (code generated/verified)
Note over M: hasToolCall("executeCesiumCode") true → stopWhen fires
M-->>U: loop ends, no reply yet (request 1 done)
T->>B: run generated code client-side
B-->>U: real outcome (success or error)
U->>M: report outcome (request 2, via onServerToolResult + continueConversation)
M-->>U: "Done! Flew to Paris." / "That failed: ..." ✅ reflects real result
Using the agent loop directly
runAgent (also exported) is the lower-level primitive createChatRouter calls per request. Use it directly if you need to build a custom route instead of mounting the provided router:
import { runAgent, DEFAULT_MAX_STEPS, DEFAULT_SYSTEM_PROMPT } from "@cesium-ai/server";
const result = await runAgent({
messages,
model,
tools,
system: DEFAULT_SYSTEM_PROMPT,
maxSteps: DEFAULT_MAX_STEPS,
});
Tool introspection (createToolsRouter)
Mounts GET /api/tools — lets the client see the exact tool surface a /api/chat request would run against, including MCP tools not known statically at build time, and which of them declared an MCP Apps ui:// widget resource (so the frontend can render that widget inline instead of the plain JSON result):
import { createToolsRouter } from "@cesium-ai/server";
app.use("/api/tools", rateLimiter(/* ... */));
app.use(createToolsRouter({ buildTools }));
buildTools is (req: Request) => Promise<Record<string, McpTool>> — resolved fresh per request so it can merge in per-session tools (e.g. a user-initiated MCP connection) not known at server-construction time. Despite its McpTool type import, this router has no runtime dependency on @cesium-ai/mcp-tools (the import is type-only), so it's safely part of the package's main . entry rather than the /mcp subpath. Read-only, but the host app is expected to rate-limit this route itself (same as /api/chat//api/mcp-app) — this router doesn't apply one of its own, since a third-party MCP server's tool count/descriptions could otherwise be scraped freely.
MCP Apps widget bridge (createMcpAppRouter)
Imported from
@cesium-ai/server/mcp, a separate entry point from the package's main.export (see Subpath exports below) — requires@cesium-ai/mcp-toolsinstalled alongside it.
Mounts /api/mcp-app/resource and /api/mcp-app/tool-call — the server-side half of rendering an MCP Apps widget (a tool result's ui:// HTML resource). These routes exist because the widget itself runs in the browser, inside @mcp-ui/client's sandboxed iframe, which never holds MCP credentials or a live MCPClient — only this backend does. resources/read and tools/call requests the widget issues via AppRenderer's onReadResource/onCallTool callbacks are proxied through these two routes instead:
import { createMcpAppRouter } from "@cesium-ai/server/mcp";
app.use("/api/mcp-app", rateLimiter(/* ... */));
app.use(createMcpAppRouter({ mcp, sessionMcp, timeoutMs: 10_000 }));
GET /api/mcp-app/resourceresolves the rightMCPClientfor(req.sessionID, server)(checkingmcp's operator-configured servers first, thensessionMcp's per-session connections), rejects anyurinot starting withui://, and returns the rawclient.readResource(...)result unmodified.POST /api/mcp-app/tool-callre-validates(server, toolName)against this request's own resolved tool registry (isKnownMcpTool) before callingclient.callTool(...)— a widget can never invoke a tool this host wouldn't otherwise have offered the model.- Neither route executes a tool call automatically — the host's own frontend is expected to gate
tool-callbehind an explicit user Approve/Reject step before ever calling it (see@cesium-ai/chat-element'sMcpAppWidget.tsxfor this repo's own implementation); this router's checks are defense-in-depth, not a replacement for that UI. - Accepts
mcp/sessionMcp(both optional — see@cesium-ai/mcp-tools'McpScope) and mounts a safe "no such server" 404 for everything if neither is configured, rather than throwing. - The host app is expected to rate-limit these routes itself (same as
/api/chat//api/tools) — this router doesn't apply one of its own.
Session-scoped MCP OAuth connect (createMcpSessionRouter)
Also imported from
@cesium-ai/server/mcp(see below).
Mounts /api/mcp/* routes for user-initiated, per-browser-session MCP OAuth connections (e.g. a "Connect to Cesium ion" button) — the dynamic counterpart to createMcpTools' operator-configured, always-on servers:
import { createMcpSessionRouter } from "@cesium-ai/server/mcp";
app.use(createSessionMiddleware(/* ... */)); // express-session, mounted first
app.use(createMcpSessionRouter(sessionMcp)); // sessionMcp: SessionMcpManager, from @cesium-ai/mcp-tools
Requires express-session (or equivalent) middleware mounted earlier in the pipeline, since every route keys off req.sessionID. Implements the full OAuth 2.0/2.1 authorization-code + PKCE exchange server-side: GET /api/mcp/callback renders a small self-contained HTML page (no redirect to a host-owned URL, since a SessionMcpManager may be shared by more than one frontend origin) that pushes the outcome to window.opener via postMessage and then closes itself. See @cesium-ai/mcp-tools's README for the full sequence diagram and in-memory-state caveats.
Session middleware
createMcpSessionRouter requires that session middleware, but doesn't provide one itself — a host supplies its own express-session-compatible middleware. This repo's own backend wraps express-session in backend/src/utils/session.ts's createSessionMiddleware: it defaults to express-session's in-memory MemoryStore — fine for local dev / a single instance, but sessions (and any MCP connections tied to them) are lost on restart and aren't shared across replicas. Its store option accepts any real express-session-compatible Store (e.g. connect-redis) for production. Note this only replaces the session-ID/cookie layer — @cesium-ai/mcp-tools's SessionMcpManager keeps its own in-memory state (live MCP client connections), so a multi-instance deployment still needs sticky sessions / instance affinity for the "Connect" flow to keep working; see that package's README's "Multi-instance deployment" section for how its own pluggable descriptor-repository options can similarly be swapped for an external store.
Why this is a Backend-for-Frontend (BFF), not a browser-side PKCE client
The per-session MCP "Connect" flow implemented by createMcpSessionRouter runs the entire OAuth 2.0/2.1 authorization-code + PKCE exchange server-side — the browser only ever sees an authorizationUrl to open in a popup and later polls GET /api/mcp/:server/status. The host backend is deliberately acting as a BFF: it generates and stores the PKCE code verifier/challenge and OAuth state, exchanges the authorization code for tokens, and holds the resulting access/refresh tokens entirely in server memory (@cesium-ai/mcp-tools's SessionMcpManager), keyed only by the opaque, httpOnly express-session cookie (req.sessionID). The browser never receives or handles a code verifier, an access token, or a refresh token at any point.
Sequence diagram
sequenceDiagram
actor User
participant Popup as Browser popup
participant Opener as Browser (opener tab)
participant Backend as Backend (BFF)
participant AS as Authorization Server
participant MCP as MCP Server
Note over Opener: Already holds an httpOnly, opaque express-session<br/>cookie from its very first request - this flow<br/>attaches MCP credentials to that session, it doesn't create it
User->>Opener: clicks "Connect" for a session-connectable server
Opener->>Backend: POST /api/mcp/:server/connect (cookie)
Backend->>Backend: generate PKCE verifier/challenge (S256) + OAuth "state"<br/>store the pending flow keyed by state
Backend-->>Opener: { authorizationUrl }
Opener->>Popup: window.open(authorizationUrl)
Popup->>AS: GET /authorize?client_id&redirect_uri&code_challenge&state...
User->>AS: logs in / grants consent
AS-->>Popup: 302 redirect to /api/mcp/callback?code&state
Popup->>Backend: GET /api/mcp/callback?code&state (cookie)
Backend->>Backend: look up the pending flow by "state" alone<br/>(the callback route is server-name-agnostic)
Backend->>AS: POST token_endpoint (code + code_verifier, client_id - no client_secret for a public/PKCE client)
AS-->>Backend: access_token (+ refresh_token)
Backend->>MCP: connect with "Authorization: Bearer access_token"
MCP-->>Backend: 200 OK + tool list
Note over Backend: tokens/tools held ONLY in server memory,<br/>keyed by req.sessionID - never sent to the browser
Backend-->>Popup: 200 OK, plain HTML result page<br/>(no redirect, no tokens, no provider-controlled text unescaped)
Popup->>Popup: pushes {server, connected, error?} to window.opener<br/>via postMessage, then window.close()
Opener->>Opener: listenForMcpOAuthResult matches event.source against<br/>the exact popup Window it opened, sees connected:true,<br/>refetches /api/tools (Window.closed poll is the fallback<br/>if postMessage/the popup itself never arrives)
This is a different (and, for a public client talking to third-party OAuth providers, safer) design than the common SPA pattern of running the whole PKCE dance in the browser and stashing the resulting tokens in sessionStorage:
| Backend-for-Frontend (this pattern) | Browser-side PKCE + sessionStorage |
|
|---|---|---|
| Code verifier / tokens | Never leave the server process; browser only holds an httpOnly session cookie | Live in sessionStorage, readable by any JS running on that page |
| Exposure to XSS | An XSS bug can ride the session cookie via same-origin requests, but can't directly read/exfiltrate the token value | An XSS bug can sessionStorage.getItem(...) the access/refresh token directly and exfiltrate it |
| Token refresh | Handled transparently by the backend (@ai-sdk/mcp's refresh logic) — the browser never needs to see a new token |
The browser page must itself store and rotate refreshed tokens |
| Third-party client secret | Can be configured and used safely (never shipped to the browser) | Never safe to embed — must stay a public/PKCE-only client |
| Persistence across tabs/reloads | Survives via the session cookie; MCP connection itself is still in-memory per backend instance (see the multi-instance caveat above) | Tied to one tab's sessionStorage (cleared on tab close, not shared across tabs) |
sessionStorage is a reasonable choice for genuinely client-only state that never needs to be secret (e.g. this repo's own codegen sandbox keeps its own API keys in sessionStorage, isolated from the sandboxed iframe — see docs/Codegen-tool-security-attacks-vectors.md), but it is the wrong place for OAuth tokens or PKCE material precisely because any script with page access can read it. Keeping the whole PKCE exchange and the resulting tokens server-side (this BFF pattern) removes that entire class of exposure, at the cost of the backend needing to track per-session state itself (see @cesium-ai/mcp-tools's README for the full sequence diagram and in-memory-state caveats).
Subpath exports: @cesium-ai/server/mcp
The MCP-related routers (createMcpAppRouter, createMcpSessionRouter) live behind a separate @cesium-ai/server/mcp entry point, deliberately not re-exported from the package's main . entry. ES module imports are eager — re-exporting them from index.ts would force @cesium-ai/mcp-tools to resolve at load time for every consumer of @cesium-ai/server, even one that only ever calls createChatRouter and has no interest in MCP at all. Keeping them behind their own subpath means:
- A chat-only host never needs
@cesium-ai/mcp-toolsinstalled — importing@cesium-ai/server's main entry never touches it. - A host that DOES want MCP support explicitly opts in via
import { ... } from "@cesium-ai/server/mcp", and must have@cesium-ai/mcp-toolsinstalled itself — it's declared as an optional peer dependency (peerDependenciesMeta), not a harddependency, of this package.
Exports
@cesium-ai/server (main entry)
| Export | From | Description |
|---|---|---|
createChatRouter |
./index.js |
Builds the Express Router mounting POST /api/chat. |
ChatRouterOptions |
./index.js |
Type for createChatRouter's options. |
runAgent |
./index.js |
Runs one agent-loop turn with streamText. |
DEFAULT_MAX_STEPS |
./index.js |
Default maxSteps (5). |
DEFAULT_SYSTEM_PROMPT |
./index.js |
Default system prompt string. |
RunAgentOptions |
./index.js |
Type for runAgent's options. |
createToolsRouter |
./index.js |
Builds the Express Router mounting GET /api/tools. |
ToolsRouterOptions |
./index.js |
Type for createToolsRouter's options. |
ServerLogger |
./index.js |
Structured logger interface accepted by createChatRouter/createMcpAppRouter. |
noopServerLogger |
./index.js |
Default ServerLogger — silent, used when no logger is passed. |
@cesium-ai/server/mcp (requires @cesium-ai/mcp-tools)
| Export | From | Description |
|---|---|---|
createMcpAppRouter |
./mcp.js |
Builds the Express Router mounting /api/mcp-app/resource + tool-call. |
McpAppRouterOptions |
./mcp.js |
Type for createMcpAppRouter's options, including its optional logger. |
createMcpSessionRouter |
./mcp.js |
Builds the Express Router mounting /api/mcp/* OAuth connect routes. |