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.
- Server. Ein Prozess, der Capabilities exponiert.
- Client. Eine LLM-Anwendung, die sich mit Servern verbindet.
- Tools. Aufrufbare Funktionen mit typisierten Argumenten, die das Modell invoken kann.
- Resources. Read-only-Datenquellen (Dateien, Records, Seiten), die das Modell in den Kontext aufnehmen kann.
- 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:
- OAuth 2.1 mit PKCE – korrekt, flexibel, meiste Arbeit.
- OIDC aus Ihrem bestehenden IdP – gut, wenn Ihre Clients bereits Nutzer-Identität haben.
- 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.