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/mcpviamcp-handler. - Einen Chat‑Endpoint (
/api/chat), der Antworten streamt und MCP‑Tools aufruft. - Eine schlanke UI unter
/chatmit@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-domai,@ai-sdk/openai,@ai-sdk/reactmcp-handlerzoddrizzle-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,redisUrlund großzügigesmaxDurationsetzen.- Tools mit
zodtypisieren 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
/sseendet. - In lokaler Dev‑Umgebung
https://localhost→http://localhostfür SSE erzwingen (Mixed‑Content vermeiden). experimental_createMCPClientausainutzen, 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_diceoderattendees_*, um DB/Output zu verifizieren.
Stolpersteine (Stand heute)
- Node.js‑Runtime für MCP:
export const runtime = 'nodejs'verwenden. - SSE braucht Redis:
REDIS_URLsetzen, sonst Laufzeit/Verbindungs‑Fehler. - Catch‑All‑Route für
/sse:src/app/api/mcp/[...mcp]/route.tshinzufügen. - Mixed Content lokal:
https://localhost→http://localhostfür SSE. - Client‑URL immer mit
/ssebeenden. - Großzügiges
maxDurationfü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/requiredScopessetzen. - Inspector/Clients dieselben Header mitsenden lassen.
Troubleshooting
- 404 auf
/api/mcp/sse: Catch‑All‑Route vorhanden und korrekt re‑exportiert? - Keine SSE‑Verbindung:
REDIS_URLprüfen; Redis‑Erreichbarkeit sicherstellen; Logs checken. - Mixed‑Content‑Fehler:
https→http‑Normalisierung fürlocalhostaktiv?
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,
withMcpAuthaktivieren, 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.
maxDurationfürcreateMcpHandlerhoch 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-handlerSSE (bei Upstash die Redis‑TLS‑URLrediss://…).
-
Upstash Redis
- Redis‑DB anlegen, TLS‑URL kopieren (
rediss://…) und alsREDIS_URLsetzen.
- Redis‑DB anlegen, TLS‑URL kopieren (
-
Neon Postgres
- Projekt/DB anlegen, Connection‑String als
DATABASE_URLsetzen. - Migrationen lokal oder via CI ausführen; bei Bedarf Seeds fahren.
- Projekt/DB anlegen, Connection‑String als
-
Nach dem Deploy
/chataufrufen und UI prüfen.- Mit MCP Inspector
https://<deine-domain>/api/mcpverbinden. - 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: