How to Build an MCP Server in Next.js: Streamable HTTP, Auth and Tools A developer published a step-by-step guide for building a Model Context Protocol server inside a Next.js App Router application using the official TypeScript SDK v2 (2.3.1), registering tools on an McpServer with Zod input schemas and exporting a createMcpHandler-wrapped POST route at app/api/mcp/route.ts. The guide, modeled on the MCP server behind fabwaseem.com that lets an AI agent list, create and publish blog posts and portfolio projects through one authenticated endpoint, recommends an Origin check and bearer-token auth in front of the handler and argues for Streamable HTTP over stdio for web-hosted servers. Cross-posted from my blog. The original, with any updates, lives on fabwaseem.com https://www.fabwaseem.com/blog/build-mcp-server-nextjs . To build an MCP server in Next.js, install the official TypeScript SDK @modelcontextprotocol/server , register your tools on an McpServer with Zod input schemas, wrap it with createMcpHandler , and export that handler as POST from an App Router route handler such as app/api/mcp/route.ts . Put an Origin check and bearer-token auth in front of it, and any MCP client can call your tools over Streamable HTTP. This guide builds exactly that, step by step, using the current v2 SDK version 2.3.1 at the time of writing . It is modelled on the real MCP server behind this site, fabwaseem.com https://www.fabwaseem.com , which lets an AI agent list, create and publish blog posts and portfolio projects https://www.fabwaseem.com/projects through one authenticated endpoint. Every code block below was type-checked and run against the published SDK before this post went out. An MCP server is a program that exposes tools, resources and prompts to AI applications through the Model Context Protocol https://modelcontextprotocol.io/docs/getting-started/intro , an open standard. The AI client ChatGPT, Claude, Cursor, an agent framework discovers what the server offers and calls it with JSON-RPC messages, so one integration works across every compatible client. In practice, a tool is a function the model can call create post , a resource is data the client can read by URI, and a prompt is a reusable template. Most remote servers start with tools only, and that is what this guide covers. Building it inside Next.js means your MCP tools live next to the code they call. The route handler can import the same Prisma client, validation and business logic as your pages and admin, deploys with the app, and shares its domain and HTTPS certificate. You do not run, secure or monitor a second service. That is the main reason the portfolio server lives at /api/mcp instead of in a separate repo. The tools call the same data layer that powers the blog https://www.fabwaseem.com/blog and admin panel, and revalidate the same pages after a write, so an agent-published post shows up exactly like a hand-published one. Two things are worth clearing up early, because they are common search confusions: Request to Response function, so this runs anywhere Next.js runs, including a VPS. This site, for example, is self-hosted on Coolify; see Use Streamable HTTP for a server hosted in a Next.js app. The MCP specification https://modelcontextprotocol.io/specification/2026-07-28/basic/transports defines two standard transports: stdio, where the client launches your server as a local child process, and Streamable HTTP, where every message is an HTTP POST to one endpoint. A web app can only do the second. | | stdio | Streamable HTTP | |---|---|---| | Where it runs | On the user's machine, as a subprocess | On a server, at a URL | | Who can connect | The one host app that launched it | Any client that can reach the URL | | Auth | Credentials from environment variables | Bearer token or OAuth 2.1 | | Deploy | Ship a package or binary | Deploy once with your app | | Fits Next.js | No | Yes, as a route handler | | Typical client | Desktop app, local IDE | ChatGPT connectors, remote agents, Cursor | Quotable facts from the current spec revision 2026-07-28 : https://example.com/mcp Install @modelcontextprotocol/server . It is v2 of the official MCP TypeScript SDK, the stable line, and implements the 2026-07-28 spec. It replaces the single @modelcontextprotocol/sdk package from v1, which is still published 1.32.x for existing projects. New servers should start on v2; v1 code needs the upgrade guide https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2 . npm install @modelcontextprotocol/server zod The SDK depends on Zod 4 zod ^4.2.0 and you import it as zod/v4 . If you are on TypeScript 6 or later, add "types": "node" to compilerOptions in tsconfig.json , because the published types reference Buffer . The v1 to v2 differences you will meet in older tutorials: | v1 @modelcontextprotocol/sdk | v2 @modelcontextprotocol/server | |---|---| | server.tool name, shape, cb | server.registerTool name, config, cb | | Raw Zod shape for inputs | inputSchema: z.object {...} | | StreamableHTTPServerTransport + connect per request | createMcpHandler factory | | extra.authInfo in tools | ctx.http?.authInfo or the factory's authInfo | | HTTP+SSE transport available | Removed; Streamable HTTP only | Building an MCP server in Next.js takes five steps: install the SDK, define tools in a server factory, write a bearer-token check, mount the handler in app/api/mcp/route.ts behind Origin and auth checks, then connect a client. The whole thing is three small files. @modelcontextprotocol/server and zod . lib/mcp/server.ts with a function that returns a fresh McpServer and registers your tools. lib/mcp/auth.ts to verify the Authorization: Bearer header. app/api/mcp/route.ts that validates Origin, verifies the token, then calls handler.fetch request, { authInfo } . MCP TOKEN , deploy, and point a client at https://your-domain/api/mcp . Call server.registerTool name, config, handler . The config holds a title , a description the model reads, a Zod inputSchema , and optional outputSchema and annotations . The SDK turns the Zod schema into the JSON Schema clients see, validates arguments before your handler runs, and infers the handler's types. js // lib/mcp/server.ts import { McpServer } from "@modelcontextprotocol/server" import as z from "zod/v4" // Stand-in for your data layer Prisma, Drizzle, a CMS API... . const posts = { slug: "self-host-nextjs-with-coolify", title: "How to Self-Host Next.js with Coolify", status: "published" }, export function createServer { const server = new McpServer { name: "portfolio", version: "1.0.0" } server.registerTool "list posts", { title: "List posts", description: "List blog posts. Filter by status or a search string.", inputSchema: z.object { status: z.enum "draft", "published" .optional , q: z.string .max 200 .optional .describe "Matches title or slug" , } , annotations: { readOnlyHint: true }, }, async { status, q } = { const hits = posts.filter p = status || p.status === status && q || ${p.title} ${p.slug} .toLowerCase .includes q.toLowerCase return { content: { type: "text", text: JSON.stringify hits } } } server.registerTool "create post", { title: "Create post", description: "Create a draft post. Never publishes.", inputSchema: z.object { title: z.string .min 8 .max 180 , slug: z.string .regex /^ a-z0-9 + ?:- a-z0-9 + $/ , content: z.string .min 1 , } , outputSchema: z.object { slug: z.string , status: z.literal "draft" } , annotations: { readOnlyHint: false, destructiveHint: false }, }, async { title, slug } = { if posts.some p = p.slug === slug { return { content: { type: "text", text: Slug "${slug}" is taken } , isError: true } } posts.push { slug, title, status: "draft" } const output = { slug, status: "draft" as const } return { content: { type: "text", text: JSON.stringify output } , structuredContent: output } } return server } A few details that matter more than they look: .describe on a field is carried into the advertised JSON Schema, so spend words there isError: true and a readable message, so the model can correct itself and retry. outputSchema plus structuredContent readOnlyHint and destructiveHint help a client decide what to auto-approve, but they never change how the SDK runs the tool. Enforce permissions in your handler. Verify the token yourself, in front of the SDK. The SDK's HTTP handler validates no token and no Origin header; it only passes through an authInfo object you give it. Read Authorization: Bearer