Loading...
Alle Artikel
AI Infrastructure · 9 min read

MCP-Server-Implementierungsleitfaden: Model Context Protocol für Produktion

Einen produktionsreifen Model-Context-Protocol-Server in TypeScript bauen – mit Authentifizierung, Rate Limiting, Observability und Kubernetes-Deployment.

Warum MCP zählt

Das Model Context Protocol wurde Ende 2024 von Anthropic eingeführt und ist seitdem das Näheste an einem Standard, um LLM-Anwendungen mit Tools, Datenquellen und externen Systemen zu verbinden. 2026 wird es von Claude Desktop, Claude Code, Cursor, Zed, den jüngsten OpenAI-Clients und einem langen Schwanz von Agents unterstützt. Wenn Sie ein internes API pflegen, das AI-Assistenten sicher aufrufen können sollen – ein Ticketing-System, ein Runbook-Store, ein Metrics-Backend –, ist das Exponieren via MCP der reibungsärmste Weg.

Dieser Beitrag ist die produktionsreife Version eines MCP-Servers. Kein Hello World. Wir behandeln Authentifizierung, Autorisierung, Rate Limiting, Observability und Deployment. Die Beispiele nutzen das offizielle TypeScript-SDK.

Die fünf Konzepte, die Sie brauchen

MCP definiert ein kleines Vokabular. Verstehen Sie diese fünf und Sie verstehen 90 % der Spec.

  1. Server. Ein Prozess, der Capabilities exponiert.
  2. Client. Eine LLM-Anwendung, die sich mit Servern verbindet.
  3. Tools. Aufrufbare Funktionen mit typisierten Argumenten, die das Modell invoken kann.
  4. Resources. Read-only-Datenquellen (Dateien, Records, Seiten), die das Modell in den Kontext aufnehmen kann.
  5. Prompts. Parametrisierte Prompt-Templates, die der Server dem Client exponiert.

Die meisten produktiven MCP-Server drehen sich tatsächlich um Tools und Resources. Prompts sind nützlich, aber sekundär.

Transport: stdio oder HTTP

MCP unterstützt mehrere Transports. Für lokale Entwicklung ist stdio die offensichtliche Wahl – der Client spawnt den Server als Subprozess, und beide reden über stdin/stdout. Für Produktion wollen Sie HTTP mit Server-Sent Events (SSE), den netzwerktauglichen Transport, den die Spec 2025 standardisiert hat. Er unterstützt mehrere Clients, TLS, Auth-Header und Load Balancer.

Wenn Ihr Server von Agents auf Entwickler-Laptops konsumiert wird, shippen Sie beide Transports. Wenn er von einer Produktions-Agent-Plattform konsumiert wird, shippen Sie nur HTTP.

Projektstruktur

mcp-ticketing/
├── src/
│   ├── index.ts          # entrypoint
│   ├── server.ts         # server setup
│   ├── auth.ts           # auth middleware
│   ├── tools/
│   │   ├── search.ts
│   │   ├── create_ticket.ts
│   │   └── close_ticket.ts
│   ├── resources/
│   │   └── ticket.ts
│   ├── ratelimit.ts
│   └── telemetry.ts
├── Dockerfile
├── docker-compose.yaml
├── k8s/
│   ├── deployment.yaml
│   ├── service.yaml
│   └── ingress.yaml
├── package.json
└── tsconfig.json

Minimaler Server mit dem offiziellen SDK

Das offizielle Paket @modelcontextprotocol/sdk stellt die Protokoll-Klempnerei bereit. Sie implementieren Tools und Resources; das SDK macht den Rest.

// src/server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import { searchTickets, createTicket, closeTicket } from "./tools/index.js";
import { readTicketResource } from "./resources/ticket.js";

export function buildServer(): McpServer {
  const server = new McpServer(
    {
      name: "ticketing",
      version: "1.2.0",
    },
    {
      capabilities: {
        tools: {},
        resources: { subscribe: false, listChanged: true },
      },
    },
  );

  server.registerTool(
    "search_tickets",
    {
      title: "Search tickets",
      description: "Search the ticketing system by free text and optional status",
      inputSchema: {
        query: z.string().min(1).max(500),
        status: z.enum(["open", "closed", "pending"]).optional(),
        limit: z.number().int().min(1).max(50).default(10),
      },
    },
    async (args, extra) => {
      const results = await searchTickets(args, extra.sessionId);
      return {
        content: [
          { type: "text", text: JSON.stringify(results, null, 2) },
        ],
      };
    },
  );

  server.registerTool(
    "create_ticket",
    {
      title: "Create ticket",
      description: "Create a new ticket. Requires write permission.",
      inputSchema: {
        title: z.string().min(1).max(200),
        body: z.string().min(1).max(10_000),
        priority: z.enum(["low", "medium", "high"]).default("medium"),
      },
    },
    async (args, extra) => {
      const ticket = await createTicket(args, extra.sessionId);
      return {
        content: [{ type: "text", text: `Created ticket ${ticket.id}` }],
      };
    },
  );

  server.registerTool(
    "close_ticket",
    {
      title: "Close ticket",
      description: "Close an existing ticket by ID.",
      inputSchema: {
        id: z.string().regex(/^T-\d+$/),
        resolution: z.string().max(1000),
      },
    },
    async (args, extra) => {
      await closeTicket(args, extra.sessionId);
      return { content: [{ type: "text", text: `Closed ${args.id}` }] };
    },
  );

  server.registerResource(
    "ticket",
    "ticket://{id}",
    {
      title: "Ticket",
      description: "Read a ticket by ID",
      mimeType: "application/json",
    },
    async (uri, { id }) => {
      const ticket = await readTicketResource(String(id));
      return {
        contents: [
          {
            uri: uri.href,
            mimeType: "application/json",
            text: JSON.stringify(ticket),
          },
        ],
      };
    },
  );

  return server;
}

Beachten Sie extra.sessionId – das SDK exponiert einen Per-Request-Kontext, mit dem Sie die authentifizierte Nutzer-Identität in Ihre Tool-Handler transportieren können.

Authentifizierung: OAuth, keine API-Keys

Die MCP-Spec empfiehlt OAuth 2.1 für HTTP-Transports. In der Praxis haben Sie drei Optionen:

  1. OAuth 2.1 mit PKCE – korrekt, flexibel, meiste Arbeit.
  2. OIDC aus Ihrem bestehenden IdP – gut, wenn Ihre Clients bereits Nutzer-Identität haben.
  3. Kurzlebige Bearer Tokens, ausgestellt von einer vertrauenswürdigen Control Plane – am einfachsten für Machine-to-Machine.

Für ein Tool, das authentifizierte Nutzer-Aktionen repräsentiert (Tickets im Auftrag einer Person anlegen), ist OAuth mit Nutzer-Consent das richtige Modell. Für ein Tool, das auf eine einzelne Service-Identität gescoped ist, reichen signierte Bearer Tokens.

Eine minimale JWT-Verifikations-Middleware mit Hono:

// src/auth.ts
import { jwtVerify, createRemoteJWKSet } from "jose";
import type { Context, Next } from "hono";

const JWKS = createRemoteJWKSet(new URL(process.env.OIDC_JWKS_URL!));

export async function requireAuth(c: Context, next: Next) {
  const authHeader = c.req.header("authorization");
  if (!authHeader?.startsWith("Bearer ")) {
    return c.json({ error: "missing bearer token" }, 401);
  }

  try {
    const { payload } = await jwtVerify(authHeader.slice(7), JWKS, {
      issuer: process.env.OIDC_ISSUER,
      audience: "mcp-ticketing",
    });

    c.set("principal", {
      sub: payload.sub as string,
      scopes: (payload.scope as string | undefined)?.split(" ") ?? [],
    });
  } catch {
    return c.json({ error: "invalid token" }, 401);
  }

  await next();
}

export function requireScope(scope: string) {
  return async (c: Context, next: Next) => {
    const principal = c.get("principal") as { scopes: string[] } | undefined;
    if (!principal?.scopes.includes(scope)) {
      return c.json({ error: `missing scope: ${scope}` }, 403);
    }
    await next();
  };
}

Im Tool-Handler prüfen Sie den Scope, bevor Sie die Aktion ausführen:

// src/tools/create_ticket.ts
import { principalForSession } from "../session.js";

export async function createTicket(
  args: { title: string; body: string; priority: string },
  sessionId: string,
) {
  const principal = principalForSession(sessionId);
  if (!principal.scopes.includes("tickets:write")) {
    throw new Error("forbidden: tickets:write required");
  }
  // ...call the real backend
  return { id: "T-12345" };
}

Rate Limiting pro Client

Sie wollen nicht, dass ein einzelner Client Ihr Backend leerzieht. Rate Limits pro Client leben in Redis.

// src/ratelimit.ts
import { RateLimiterRedis } from "rate-limiter-flexible";
import Redis from "ioredis";

const redis = new Redis(process.env.REDIS_URL!);

const limiters = {
  search: new RateLimiterRedis({
    storeClient: redis,
    keyPrefix: "mcp:search",
    points: 120,
    duration: 60,
  }),
  write: new RateLimiterRedis({
    storeClient: redis,
    keyPrefix: "mcp:write",
    points: 20,
    duration: 60,
  }),
};

export async function enforce(kind: "search" | "write", clientId: string) {
  await limiters[kind].consume(clientId);
}

Anwenden am Anfang jedes Tool-Handlers. 429er werden vom SDK an den MCP-Client zurückpropagiert.

Observability mit OpenTelemetry

Jeder Tool-Call sollte einen Trace erzeugen. Das MCP-SDK instrumentiert nicht von selbst – Sie fügen Spans in Ihren Handlern hinzu.

// src/telemetry.ts
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
import { trace, SpanStatusCode } from "@opentelemetry/api";

const sdk = new NodeSDK({
  resource: resourceFromAttributes({
    [ATTR_SERVICE_NAME]: "mcp-ticketing",
  }),
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
  }),
});

sdk.start();

const tracer = trace.getTracer("mcp-ticketing");

export async function withSpan<T>(
  name: string,
  attrs: Record<string, string | number>,
  fn: () => Promise<T>,
): Promise<T> {
  return tracer.startActiveSpan(name, { attributes: attrs }, async (span) => {
    try {
      const result = await fn();
      span.setStatus({ code: SpanStatusCode.OK });
      return result;
    } catch (err) {
      span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
      span.recordException(err as Error);
      throw err;
    } finally {
      span.end();
    }
  });
}

Wickeln Sie jeden Tool-Call ein:

async (args, extra) => {
  return withSpan(
    "tool.search_tickets",
    { "mcp.client": extra.sessionId, "query.len": args.query.length },
    async () => {
      const results = await searchTickets(args, extra.sessionId);
      return {
        content: [{ type: "text", text: JSON.stringify(results) }],
      };
    },
  );
},

Docker und lokale Entwicklung

FROM node:20-alpine AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build

FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./package.json
USER node
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD node dist/healthcheck.js || exit 1
CMD ["node", "dist/index.js"]

Und eine Compose-Datei mit Redis und einem Fake-OIDC-Issuer für lokale Entwicklung:

services:
  mcp-ticketing:
    build: .
    ports:
      - "8080:8080"
    environment:
      OIDC_ISSUER: http://oidc-mock:4444/
      OIDC_JWKS_URL: http://oidc-mock:4444/.well-known/jwks.json
      REDIS_URL: redis://redis:6379
      OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
    depends_on: [redis, oidc-mock, otel-collector]

  redis:
    image: redis:7-alpine

  oidc-mock:
    image: ghcr.io/navikt/mock-oauth2-server:2.1.10
    ports:
      - "4444:4444"

  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.112.0
    command: ["--config=/etc/otel-collector-config.yaml"]
    volumes:
      - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml

Kubernetes-Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: mcp-ticketing
  namespace: ai-platform
spec:
  replicas: 3
  selector:
    matchLabels: { app: mcp-ticketing }
  template:
    metadata:
      labels: { app: mcp-ticketing }
    spec:
      containers:
        - name: server
          image: ghcr.io/example/mcp-ticketing:1.2.0
          ports:
            - containerPort: 8080
          env:
            - name: OIDC_ISSUER
              value: https://auth.example.com/
            - name: OIDC_JWKS_URL
              value: https://auth.example.com/.well-known/jwks.json
            - name: REDIS_URL
              valueFrom: { secretKeyRef: { name: mcp-redis, key: url } }
            - name: OTEL_EXPORTER_OTLP_ENDPOINT
              value: http://otel-collector.observability:4318
          readinessProbe:
            httpGet: { path: /ready, port: 8080 }
            periodSeconds: 5
          livenessProbe:
            httpGet: { path: /live, port: 8080 }
            periodSeconds: 15
          resources:
            requests: { cpu: 100m, memory: 128Mi }
            limits: { memory: 256Mi }
---
apiVersion: v1
kind: Service
metadata:
  name: mcp-ticketing
  namespace: ai-platform
spec:
  selector: { app: mcp-ticketing }
  ports:
    - port: 80
      targetPort: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: mcp-ticketing
  namespace: ai-platform
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt
spec:
  tls:
    - hosts: [mcp-ticketing.ai.example.com]
      secretName: mcp-ticketing-tls
  rules:
    - host: mcp-ticketing.ai.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: mcp-ticketing
                port: { number: 80 }

Was in Produktion zu beobachten ist

  • p95-Tool-Call-Latenz. Ihr Backend ist wahrscheinlich der Engpass, nicht die MCP-Schicht.
  • Tool-Call-Error-Rate pro Tool. Ein Spike in einem Tool bedeutet meist, dass ein neues Modell es falsch aufruft.
  • Rate-Limit-Ablehnungen pro Client. Hinweis auf Missbrauch oder einen Agent in einer Retry-Schleife.
  • Auth-Fehler. Abgelaufene Tokens, fehlende Scopes – zeigt, dass Clients Orientierung brauchen.
  • Schema-Validierungsfehler. LLMs erfinden manchmal Argumente. Tracken Sie die Häufigkeit.

Häufige Fehler

  • Zu viel exponieren. Jedes Tool vergrößert die Angriffsfläche. Starten Sie read-only.
  • Keine Autorisierung in Handlern. Transport-Auth reicht nicht – Scope pro Aktion prüfen.
  • Riesige Blobs zurückgeben. Modelle haben endlichen Kontext. Paginieren, zusammenfassen, filtern.
  • Idempotenz vergessen. Write-Tools können beim Retry doppelt aufgerufen werden. Idempotency Keys nutzen.
  • Kein Audit-Log. Jede Write-Aktion sollte mit Principal, Argumenten und Ausgang geloggt werden.

Nächste Schritte

MCP ist die Richtung, in die Tool-Integration mit LLMs geht, und eine Woche zu investieren, um Ihre internen APIs über einen produktionsreifen MCP-Server zu exponieren, zahlt sich vielfach aus, wenn Agents – Ihre und die Ihrer Kunden – sie sicher nutzen können. Beginnen Sie mit einem einzelnen Read-only-Tool, bekommen Sie Auth und Observability richtig hin und weiten Sie von dort aus. Wenn Sie Unterstützung beim Entwurf von MCP-Servern für Ihre Plattform oder beim Audit eines bestehenden wünschen, nehmen Sie Kontakt auf.

abgelegt unter
mcpmodel-context-protocolaiinfrastructure
mit uns arbeiten

Soll unser Team Ihrer Infrastruktur helfen?

talk to an engineerFree 30-min discovery callBook
close