Warum dein MCP-Server einen Smoketest braucht (und wie du ihn in TypeScript baust)
Ich habe letzte Woche zwei Stunden damit verbracht, herauszufinden, warum mein MCP-Server plötzlich nicht mehr mit Claude Desktop funktionierte. Der Server startete ohne Fehler, die Logs sahen sauber aus – aber die Tools tauchten einfach nicht auf.
Das Problem: Eine Kleinigkeit in der Manifest-Definition hatte sich geändert, und ohne automatischen Test hatte ich null Feedback darüber, ob mein Server überhaupt noch das tut, was er soll.
Seitdem baue ich für jeden MCP-Server einen simplen Smoketest. Nicht für perfekte Test-Coverage, sondern um die offensichtlichsten Fehler sofort zu sehen. In diesem Artikel zeige ich dir, wie du mit TypeScript einen lauffähigen Smoketest-Server für deine MCP-Integration baust – inklusive der Stolperfallen, die ich dabei selbst erlebt habe.
Am Ende hast du Code, den du direkt in deine Projekte übernehmen kannst, plus ein Verständnis dafür, was ein Smoketest bei MCP-Servern eigentlich prüfen muss. Kein perfektes Test-Setup, sondern ein pragmatischer Sicherheitsgurt für deine tägliche Arbeit.
Was ein Smoketest bei MCP-Servern eigentlich prüfen muss
Ein Smoketest ist nicht dazu da, jede Edge-Case zu testen. Er prüft: Funktioniert das Grundgerüst? Kann ich überhaupt mit dem Server kommunizieren?
Bei MCP-Servern bedeutet das konkret: Startet der Server? Antwortet er auf initialize? Gibt er seine Tools korrekt zurück? Diese drei Checks fangen die meisten Fehler ab, die du im Alltag machst.
Ich habe gemerkt, dass gerade die Tool-Definition häufig kaputt geht. Ein fehlender Parameter, ein falscher Typ in der JSON-Schema-Definition – und Claude kann das Tool nicht mehr aufrufen. Der Smoketest muss genau das abfangen.
[CHRIS-NOTIZ: Hier vielleicht Screenshot von Claude Desktop mit fehlendem Tool vs. funktionierendem Tool – zeigt visuell, warum der Test wichtig ist]
Setup: Ein minimaler MCP-Server als Testbasis
Bevor wir den Test schreiben, brauchen wir einen Server, den wir testen können. Hier ist ein schlanker MCP-Server in TypeScript, der ein einziges Tool exponiert:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
ListToolsRequestSchema,
CallToolRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
const server = new Server(
{
name: "example-server",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "get_status",
description: "Returns the current server status",
inputSchema: {
type: "object",
properties: {
verbose: {
type: "boolean",
description: "Include detailed information",
},
},
},
},
],
};
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === "get_status") {
return {
content: [
{
type: "text",
text: JSON.stringify({ status: "ok", timestamp: Date.now() }),
},
],
};
}
throw new Error(`Unknown tool: ${request.params.name}`);
});
const transport = new StdioServerTransport();
await server.connect(transport);
Das ist der Server, den wir gleich testen. Er ist bewusst minimal gehalten – in echten Projekten hast du natürlich mehr Tools und komplexere Logik.
[CHRIS-CODE: Hier noch die package.json Dependencies zeigen – @modelcontextprotocol/sdk Version, TypeScript-Config etc.]
Der Smoketest: Server-Start und Initialize-Handshake
Der erste Teil des Smoketests prüft, ob der Server überhaupt startet und den Initialize-Handshake korrekt abwickelt. Das ist der Teil, der bei mir am häufigsten fehlschlägt, wenn ich am Server-Setup rumschraube.
Hier ist der Code für den Test-Runner:
import { spawn } from "child_process";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
async function runSmoketest() {
console.log("Starting MCP server smoketest...");
const serverProcess = spawn("node", ["dist/index.js"], {
stdio: ["pipe", "pipe", "pipe"],
});
const transport = new StdioClientTransport({
reader: serverProcess.stdout,
writer: serverProcess.stdin,
});
const client = new Client(
{
name: "smoketest-client",
version: "1.0.0",
},
{
capabilities: {},
}
);
try {
await client.connect(transport);
console.log("✓ Server started and connected");
const serverInfo = await client.getServerVersion();
console.log(`✓ Server info: ${serverInfo.name} v${serverInfo.version}`);
return true;
} catch (error) {
console.error("✗ Smoketest failed:", error);
return false;
} finally {
serverProcess.kill();
}
}
runSmoketest().then((success) => {
process.exit(success ? 0 : 1);
});
Der Test spawned den kompilierten Server als Child-Process und baut eine Client-Verbindung auf. Wenn das klappt, wissen wir: Der Server startet und spricht das MCP-Protokoll.
Ich habe hier bewusst dist/index.js als Pfad gewählt – das setzt voraus, dass du deinen TypeScript-Code vor dem Test kompilierst. Bei mir läuft das über ein npm-Script: npm run build && npm run smoketest.
Tool-Listing und Call-Validierung
Der zweite Teil des Tests prüft, ob die Tools korrekt exponiert werden und ob wir sie aufrufen können. Das ist der Teil, der kaputte Schema-Definitionen erwischt.
// Fortsetzung in der runSmoketest-Funktion nach dem connect-Block:
const toolsResponse = await client.listTools();
console.log(`✓ Server exposes ${toolsResponse.tools.length} tool(s)`);
const expectedTool = toolsResponse.tools.find(
(t) => t.name === "get_status"
);
if (!expectedTool) {
throw new Error("Expected tool 'get_status' not found");
}
console.log(`✓ Tool 'get_status' found in listing`);
const callResult = await client.callTool({
name: "get_status",
arguments: { verbose: false },
});
if (callResult.content.length === 0) {
throw new Error("Tool returned empty content");
}
console.log(`✓ Tool call succeeded`);
const resultText = callResult.content[0].text;
const parsed = JSON.parse(resultText);
if (parsed.status !== "ok") {
throw new Error(`Unexpected status: ${parsed.status}`);
}
console.log(`✓ Tool returned expected status`);
Dieser Code macht vier Dinge: Er listet alle Tools auf, prüft, ob unser erwartetes Tool dabei ist, ruft es auf und validiert die Antwort.
Bei mir hat das anfangs nicht funktioniert, weil ich vergessen hatte, dass callTool das Tool-Argument als arguments-Property erwartet – nicht als direktes Objekt. Die TypeScript-Types haben mich darauf hingewiesen, aber nur, weil ich sie auch wirklich verwendet habe.
[CHRIS-NOTIZ: Hier könnte ein Beispiel kommen, wie der Test bei kaputtem Schema fehlschlägt – z.B. fehlender required-Parameter]
Integration in deine Build-Pipeline
Der Smoketest bringt dir nichts, wenn du ihn nicht regelmäßig ausführst. Bei mir läuft er in drei Szenarien: Lokal vor jedem Commit, in GitHub Actions bei jedem Push und als Pre-Push-Hook.
Hier ist die GitHub Actions Workflow-Datei:
name: Smoketest
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
smoketest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: "18"
- run: npm ci
- run: npm run build
- run: npm run smoketest
Das ist bewusst simpel gehalten. Wenn der Smoketest fehlschlägt, bricht der Build ab – und du merkst sofort, dass was kaputt ist.
Für den Pre-Push-Hook nutze ich Husky. Das Setup ist ein einziger Befehl:
npx husky-init && npm install
echo "npm run build && npm run smoketest" > .husky/pre-push
Jetzt läuft der Test automatisch, bevor du Code hochschiebst. Das hat mich schon mehrfach vor peinlichen “Oops, kaputt”-Commits bewahrt.
Erweiterte Checks: Claude API Integration testen
Wenn du deinen MCP-Server direkt mit der Claude API nutzt (nicht nur über Claude Desktop), solltest du das auch im Smoketest abbilden. Das ist der Teil, den ich am Anfang unterschätzt habe.
Die Claude API hat ein eigenes MCP-Client-Pattern, und nicht alles, was lokal funktioniert, funktioniert auch über die API. Besonders Tool-Responses mit großen Payloads können Probleme machen.
import Anthropic from "@anthropic-ai/sdk";
async function testClaudeIntegration() {
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
// Server muss als HTTP-Endpoint verfügbar sein, nicht stdio
const mcpServerUrl = "http://localhost:3000/mcp";
const message = await anthropic.messages.create({
model: "claude-3-5-sonnet-20241022",
max_tokens: 1024,
messages: [
{
role: "user",
content: "Use the get_status tool to check server status",
},
],
tools: [
{
name: "get_status",
description: "Returns the current server status",
input_schema: {
type: "object",
properties: {
verbose: { type: "boolean" },
},
},
},
],
});
const toolUse = message.content.find((block) => block.type === "tool_use");
if (!toolUse) {
throw new Error("Claude did not use the tool");
}
console.log("✓ Claude API integration works");
}
[CHRIS-CODE: Hier fehlt noch der Teil, wie du den MCP-Server als HTTP-Endpoint exponierst – das ist ein eigenes Thema, vielleicht separater Artikel?]
Ich habe gemerkt, dass dieser Test nur sinnvoll ist, wenn du wirklich mit der Claude API arbeitest. Für reine Desktop-Integration reicht der stdio-basierte Test von oben.
FAQ
Muss ich für jeden MCP-Server einen eigenen Smoketest schreiben?
Ja und nein. Die Struktur bleibt gleich, aber die Tool-Namen und erwarteten Responses musst du anpassen. Ich habe mittlerweile eine Template-Datei, die ich kopiere und anpasse – spart Zeit.
Wie lange darf ein Smoketest dauern?
Bei mir liegt die Grenze bei 10 Sekunden. Alles darüber ist zu langsam für den Pre-Commit-Hook. Wenn dein Test länger braucht, prüfst du wahrscheinlich zu viel – oder dein Server hat Performance-Probleme.
Sollte ich auch die einzelnen Tool-Funktionen Unit-testen?
Ja, aber das ist kein Ersatz für den Smoketest. Unit-Tests prüfen Logik, der Smoketest prüft Integration. Beides ist wichtig, aber der Smoketest erwischt die Fehler, die du mit Unit-Tests nicht siehst.
Was mache ich, wenn der Smoketest in CI fehlschlägt, lokal aber funktioniert?
Meistens sind das Environment-Probleme: Fehlende Dependencies, falsche Node-Version oder unterschiedliche Pfade. Ich checke dann zuerst die package.json und die Node-Version im Workflow.
Kann ich den Smoketest auch für bestehende MCP-Server nachrüsten?
Absolut. Ich habe das für drei ältere Projekte gemacht und dabei jeweils 2-3 Bugs gefunden, die ich vorher nicht bemerkt hatte. Der Aufwand ist minimal, der Nutzen groß.
Brauche ich für den Test die Claude API Keys?
Nur wenn du den erweiterten Claude-API-Test nutzt. Der grundlegende Smoketest kommt ohne API-Keys aus – er testet nur die Server-zu-Client-Kommunikation über stdio.
Fazit: Ein Sicherheitsnetz für 30 Minuten Arbeit
Der Smoketest ist kein perfektes Test-Setup. Er ersetzt weder Unit-Tests noch manuelle Checks. Aber er gibt dir ein Sicherheitsnetz für die offensichtlichsten Fehler – und das mit minimalem Aufwand.
Ich habe den Code aus diesem Artikel in einem GitHub-Repo gesammelt, inklusive der kompletten Beispiel-Server-Implementation. Du findest den Link im kiforge-Newsletter, zusammen mit Updates zu neuen MCP-Patterns und praktischen Coding-Workflows.
Wenn du tiefer in MCP-Server-Entwicklung einsteigen willst, schau dir unseren Artikel über [CHRIS-NOTIZ: Link zu weiterführendem MCP-Artikel einfügen] an. Dort zeige ich, wie du komplexere Tool-Chains baust und mit State umgehst.