Skip to main content
Building a Remote MCP Server in Next.js with mcp-handler 2: Tools, OAuth and the Stateless 2026-07-28 Spec

Building a Remote MCP Server in Next.js with mcp-handler 2: Tools, OAuth and the Stateless 2026-07-28 Spec

November 4, 2026
AI Engineering
8 min read

Key takeaways

The Model Context Protocol (MCP) is an open protocol that lets AI clients discover and call external tools. A remote MCP server exposes those tools over Streamable HTTP instead of a local stdio process.

mcp-handler 2.x wraps the official MCP TypeScript SDK v2, published as @modelcontextprotocol/server, and returns a Web-standard request handler that a Next.js Route Handler exports as GET and POST.

The 2026-07-28 MCP spec is stateless: no sessions, a per-request _meta envelope and a server/discover method. mcp-handler 2 still serves 2025-era Streamable HTTP clients from the same route and drops the HTTP+SSE transport and Redis.

Tool design decides whether a model calls a tool correctly: one task per tool, a description written like a prompt, strict input schemas, outputSchema with structuredContent, and isError results the model can recover from.

withMcpAuth verifies bearer tokens and answers with RFC 9728 WWW-Authenticate challenges. The 2026-07-28 spec deprecates Dynamic Client Registration in favor of Client ID Metadata Documents, which the authorization server implements.

What is a remote MCP server, and when is it worth building one?

A remote MCP server is an HTTPS endpoint that speaks the Model Context Protocol, so any MCP client can list its tools and call them on behalf of a user. A local MCP server runs as a child process over stdio on the user's machine; a remote MCP server runs next to the product API, uses the product's auth, and works for users who will never install a CLI.

At Devya we build a remote MCP server when a product already has an API that users want an assistant to drive: search records, summarize an account, create a draft. We skip it when the task needs local files or a shell, which is still stdio territory. The rule our team holds to: an MCP server is one more client of the domain layer, exactly like the web UI, and it passes through the same authorization checks.

How do we mount an MCP server in a Next.js Route Handler?

createMcpHandler from mcp-handler takes an initialize callback that registers tools on the server and returns a Web-standard request handler. We export that handler as GET and POST from a route such as app/api/mcp/route.ts; /api/mcp is a convention, not a requirement, because mcp-handler 2 never inspects the pathname.

Each tool is registered with server.registerTool and a config object: a title, a description, an inputSchema built with z.object from Zod 4, an optional outputSchema, and annotations. The callback receives the validated arguments plus a ctx object, and ctx.http?.authInfo carries the verified user once OAuth is in place.

Three details do most of the work. inputSchema is a full Standard Schema, so the SDK validates arguments before the callback runs and turns a rejection into a tool result the model can correct. outputSchema plus structuredContent gives clients a typed JSON payload next to the human-readable content text. annotations such as readOnlyHint: true tell the host that a tool only reads data, which hosts use when deciding whether to ask the user for approval.

The mcp-handler 2 route is stateless, so it runs on Vercel Functions or any Node.js server without sticky sessions. Every request carries everything the server needs, which is why the Redis dependency from 1.x is gone.

What changed in mcp-handler 2 and the 2026-07-28 MCP spec?

mcp-handler 2 swaps the SDK package, the schema shape and the transport list, so a 1.x route does not compile unchanged. These are the edits our migration needed:

SDK package: @modelcontextprotocol/sdk v1 is replaced by @modelcontextprotocol/server v2 plus zod 4.

Tool registration: the variadic server.tool(...) form is removed; registerTool, registerPrompt and registerResource remain.

inputSchema: a raw Zod shape is replaced by a full Standard Schema such as z.object({ ... }).

Auth info inside a tool: extra.authInfo becomes ctx.http?.authInfo.

Transports: HTTP+SSE and its Redis backing are removed; only stateless Streamable HTTP remains.

Routing and signature: basePath, maxDuration and sessionIdGenerator options are gone, the handler is mounted at any route, and createMcpHandler(init, serverOptions, config) becomes createMcpHandler(init, options).

On the protocol side, the 2026-07-28 MCP spec drops sessions: each request carries a _meta envelope, and clients can call server/discover to learn what a server supports. Clients that still speak 2025-era Streamable HTTP keep working against the same route through the SDK's stateless legacy fallback, and session operations over GET and DELETE answer 405.

How should MCP tools be designed so a model calls them correctly?

A model picks and fills MCP tools from their names, descriptions and schemas alone, so we treat tool design as prompt design with a type system attached.

One user task per tool, not one tool per REST endpoint: search_invoices and create_invoice_draft beat a generic call_api tool with a method argument.

Write the description as instructions: what the tool returns, its limits, and which tool to call next. A line such as “Returns at most 20 results, newest first” saves a wasted call.

Constrain inputs with enums, min and max values, and .describe() hints. Every constraint the schema enforces is one the model cannot get wrong silently.

Return compact text plus structuredContent. Large JSON dumps fill the client's context window, so we summarize in content and keep the typed payload in structuredContent.

Return isError: true with an actionable message for expected failures. The MCP TypeScript SDK treats a thrown error and an explicit isError result the same way and skips outputSchema validation on error results.

Mark write tools honestly with destructiveHint and idempotentHint. Annotations are hints for client approval prompts and never enforce anything, so the authorization check still lives in the callback.

MCP tool output is also untrusted input for the model's next turn. A record that contains instructions lands in the model's context, so we keep write tools narrow and never let one tool's output decide another tool's permissions.

How do we add OAuth to a remote MCP server?

withMcpAuth wraps the MCP handler, extracts the bearer token, calls a verify function, and answers 401 or 403 with an RFC 9728 WWW-Authenticate challenge that points the client at the protected resource metadata. The verify function returns an AuthInfo object with the token, clientId, scopes and an extra field such as the user id, and options like required: true and requiredScopes enforce access before any tool runs.

RFC 9728 Protected Resource Metadata is a JSON document that tells an MCP client which authorization server issues tokens for a resource. mcp-handler serves it from app/.well-known/oauth-protected-resource/route.ts with protectedResourceHandler and an authServerUrls list, plus metadataCorsOptionsRequestHandler for the OPTIONS preflight.

Two rules are non-negotiable for our team. We verify the token audience and accept only tokens minted for the MCP server's URL, never forwarding a client's token to upstream services. And we keep the token out of tool output and logs.

Client registration changed in the 2026-07-28 spec. Dynamic Client Registration (DCR) is deprecated in favor of Client ID Metadata Documents (CIMD), where an OAuth client identifies itself with an HTTPS URL that serves its metadata. The authorization server implements CIMD and advertises client_id_metadata_document_supported in its RFC 8414 metadata, so the MCP route stays the same.

How do we test a remote MCP server and connect real clients?

We test in three layers. Unit tests call the domain functions directly, because a tool callback should be a thin adapter. The MCP Inspector (npx @modelcontextprotocol/inspector) connects to the local route so we can call each tool by hand and read raw results, including validation errors. The MCP TypeScript SDK also supports an in-memory Client connected to the server for automated protocol-level tests.

Then a real client. Clients that support Streamable HTTP take the route URL directly; stdio-only clients go through the mcp-remote bridge with npx -y mcp-remote and the same URL.

The real-client pass catches what the Inspector cannot: a vague description that makes the model pick the wrong tool, or output too long for the client's context. We fix those in the description and the response shape, not by adding more tools.

FAQ

Q: Do we need Redis or sticky sessions to run an MCP server on Vercel?

A: No. mcp-handler 2 serves MCP statelessly, so each request is independent and runs on Vercel Functions without Redis. Redis was only needed by the HTTP+SSE transport, which mcp-handler 2.x removed.

Q: Can a project keep using @modelcontextprotocol/sdk with mcp-handler 2?

A: No. mcp-handler 2 has a peer dependency on @modelcontextprotocol/server ^2.0.0, the v2 MCP TypeScript SDK, plus Zod 4. A project that stays on @modelcontextprotocol/sdk v1 has to stay on mcp-handler 1.x.

Q: What happens to clients that only speak the 2025 MCP spec?

A: They keep working. mcp-handler 2 serves 2025-era Streamable HTTP through the SDK's stateless legacy fallback on the same route; only session operations over GET and DELETE return 405. The HTTP+SSE transport from the 2024-11-05 spec is no longer served.

Q: Is readOnlyHint enough to stop a model from changing data?

A: No. MCP tool annotations such as readOnlyHint and destructiveHint are hints that clients use for approval prompts, and the SDK does not enforce them. Authorization belongs inside the tool callback, using the verified user from ctx.http?.authInfo.

Q: Does mcp-handler only work with Next.js?

A: No. createMcpHandler returns a Web-standard request handler, so it mounts in Next.js Route Handlers, SvelteKit routes, Nuxt/Nitro via fromWebHandler and Hono via c.req.raw. Express needs a Web Request adapter or the official MCP framework middleware.