MCP‑Server mit Next.js: Praxisleitfaden (inkl. Inspector)
24. August 2025 - Beni & Daniel
Dieser Beitrag zeigt, wie du einen echten Model Context Protocol (MCP)‑Server in einer Next.js‑App aufsetzt, lokal testest und produktiv ausrollst – basierend auf dem Projektaufbau in diesem Repository. Du erfährst, welche Pakete sinnvoll sind, welche Stolpersteine es aktuell gibt und wie du alles mit dem MCP Inspector verifizierst.
Kurz gesagt: Ein Server, der KI‑Modelle sicher mit deinen Tools/Resources verbindet – direkt in deinem Next.js‑Projekt.
Für wen ist das gedacht?
- Für Teams, die Tools/Resources per MCP an Modelle anbinden wollen.
- Für alle, die Next.js bereits für UI, APIs, Env/Logging/Auth nutzen.
- Für Deployments auf Vercel und andere Next.js‑fähige Plattformen.
Warum Next.js für MCP?
- Vertrauter Stack: API, UI, Tools und Infra in einem Codebase.
- Flexible Runtimes: Edge für schnelle Chat‑Antworten, Node.js für lange SSE/Jobs.
- Dateibasierte Routen: Saubere Abbildung auf MCP‑Endpunkte.
- Portable Deploys: Lokal und in der Cloud schnell startklar.
Was wir bauen
- Einen HTTP‑basierten MCP‑Server unter
/api/mcp
viamcp-handler
. - Einen Chat‑Endpoint (
/api/chat
), der Antworten streamt und MCP‑Tools aufruft. - Eine schlanke UI unter
/chat
mit@ai-sdk/react
. - Ein kleines, Postgres‑gestütztes Tool‑Set (Attendees CRUD) mit Drizzle ORM + Neon HTTP‑Driver.
- Verifiziert mit MCP Inspector.
Pakete
next
,react
,react-dom
ai
,@ai-sdk/openai
,@ai-sdk/react
mcp-handler
zod
drizzle-orm
,drizzle-kit
@neondatabase/serverless
Voraussetzungen
- Node.js 18+ und pnpm/npm/yarn
- Postgres‑Datenbank (Neon, lokal, …)
- Redis‑URL für SSE (benötigt von
mcp-handler
) - OpenAI API‑Key (oder anderer Provider über
ai
)
Kopiere .env.example
nach .env.local
und fülle die Variablen:
OPENAI_API_KEY=sk-...
DATABASE_URL=postgres://...
REDIS_URL=redis://localhost:6379
Datenbank: Schema und Seeds
Das Schema liegt in src/db/schema.ts
und definiert eine einfache attendees
‑Tabelle.
// src/db/schema.ts
import { pgTable, serial, varchar } from 'drizzle-orm/pg-core';
export const attendees = pgTable('attendees', {
id: serial('id').primaryKey(),
firstName: varchar('first_name', { length: 255 }).notNull(),
lastName: varchar('last_name', { length: 255 }).notNull(),
nickname: varchar('nickname', { length: 255 }),
});
Nützliche Commands (siehe package.json
):
pnpm drizzle:generate
pnpm drizzle:migrate
pnpm seed-attendees
MCP‑Server in Next.js
Wir mounten MCP unter /api/mcp
mit mcp-handler
. Wichtige Punkte:
export const runtime = 'nodejs'
für lange Requests und Redis‑Zugriff.basePath
,redisUrl
und großzügigesmaxDuration
setzen.- Tools mit
zod
typisieren und strukturierte Inhalte zurückgeben.
// src/app/api/mcp/route.ts
import { sql } from '@/lib/db';
import { createMcpHandler } from 'mcp-handler';
import { z } from 'zod';
export const runtime = 'nodejs';
const handler = createMcpHandler(
(server) => {
// Beispiel‑Tool
server.tool(
'roll_dice',
'Würfelt einen N‑seitigen Würfel',
{ sides: z.number().int().min(2) },
async ({ sides }) => {
const value = 1 + Math.floor(Math.random() * sides);
return { content: [{ type: 'text', text: `Du hast eine ${value} gewürfelt.` }] };
}
);
// DB‑gestützte Tools
server.tool(
'attendees_list',
'Listet alle Teilnehmenden',
{},
async () => {
const rows = await sql`SELECT id, first_name, last_name, nickname FROM attendees ORDER BY id`;
const data = (rows as any[]).map(r => ({
id: r.id, firstName: r.first_name, lastName: r.last_name, nickname: r.nickname,
}));
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
}
);
},
{},
{
basePath: '/api/mcp',
redisUrl: process.env.REDIS_URL,
maxDuration: 800,
}
);
export { handler as DELETE, handler as GET, handler as POST };
Routing‑Fix aktuell nötig
Next.js muss Unterpfade unter /api/mcp
bedienen (z. B. /api/mcp/sse
). Füge eine Catch‑All‑Route hinzu, die die Handler re‑exportiert:
// src/app/api/mcp/[...mcp]/route.ts
export { DELETE, GET, POST, runtime } from '../route';
Chat‑Endpoint, der MCP‑Tools aufruft
Der Chat läuft performant auf Edge, spricht aber per SSE mit dem Node.js‑MCP‑Server.
Wichtig dabei:
- MCP‑SSE‑URL normalisieren und sicherstellen, dass sie mit
/sse
endet. - In lokaler Dev‑Umgebung
https://localhost
→http://localhost
für SSE erzwingen (Mixed‑Content vermeiden). experimental_createMCPClient
ausai
nutzen, um Tools zu beziehen und vom Modell aufrufen zu lassen.
// src/app/api/chat/route.ts
import { openai } from '@ai-sdk/openai';
import { convertToModelMessages, experimental_createMCPClient, stepCountIs, streamText, UIMessage } from 'ai';
import { NextRequest } from 'next/server';
const MODEL = 'gpt-4o-mini';
export const runtime = 'edge';
export const maxDuration = 60;
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => ({}));
const { messages = [] }: { messages: UIMessage[] } = body;
let mcpClient: Awaited<ReturnType<typeof experimental_createMCPClient>> | null = null;
try {
const origin = req.nextUrl.origin;
const configured = `${origin}/api/mcp`;
let mcpUrl: string;
try {
const u = new URL(configured);
if ((u.hostname === 'localhost' || u.hostname === '127.0.0.1') && u.protocol === 'https:') {
u.protocol = 'http:';
}
if (!u.pathname.endsWith('/sse')) u.pathname = `${u.pathname.replace(/\/$/, '')}/sse`;
mcpUrl = u.toString();
} catch {
mcpUrl = configured.endsWith('/sse') ? configured : `${configured.replace(/\/$/, '')}/sse`;
}
mcpClient = await experimental_createMCPClient({
transport: { type: 'sse', url: mcpUrl },
onUncaughtError: (err) => console.error('MCP error', err),
});
const tools = await mcpClient.tools();
const result = streamText({
model: openai(MODEL),
messages: convertToModelMessages(messages),
tools,
stopWhen: stepCountIs(2),
});
return result.toUIMessageStreamResponse();
} catch (e) {
await mcpClient?.close().catch(() => {});
const msg = e instanceof Error ? e.message : String(e);
return new Response('Chat error: ' + msg, { status: 500 });
}
}
Minimale Chat‑UI
// src/app/chat/page.tsx (Ausschnitt)
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { useMemo } from 'react';
export default function ChatPage() {
const transport = useMemo(() => new DefaultChatTransport({ api: '/api/chat' }), []);
const { messages, sendMessage, status } = useChat({ id: 'main-chat', transport });
// …Messages rendern und Input
}
Routen & Runtimes (Überblick)
Route | Runtime | Zweck |
---|---|---|
/api/mcp |
Node.js | MCP‑Server (HTTP/SSE via Redis) |
/api/mcp/sse |
Node.js | SSE‑Transport (Catch‑All) |
/api/chat |
Edge | Chat‑Streaming + Tool‑Calls |
/chat |
Client | UI |
Lokal starten
- Abhängigkeiten installieren und Env setzen:
pnpm install
cp .env.example .env.local
# OPENAI_API_KEY, DATABASE_URL, REDIS_URL füllen
- Migrationen und Seeds (optional):
pnpm drizzle:migrate
pnpm seed-attendees
- Dev‑Server starten:
pnpm dev
# http://localhost:3000/chat öffnen
MCP Inspector: Entdecken & Debuggen
- Verbinde den Inspector mit
http://localhost:3000/api/mcp
. - Der SSE‑Pfad ist
/api/mcp/sse
– viele Tools erkennen ihn automatisch; ansonsten manuell anhängen. - Falls du Auth aktivierst, trage die benötigten Header/Tokens im Inspector ein.
- Teste Tools wie
roll_dice
oderattendees_*
, um DB/Output zu verifizieren.
Stolpersteine (Stand heute)
- Node.js‑Runtime für MCP:
export const runtime = 'nodejs'
verwenden. - SSE braucht Redis:
REDIS_URL
setzen, sonst Laufzeit/Verbindungs‑Fehler. - Catch‑All‑Route für
/sse
:src/app/api/mcp/[...mcp]/route.ts
hinzufügen. - Mixed Content lokal:
https://localhost
→http://localhost
für SSE. - Client‑URL immer mit
/sse
beenden. - Großzügiges
maxDuration
für lange Tools (z. B. 800 s).
Optional: Auth hinzufügen
mcp-handler
unterstützt pluggable Auth. Du kannst einen Bearer‑Token‑Validator implementieren und den Handler mit withMcpAuth
wrappen (Beispiel im Repo kommentiert).
verifyToken(req, token)
implementieren und Scopes/Identity zurückgeben.- Handler wrappen,
required
/requiredScopes
setzen. - Inspector/Clients dieselben Header mitsenden lassen.
Troubleshooting
- 404 auf
/api/mcp/sse
: Catch‑All‑Route vorhanden und korrekt re‑exportiert? - Keine SSE‑Verbindung:
REDIS_URL
prüfen; Redis‑Erreichbarkeit sicherstellen; Logs checken. - Mixed‑Content‑Fehler:
https
→http
‑Normalisierung fürlocalhost
aktiv?
Zusammenfassung
Mit wenig Code bekommst du einen HTTP‑basierten MCP‑Server in Next.js zum Laufen, stellst getypte Tools über SSE bereit und lässt sie vom Modell via ai
‑SDK aufrufen. Kritisch sind der Runtime‑Split (Edge vs. Node.js), SSE‑Verkabelung (Redis + /sse
) und die Catch‑All‑Route. Der MCP Inspector macht die Validierung leicht.
Nächste Schritte: Weitere Tools/Resources hinzufügen,
withMcpAuth
aktivieren, auf Vercel deployen und Redis/Postgres verwalten.
Deployment (Vercel + Upstash + Neon)
-
Vercel‑Projekt
- Repo pushen und in Vercel importieren.
- App Router funktioniert ohne Extra‑Config; MCP‑Route läuft als Node.js‑Funktion, Chat‑Route auf Edge.
maxDuration
fürcreateMcpHandler
hoch genug ansetzen.
-
Umgebungsvariablen (Vercel → Project → Settings → Environment Variables)
OPENAI_API_KEY
: dein OpenAI‑Key (oder Provider‑Wechsel imai
‑SDK).DATABASE_URL
: Neon‑/Postgres‑Connection‑String (pooled Verbindungen funktionieren gut mit@neondatabase/serverless
).REDIS_URL
: Redis‑URL fürmcp-handler
SSE (bei Upstash die Redis‑TLS‑URLrediss://…
).
-
Upstash Redis
- Redis‑DB anlegen, TLS‑URL kopieren (
rediss://…
) und alsREDIS_URL
setzen.
- Redis‑DB anlegen, TLS‑URL kopieren (
-
Neon Postgres
- Projekt/DB anlegen, Connection‑String als
DATABASE_URL
setzen. - Migrationen lokal oder via CI ausführen; bei Bedarf Seeds fahren.
- Projekt/DB anlegen, Connection‑String als
-
Nach dem Deploy
/chat
aufrufen und UI prüfen.- Mit MCP Inspector
https://<deine-domain>/api/mcp
verbinden. - Falls Auth aktiv: passende Header/Tokens im Inspector setzen.
-
Hinweise
- Der
http://localhost
‑Fix gilt nur lokal; in Produktion auf HTTPS bleiben. - Bei Timeouts: Tool‑Laufzeiten senken oder Function‑Limits erhöhen; Chat auf Edge, Schweres auf Node.js.
- Der
Ein Beispiel findet ihr sonst auch auf GitHub: