Model Context Protocol (MCP) solves a common headache: giving AI systems a safe, uniform way to call your app’s tools and read your data. Instead of wiring bespoke webhooks for every assistant, you run an MCP server that exposes tools/resources, and any MCP client (your UI, a desktop assistant, CI, etc.) can connect.
In this tutorial you’ll drop a tiny MCP server into a brand-new Next.js (App Router, TypeScript) app, add two example tools, secure the endpoint, and ship it. You’ll deploy wherever you like; if you want a no-drama path, make sure you have a DigitalOcean account handy for App Platform or a small Droplet.
What you’ll build:
- Scaffold a Next.js 15+ project with TypeScript.
- Add a minimal MCP server route using the official TypeScript SDK and Vercel’s adapter.
- Register two tools (
echo
,add
) with input validation. - Call those tools from a lightweight client helper inside your Next.js app.
- Protect the endpoint with a bearer token.
- Containerize and deploy (DigitalOcean instructions included).
This gives you a clean pattern you can copy for real tools (DB lookups, vector search, Slack/Jira actions), then evolve with streaming, resources, and prompts.
Prerequisites:
- Node.js 20+ and npm (or pnpm/bun)
- Git
- Next.js familiarity (App Router)
- A cloud account to deploy (e.g., DigitalOcean)
We’ll rely on the MCP TypeScript SDK and Vercel’s MCP adapter (the adapter’s package name has been @vercel/mcp-adapter
; recent releases indicate a rename to mcp-handler
. If you see the rename, swap imports accordingly—notes below).
1) Create the project
npx create-next-app@latest my-mcp-next --typescript --app
cd my-mcp-next
Use the latest versions when prompted. Next.js 15+ is recommended for stability and performance.
2) Install MCP packages
npm install @modelcontextprotocol/sdk @vercel/mcp-adapter zod
If the adapter has been renamed in your environment:
# alternative if you see the rename note in docs/changelog
npm install mcp-handler zod
Both variants expose the same idea: a thin adapter that turns a Next.js route into an MCP server endpoint.
3) Add an MCP server route
Create app/api/mcp/[transport]/route.ts
. This single file declares your server and its tools.
// app/api/mcp/[transport]/route.ts
import { z } from 'zod';
import { createClientError } from 'next/dist/client/components/not-found-error';
import { NextRequest } from 'next/server';
// SDK: tools/resources/prompts registry + transports
import { Server, type ToolHandler } from '@modelcontextprotocol/sdk';
// Adapter (rename-safe import)
let createMcpHandler: any;
try {
// Preferred current name
({ createMcpHandler } = await import('@vercel/mcp-adapter'));
} catch {
// Fallback if the package was renamed
({ createMcpHandler } = await import('mcp-handler'));
}
// 1) Define tools with schemas
const echoSchema = z.object({ text: z.string().min(1) });
const addSchema = z.object({ x: z.number(), y: z.number() });
const registerTools = (server: Server) => {
server.tool(
'echo',
'Return the same string you send.',
echoSchema,
async ({ text }) => ({
content: [{ type: 'text', text }]
})
);
server.tool(
'add',
'Add two numbers.',
addSchema,
async ({ x, y }) => ({
content: [{ type: 'text', text: String(x + y) }]
})
);
};
// 2) Optional: prompts/resources can go here later
// server.resource('doc', ...); server.prompt('summarize', ...);
// 3) Route handler with simple bearer auth
const handler = createMcpHandler(
async (server: Server) => {
registerTools(server);
},
// server options
{
serverMetadata: { name: 'nextjs-mcp', version: '1.0.0' }
},
// adapter options
{
basePath: '/api/mcp', // your API base path
// For SSE (if you enable it in your adapter): redisUrl: process.env.REDIS_URL,
maxDuration: 60, // tune for your host
verboseLogs: true,
onAuthorize: async (req: NextRequest) => {
const header = req.headers.get('authorization') || '';
const ok = header === `Bearer ${process.env.MCP_API_KEY}`;
return ok ? { subject: 'service-account' } : null;
}
}
);
export { handler as GET, handler as POST }
What this does:
- Registers two tools with Zod validation.
- Exposes an MCP endpoint under
/api/mcp/[transport]
(HTTP or SSE depending on client/adapter). - Applies a simple bearer token gate you’ll set via
MCP_API_KEY
. - Exposes both
GET
andPOST
to support clients that use streamable HTTP or JSON-RPC over HTTP.
Vercel’s adapter supports SSE and stateless HTTP; the example keeps it stateless by default to run anywhere. (Vercel)
4) Add a tiny client helper (to call your own tools)
Put a thin wrapper under lib/mcp-client.ts
so your UI can call your MCP tools without re-implementing protocol details.
// lib/mcp-client.ts
type MCPResult = { content: Array<{ type: 'text'; text: string }> };
export async function callTool(
tool: 'echo' | 'add',
args: Record<string, unknown>
) {
const res = await fetch('/api/mcp/http', {
method: 'POST',
headers: {
'content-type': 'application/json',
// optional: include auth if your server expects it even for same-origin calls
authorization: `Bearer ${process.env.NEXT_PUBLIC_MCP_BROWSER_KEY ?? ''}`
},
body: JSON.stringify({
// Minimal “stateless HTTP” style envelope widely used by adapters
method: tool,
params: args
})
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`MCP call failed: ${res.status} ${text}`);
}
const json = (await res.json()) as MCPResult;
return json;
}
Now wire a simple client component:
// app/page.tsx
'use client';
import { useState } from 'react';
import { callTool } from '@/lib/mcp-client';
export default function Home() {
const [result, setResult] = useState<string>('');
async function doEcho() {
const out = await callTool('echo', { text: 'Hello MCP' });
setResult(out.content?.[0]?.text ?? '');
}
async function doAdd() {
const out = await callTool('add', { x: 3, y: 5 });
setResult(out.content?.[0]?.text ?? '');
}
return (
<main className="p-8 space-y-4">
<h1 className="text-2xl font-semibold">Next.js + MCP</h1>
<div className="space-x-2">
<button className="px-3 py-2 rounded bg-black text-white" onClick={doEcho}>
Echo
</button>
<button className="px-3 py-2 rounded bg-black text-white" onClick={doAdd}>
Add 3 + 5
</button>
</div>
<div className="mt-4">
<span className="font-mono">Result:</span> {result}
</div>
</main>
);
}
This is intentionally simple: under the hood, the adapter translates your HTTP request to MCP’s schema and dispatches to your server.tool
handlers.
5) Configure environment variables
Create .env.local
:
MCP_API_KEY=change-me-in-prod
# If you want the browser to hit the protected endpoint directly (not required)
NEXT_PUBLIC_MCP_BROWSER_KEY=
# Optional if you enable SSE transport in your adapter
# REDIS_URL=redis://...
Restart npm run dev
to load new env vars.
6) Run and test locally
Start the dev server:
npm run dev
Visit http://localhost:3000
and press the buttons.
For external MCP clients (desktop assistants, inspectors), point them to http://localhost:3000/api/mcp/http
and include the bearer token. Most modern clients support streamable HTTP; some still support SSE. The Vercel template’s notes outline both transports and the optional Redis requirement for SSE.
7) Secure the server
The example gated the endpoint with a static bearer. In production you’ll likely:
- Derive claims from your app session (NextAuth/Clerk) and pass them to
onAuthorize
. - Rotate keys and log every tool invocation.
- Scope access per user (which tools/resources are allowed).
If you use Clerk, there’s an end-to-end guide showing how to mount an MCP server in a Next.js route and authorize via OAuth. The patterns map 1:1 to the route you created.
8) Add resources and prompts (optional)
MCP supports resources (read-only data the model can fetch) and prompts (templated multi-message instructions). Add them next to your tools:
// inside the createMcpHandler callback
server.resource('helloDoc', 'A simple text resource', async () => ({
contents: [{ type: 'text', text: 'This is helloDoc content.' }]
}));
server.prompt('askHello', 'Show the helloDoc content', {
messages: [
{ role: 'system', content: 'Use helloDoc to answer.' },
{ role: 'assistant', content: 'Resource says: {{ helloDoc.contents[0].text }}' }
]
});
Clients can enumerate resources
and prompts
and reference them by name. The SDK repo documents these shapes and the streamable HTTP transport.
9) Production deployment on DigitalOcean
Goal: run the Next.js server (which includes your MCP route) on a Droplet or App Platform. Containers keep it tidy.
Create a production Dockerfile:
# Dockerfile
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Ensure Next can find the standalone server
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
# Pass env at runtime (MCP_API_KEY, etc.)
EXPOSE 3000
CMD ["node", "server.js"]
Add a production script in package.json
if needed:
{
"scripts": {
"build": "next build",
"start": "NODE_ENV=production node server.js",
"dev": "next dev"
}
}
And ensure Next outputs a standalone server by adding output: 'standalone'
in next.config.js
:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = { output: 'standalone' };
module.exports = nextConfig;
Push to a public or private repo. In DigitalOcean App Platform, “Create App” → “GitHub” → pick your repo → set build command npm run build
and run command npm start
. Add MCP_API_KEY
(and REDIS_URL
only if you enable SSE). If you prefer a Droplet: install Docker, docker build -t my-mcp-next .
, then docker run -p 80:3000 --env MCP_API_KEY=... my-mcp-next
. The MCP endpoint will live under https://your-domain/api/mcp/http
.
Why this works: your MCP server is just a Next.js route handler, so deploying the app deploys the server. If you outgrow this, extract the MCP server into its own microservice and keep the same transport.
10) Troubleshooting
- 404 or 405 on /api/mcp/http: confirm file path
app/api/mcp/[transport]/route.ts
and that you exported{ handler as GET, handler as POST }
. - 401 Unauthorized: ensure
MCP_API_KEY
is set both locally (.env.local
) and on your host, and the client sendsAuthorization: Bearer ...
. - SSE clients failing: some adapters require Redis for fan-out and connection tracking; enable SSE only if your client needs it.
- Adapter import errors: if
@vercel/mcp-adapter
isn’t found, installmcp-handler
and swap the import (the rename was announced in changelogs). - Client envelope mismatch: when integrating with external tools (Claude/Cursor/VS Code agents), match their expected transport (
http
vssse
) and endpoint (/api/mcp/http
) exactly; most modern clients support streamable HTTP out of the box.
Going further
- AuthZ policies: validate who can call which tool; log every call; pass per-user context to tool handlers.
- Observability: capture timing, input sizes, errors per tool to a metrics sink.
- Real tools: wrap your DB, vector store, file storage, or third-party APIs with strict Zod schemas and clear descriptions to reduce “tool ambiguity.”
- Streams: enable SSE only when a client needs token-level streaming—stateless HTTP is simpler and deploys anywhere.
Recap
You integrated MCP into Next.js with a few files: a route that declares tools, a tiny client helper, and straightforward auth. Because the adapter speaks MCP’s modern streamable HTTP (and optionally SSE), your endpoint works with today’s assistants and agent frameworks. From here, layer in real tools, resources, and prompts, and deploy behind your preferred platform’s identity.