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 via mcp-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ügiges maxDuration 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://localhosthttp://localhost für SSE erzwingen (Mixed‑Content vermeiden).
  • experimental_createMCPClient aus ai 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

  1. Abhängigkeiten installieren und Env setzen:
pnpm install
cp .env.example .env.local
# OPENAI_API_KEY, DATABASE_URL, REDIS_URL füllen
  1. Migrationen und Seeds (optional):
pnpm drizzle:migrate
pnpm seed-attendees
  1. 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 oder attendees_*, 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://localhosthttp://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: httpshttp‑Normalisierung für localhost 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ür createMcpHandler hoch genug ansetzen.
  • Umgebungsvariablen (Vercel → Project → Settings → Environment Variables)

    • OPENAI_API_KEY: dein OpenAI‑Key (oder Provider‑Wechsel im ai‑SDK).
    • DATABASE_URL: Neon‑/Postgres‑Connection‑String (pooled Verbindungen funktionieren gut mit @neondatabase/serverless).
    • REDIS_URL: Redis‑URL für mcp-handler SSE (bei Upstash die Redis‑TLS‑URL rediss://…).
  • Upstash Redis

    • Redis‑DB anlegen, TLS‑URL kopieren (rediss://…) und als REDIS_URL setzen.
  • Neon Postgres

    • Projekt/DB anlegen, Connection‑String als DATABASE_URL setzen.
    • Migrationen lokal oder via CI ausführen; bei Bedarf Seeds fahren.
  • 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.

Ein Beispiel findet ihr sonst auch auf GitHub: