Sealed state
Keep tool state on the wire instead of in server memory. The server seals state into an HMAC token, and the widget carries it between calls.
MCP 2026-07-28 is stateless: every request builds a fresh server, so a module-level Map of
session data breaks on serverless and multi-instance hosting. Sealed state replaces it. The
server seals its state into a signed token, hands the token to the widget, and opens it
when the widget echoes it back. No storage, no session affinity.
Sealed tokens are signed (HMAC-SHA256), not encrypted. The client can read the payload, it just cannot forge or alter it. Never seal secrets.
The three state carriers
| Carrier | Lives where | Trust | Use for |
|---|---|---|---|
| View state | host, per widget instance | client-owned | UI state: selections, drafts |
| Sealed state (this page) | tool results and arguments | server-verified | server state a widget carries between calls |
requestState |
one elicitation round trip | server-verified | mid-tool context, see elicitation |
One state option powers both verified carriers with the same key. The two are not
interchangeable: every token seals the purpose it was minted for, so a widget token cannot be
replayed as an elicitation requestState or the reverse. MRTR tokens additionally seal the tool
that minted them.
Configure the server
import { McpServer } from "ng-mcp-ui/server";
export function createMcpServer(): McpServer {
return new McpServer(
{ name: "my-app", version: "0.0.0" },
{
viewManifest: resolveViewManifest(),
state: { key: process.env["NG_MCP_STATE_KEY"] },
},
);
}
key: the HMAC key, at least 32 bytes. In production (NODE_ENV=production) a missing key throws at startup. In development, ng-mcp-ui mints an ephemeral key and logs a warning; tokens then die with each restart, which is fine for a dev loop.ttlSeconds: token lifetime, default 600. An expired token failsopen().bind: derive extra verification context from the request, so a token minted for one principal fails for another. Bind by principal (for example the authenticated user), never by method: the same token legitimately flows through several tools.
You can also pass a ready-made codec from createRequestStateCodec(...) if you share one across
servers.
Seal on the way out, open on the way in
With the option set, every tool handler gets ctx.state:
import { STATE_META_KEY } from "ng-mcp-ui/server";
server
.registerTool({ name: "start", /* ... */ }, async (_args, ctx) => {
const game = { board: emptyBoard(), turn: 1 };
return {
content: "New game.",
structuredContent: publicView(game),
_meta: { [STATE_META_KEY]: await ctx.state?.seal(game) },
};
})
.registerTool(
{ name: "move", inputSchema: z.object({ state: z.string(), cell: z.number() }) },
async ({ state, cell }, ctx) => {
const game = await ctx.state!.open<Game>(state);
applyMove(game, cell);
return {
content: describe(game),
structuredContent: publicView(game),
_meta: { [STATE_META_KEY]: await ctx.state!.seal(game) },
};
},
);
The wire path is explicit and ordinary:
- The handler seals the state and returns the token under
_meta[STATE_META_KEY]("ng-mcp-ui/state"). - The widget reads it from the tool response metadata. It is not model-visible.
- The widget echoes it back as a plain tool argument (
stateby convention). - The handler opens it, mutates, and re-seals. Re-sealing also restarts the token's lifetime.
The widget side
Type the token into the response metadata, echo it, and persist the freshest copy in view state so a remounted widget keeps working:
const STATE_KEY = "ng-mcp-ui/state"; // widget code must not import server code
type WeaveMeta = { [STATE_KEY]?: string };
private readonly tool = injectToolInfo<{
output: GameView;
responseMetadata: WeaveMeta;
}>();
private readonly move = injectCallTool<
{ state: string; cell: number },
{ structuredContent: GameView; meta: WeaveMeta }
>("move");
private readonly viewState = injectViewState<{ token: string | null }>({ token: null });
The scaffolded poll example (ng add ng-mcp-ui@next --example=demo) is a complete worked version of
this pattern, including token adoption on every response.
Expiry and tampering
open() throws on any bad token: tampered, expired, or bound to someone else. The error message
is the stable SEALED_STATE_INVALID_MESSAGE constant, and the SDK converts the throw into an
isError tool result. In the widget, treat that result as "expired" and tell the user to start
over:
this.move.callTool({ state: token, cell }, {
onSuccess: (data) => {
if (data.isError) {
this.expired.set(true);
return;
}
// adopt data.structuredContent and data.meta?.[STATE_KEY]
},
});
The message carries no reason on purpose. Whether verification failed on the MAC, the expiry, or the binding is not something a client should be able to distinguish.
The API
| Method | Use |
|---|---|
ctx.state.seal(payload) |
Mint a widget-carried token. |
ctx.state.open<T>(token) |
Verify one and read its payload. |
ctx.state.sealRequestState(payload) |
Mint an elicitation requestState, bound to the current tool. |
ctx.state.requestState<T>() |
Read the verified echo on a retry round. |
Design limits
- Keep payloads small. The token rides every call in both directions.
- Each widget instance carries its own fork of the state. Two open widgets on the same data are last-writer-wins. If you need one authoritative copy, put it in a database and seal only its id.
- Tokens are replayable within their lifetime by design: the widget retries with the same token
after a network error. Use
bindand short lifetimes to narrow the window.