Migrate from 0.2.x
Move an application from ng-mcp-ui 0.2.x on the 2025-era MCP protocol to 1.x on MCP 2026-07-28 and TypeScript SDK v2.
ng-mcp-ui 1.x is a clean break. It builds on the MCP TypeScript SDK v2 and speaks the
MCP 2026-07-28 protocol revision only. There is no automated ng update migration. This page
tells you when to move, and it maps every 0.2.x surface to its 1.x replacement.
Decide when to move
The 1.x endpoint rejects 2025-era clients (legacy: "reject" on the wire). A host that has not
rolled out the 2026-07-28 revision cannot connect to it.
As of August 2026, claude.ai still connects with the 2025-11-25 protocol. Keep production connectors on 0.2.x until the hosts you target speak 2026-07-28. The 0.2.x line receives security patches until August 2027.
Move to 1.x now if you target the new protocol, build against SDK v2, or want the new
capabilities: sealed state, elicitation,
cache hints, and change notifications. 1.x ships as 1.0.0-beta.x on the npm next dist-tag
until a real host demonstrably renders widgets over 2026-07-28.
Your widget components do not change. The host bridges are protocol-era independent, so the whole
ng-mcp-ui/web surface is the same in 1.x.
What changed
| Area | 0.2.x | 1.x |
|---|---|---|
| Protocol | 2025 era, SDK v1 | 2026-07-28 only, SDK v2 |
| SDK packages | @modelcontextprotocol/sdk |
@modelcontextprotocol/server, /express, /node |
| Zod | v3 or v4 | v4.2 or later |
| Tool schemas | raw shape or z.object |
Standard Schema only (z.object, ArkType, Valibot) |
| Serving | connectStatelessTransport() inside the router |
SDK v2 per-request factory inside the router |
| Handler context | (args, extra) |
(args, ctx) |
| Protocol middleware | server.mcpMiddleware(...) |
removed |
| Edge runtimes | not supported | createMcpFetchHandler |
| Server state | none | sealed state (state option) |
| Multi-round tools | none | elicitation (inputRequired) |
| Cache control | none | cacheHints option, view.cacheHint |
| Change notifications | none | router.mcp.notify.* over subscriptions/listen |
1. Update the dependencies
1.x peers on the scoped SDK v2 packages and drops zod v3:
npm install ng-mcp-ui@next zod@^4.2.0
npm install @modelcontextprotocol/server@^2.0.0 @modelcontextprotocol/express@^2.0.0 @modelcontextprotocol/node@^2.0.0
Node 22 or later and Express 5 are required, the same as 0.2.x. The
@modelcontextprotocol/ext-apps and @modelcontextprotocol/sdk peers remain for the widget-side
host bridge.
2. Re-scaffold, then port
The recommended path is a fresh scaffold on a branch, not an in-place edit. The 1.x blueprint
files differ from 0.2.x (the server gets a state option, the example tools use sealed state),
and the schematic writes them all correctly in one step:
ng add ng-mcp-ui@next --example=demo
Then port your 0.2.x tools and widgets into the new files with the mappings below. Widgets move unchanged.
3. Wrap tool schemas in z.object
1.x accepts any Standard Schema that can produce JSON Schema. The 0.2.x raw-shape form is gone: wrap the fields.
// 0.2.x
inputSchema: { question: z.string(), options: z.array(z.string()) },
outputSchema: pollContract.shape,
// 1.x
inputSchema: z.object({ question: z.string(), options: z.array(z.string()) }),
outputSchema: pollContract,
outputSchema moves the same way. In 0.2.x it also took a raw shape, so someSchema.shape was
the natural way to write it when the contract lived in a shared z.object. Both positions now
take the schema itself, so a tool that declares input and output is two edits, not one. Size the
migration accordingly.
Type inference through to the widget (ToolInput, ToolOutput, injectToolInfo) works exactly
as before.
4. Update handler signatures
Handlers now receive the SDK v2 tool context instead of the v1 extra object:
| 0.2.x | 1.x |
|---|---|
(args, extra) |
(args, ctx) |
extra.requestInfo.headers["x-foo"] |
ctx.http?.req?.headers.get("x-foo") |
extra.authInfo |
ctx.http?.authInfo |
extra._meta (client hints) |
ctx.mcpReq._meta |
extra.sessionId |
ctx.sessionId, unchanged |
| n/a | ctx.state (sealed state) |
| n/a | ctx.mcpReq.inputResponses, ctx.mcpReq.requestState<T>() (elicitation) |
Return shapes are unchanged: content (string or array), structuredContent, _meta. Handlers
may now also return an inputRequired(...) result to request more input.
5. Replace mcpMiddleware
The protocol middleware API is removed. SDK v2 builds a fresh server per request and has no middleware chain, so there is no hook to expose. Replace each use:
| 0.2.x middleware use | 1.x replacement |
|---|---|
| Logging, metrics, tracing | Wrap each tool handler (below). Express middleware around the mount sees the request but not the result |
| Auth checks | requireBearerAuth / optionalBearerAuth before the router, ctx.http?.authInfo in handlers |
| Per-request context for handlers | Read ctx in the handler, or open an AsyncLocalStorage scope for code that takes no ctx (below) |
| Rewriting results | Do it in the handler, or wrap the handler function in your own helper |
The view _meta injection that ng-mcp-ui itself performed through middleware now happens at
registration time. You get it automatically and cannot break it by middleware ordering.
Logging, metrics, and tracing
Express middleware around the mount sees the HTTP exchange. With express.json() mounted
upstream, which is the wiring ng add generates, it can read the method, the tool name, and the
arguments straight off req.body, and time the request:
app.use("/mcp", (req, res, next) => {
const { method, params } = req.body ?? {};
const startedAt = Date.now();
res.on("finish", () =>
log("info", "mcp_request", {
method,
name: params?.name,
// The parsed arguments sit on `params.arguments`. Log their keys rather
// than their values unless you know the tool takes nothing sensitive.
argKeys: Object.keys(params?.arguments ?? {}),
ms: Date.now() - startedAt,
}),
);
next();
});
What it does not see is the outcome. The SDK handler writes the response, so whether the call
returned an error result, what it returned, and how long the handler itself ran are all out of
reach. Under the default responseMode: "auto" the response can also upgrade to an SSE stream
rather than a single JSON body, so intercepting the write is not dependable either.
Anything that depends on the result belongs in a handler wrapper applied at registration:
import type { McpToolContext } from "ng-mcp-ui/server";
const instrument =
<A, R>(name: string, fn: (args: A, ctx: McpToolContext) => Promise<R>) =>
async (args: A, ctx: McpToolContext): Promise<R> => {
const startedAt = Date.now();
try {
const result = await fn(args, ctx);
log("info", "tool_call", { name, ms: Date.now() - startedAt });
return result;
} catch (error) {
log("error", "tool_call", { name, ms: Date.now() - startedAt, error });
throw error;
}
};
server.registerTool(
{ name: "search", inputSchema: z.object({ q: z.string() }) },
instrument("search", async (args, ctx) => searchHandler(args, ctx)),
);
The wrapper is transparent to inference: args stays typed from inputSchema and typeof server
still carries the tool's structuredContent shape, so typed view helpers keep working.
Be aware that this is a per-tool edit rather than one cross-cutting registration. Budget one edit per tool on top of the shared helper. On a server with a few dozen tools it is the largest single line item in the migration.
Per-request context
ctx covers code that can take a parameter. When a per-request value has to reach code that
cannot, such as an API client several awaits down the call graph or a logger that stamps a
correlation id, open an AsyncLocalStorage scope in Express middleware around the mount. The
store propagates into tool handlers and everything they await:
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
const requestContext = new AsyncLocalStorage<{ correlationId: string }>();
app.use("/mcp", (req, _res, next) => {
const correlationId = req.header("x-correlation-id") ?? randomUUID();
requestContext.run({ correlationId }, next);
});
app.use("/mcp", createMcpExpressRouter(mcp));
That scope carries HTTP-level facts. To put tool-call-level facts in the store, the tool name or the parsed arguments, open the scope inside the handler wrapper above instead.
6. Replace the transport plumbing
connect(), connectStatelessTransport(), and getHandlerMaps() are gone. The 1.x McpServer
is a plain blueprint: it declares tools and views, and the HTTP layer turns it into a fresh SDK
server per request.
- Express hosting is unchanged from the outside:
app.use("/mcp", createMcpExpressRouter(mcp)). - The returned router now exposes the underlying SDK handler as
router.mcp, so application code can publish change notifications (router.mcp.notify.toolsChanged()) or share its subscription bus. - Fetch-native runtimes (Vercel, workerd) use
createMcpFetchHandler(mcp)directly. - If you embedded the SDK server yourself, use
mcp.factory()with the SDK v2createMcpHandler.
7. Auth helpers
requireBearerAuth, optionalBearerAuth, mcpAuthMetadataRouter, and their option types are
still exported and work as before, now backed by @modelcontextprotocol/express. The
InvalidTokenError re-export is gone: SDK v2 reports bearer failures through its own OAuth error
responses, and there is no error-class surface to re-export.
8. Testing
The ng-mcp-ui/testing harness is unchanged. One new rule: provideMockMcpUi rejects canned
tool responses with resultType: "input_required". Hosts do not surface multi-round trips to
widgets, so tools called from views must complete in one round. Keep elicitation flows on
chat-path tools.
New capabilities worth adopting
- Sealed state removes in-memory session maps: the server seals state into an HMAC token the widget carries. The scaffolded example tools use it.
- Elicitation lets a chat-path tool ask the user for structured input before it completes.
- Cache hints tell the host how long to cache
tools/listand view resources. Hashed widget assets get aggressive defaults automatically. SeeMcpServerandregisterTool. - Change notifications: publish
router.mcp.notify.toolsChanged()and hosts subscribed throughsubscriptions/listenrefresh without polling. SeecreateMcpExpressRouter.