Cross-posted from my blog. The original, with any updates, lives on fabwaseem.com.
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, which lets an AI agent list, create and publish blog posts and portfolio 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, 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 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 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.
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'sauthInfo ) |
| 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.
// 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 <token>, compare it to a secret in constant time, and return 401 when it is missing or wrong.
// lib/mcp/auth.ts
import { createHash, timingSafeEqual } from "node:crypto"
import type { AuthInfo } from "@modelcontextprotocol/server"
function sameToken(a: string, b: string): boolean {
// Hash both sides so the comparison is constant time and length-independent.
const ha = createHash("sha256").update(a).digest()
const hb = createHash("sha256").update(b).digest()
return timingSafeEqual(ha, hb)
}
export function verifyBearer(request: Request): AuthInfo | null {
const expected = process.env.MCP_TOKEN?.trim()
if (!expected) return null
const header = request.headers.get("authorization") ?? ""
const match = /^Bearer\s+(\S+)$/i.exec(header.trim())
if (!match || !sameToken(match[1], expected)) return null
return { token: match[1], clientId: "owner", scopes: ["posts:write"] }
}
Hashing both values before timingSafeEqual keeps the comparison constant-time even when the lengths differ, which timingSafeEqual alone would reject with an error. This is the same approach the portfolio server uses. It also treats an unset token as "endpoint disabled" and answers 404, so a missing environment variable can never leave the tools open.
Create app/api/mcp/route.ts, build the handler once at module scope with createMcpHandler, and export a function as POST that runs the Origin and bearer checks before calling handler.fetch(request, { authInfo }). Next.js route handlers take a web Request and return a Response, which is exactly what the handler expects.
// app/api/mcp/route.ts
import { createMcpHandler, originValidationResponse } from "@modelcontextprotocol/server"
import { verifyBearer } from "@/lib/mcp/auth"
import { createServer } from "@/lib/mcp/server"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
const handler = createMcpHandler(() => createServer(), { responseMode: "json" })
const ALLOWED_ORIGINS = ["www.example.com", "localhost"]
async function handle(request: Request): Promise<Response> {
const badOrigin = originValidationResponse(request, ALLOWED_ORIGINS)
if (badOrigin) return badOrigin
const authInfo = verifyBearer(request)
if (!authInfo) {
return Response.json(
{ error: { code: "UNAUTHORIZED", message: "Invalid or missing bearer token" } },
{ status: 401, headers: { "WWW-Authenticate": 'Bearer realm="mcp"' } }
)
}
const response = await handler.fetch(request, { authInfo })
const headers = new Headers(response.headers)
headers.set("Cache-Control", "no-store")
return new Response(response.body, { status: response.status, headers })
}
export { handle as POST }
Why each line is there:
runtime = "nodejs"`` node:crypto and your tools probably use a database driver.dynamic = "force-dynamic"`` Cache-Control: no-store``createMcpHandler(() => createServer())`` responseMode: "json"``originValidationResponse`` 403 for an Origin that is not on your list. The spec says servers 403 Forbidden to an invalid one. Requests with no Tested against SDK 2.3.1, this route answers 401 without a token, 403 for Origin: https://evil.com, and 200 with the tool list for a valid token.
Give the client the full URL of the route and the bearer header. Most clients that support remote servers accept a URL plus custom headers; for Cursor it is an entry in ~/.cursor/mcp.json. Use the MCP Inspector to call tools by hand before wiring up an agent.
{
"mcpServers": {
"portfolio": {
"url": "https://www.example.com/api/mcp",
"headers": { "Authorization": "Bearer YOUR_TOKEN" }
}
}
}
You can also smoke-test it with plain curl. The Accept header must list both application/json and text/event-stream, because the spec requires clients to accept either:
curl -s https://www.example.com/api/mcp \
-H "Authorization: Bearer $MCP_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
Use a static bearer token when the only caller is you or your own agents, and OAuth 2.1 when other people's accounts connect. The MCP spec makes authorization optional, but when an HTTP server implements it, it must follow the spec's OAuth 2.1 flow, including Protected Resource Metadata (RFC 9728).
| Static bearer token | OAuth 2.1 | |
|---|---|---|
| Best for | Single owner, internal agents, admin tools | Multi-user apps, public connectors |
| Setup | One env var | Authorization server + discovery endpoint |
| Per-user identity | No (one identity) | Yes, with scopes |
| Revocation | Rotate the secret | Per token, per user |
| Spec-compliant discovery | Not needed for private use | Required ( /.well-known/oauth-protected-resource ) |
A personal portfolio, where only the owner's agents should publish posts, is the textbook static-token case. If you later open tools to other users, swap verifyBearer for a function that validates JWTs against your identity provider and returns real clientId and scopes; the route shape stays the same, because the handler only ever sees authInfo. Vercel's mcp-handler package wraps this pattern with withMcpAuth and a protected-resource handler if you prefer a ready-made adapter.
Treat every tool as a public API endpoint that an unpredictable caller will hit. Check Origin and auth before the SDK, rate-limit by IP, validate every argument with Zod, keep destructive tools behind an explicit confirm: true, return no secrets in results, and log failures without logging tokens.
The checklist the portfolio server follows:
delete_post requires confirm: true, so a model cannot delete by accident. create_post stores a draft, and a separate publish_post tool requires SEO fields and image alt text before anything goes public.no-store on every response
Related: before you ship an MCP server with OAuth, read MCP security in 2026. Both official SDKs had a high-severity OAuth flaw (CVE-2026-104850), and in my lab upgrading alone didn't stop the leak until I passed expectedIssuer.
Yes. The route handler is a stateless web Request to Response function, so it runs on Vercel Functions, a Docker container, or next start on a VPS with no code changes. Because the 2026-07-28 spec dropped protocol sessions, there is no sticky state to share between instances.
The differences are operational. On serverless, set maxDuration high enough for your slowest tool and keep the factory cheap, since it runs per request. On a single VPS, an in-memory rate limiter like the one above works fine; across several instances, move it to Redis. For the cost and trade-off side of that choice, see Coolify vs Vercel for Next.js.
Related: an MCP server gives an agent live tools; an Agent Skill gives it a written procedure for using them. See Agent Skills: how to write a SKILL.md for when to use which.
Expose the same verbs your admin panel has, split into small, clearly described tools: list, get, create, update, publish and delete for each content type, plus helpers like image upload and site info. Small tools with precise schemas are easier for models to choose correctly than one large "do anything" tool.
The portfolio server's tool set, as a reference design:
| Group | Tools |
|---|---|
| Projects | list_projects ,get_project ,create_project ,update_project ,delete_project ,reorder_projects ,set_featured |
| Blog | list_posts ,get_post ,create_post ,update_post ,publish_post ,delete_post |
| Taxonomy | list_categories ,create_category ,list_tags ,create_tag |
| Media | upload_image_from_url ,attach_images |
| Site | get_site_info |
Every write tool revalidates the affected pages, the sitemap and the feeds, so content published by an agent is live and discoverable without a redeploy.
Related: the MCP 2026-07-28 spec removed the initialize handshake and Mcp-Session-Id, so servers no longer keep sessions. See Stateless MCP servers: what changed and how to migrate for the new request format and a tested SDK v2 server.
Related: not sure whether you need an MCP server or a plain REST API? In MCP vs API I benchmarked both on the same data: REST answered in 76 µs at p50 vs 424 µs for MCP, and this site's 21 MCP tools cost 5,949 tokens of context.
Yes. Next.js App Router route handlers accept a web Request and return a Response, which is what the official MCP TypeScript SDK's createMcpHandler works with. Export the handler as POST from app/api/mcp/route.ts, add auth in front of it, and MCP clients can connect over Streamable HTTP.
@modelcontextprotocol/server is v2 of the official TypeScript SDK and implements the 2026-07-28 MCP spec. It replaces the single @modelcontextprotocol/sdk package from v1, which is still maintained for existing projects. v2 uses registerTool with Zod 4 object schemas and createMcpHandler for HTTP.
No. A stateless Streamable HTTP server keeps nothing between requests, so it needs no session store. Your tools can use whatever data layer the app already has. You only need Redis if you want shared rate limiting or caches across several instances.
Run next dev, then connect the MCP Inspector or your client to http://localhost:3000/api/mcp with the bearer header, and call tools/list and each tool. You can also send JSON-RPC with curl, as long as the Accept header lists both application/json and text/event-stream.
For a server only you or your own agents use, yes, if the token is long and random, compared in constant time, sent only over HTTPS, and protected by rate limiting on failed attempts. For multi-user access, use OAuth 2.1 as the MCP authorization spec describes, so each user gets scoped, revocable tokens.
No. /api/mcp and /mcp are conventions. The spec only requires a single endpoint path that supports POST, so mount it on any route and give clients that full URL.
Originally published on fabwaseem.com.