Guide to manually and automatically testing local MCP STDIO servers using Postman, MCP Inspector, shell piping, and Node-based CI-friendly test patterns
Hey — this lesson walks through practical ways to test a local MCP (Model Context Protocol) server. You’ll find quick manual techniques for one-off checks, interactive UIs for exploring tools, and automated patterns for CI/CD-friendly tests that work well with STDIO MCP servers.
Callouts
Use interactive tools for quick debugging (Postman, MCP Inspector), and automated tests for CI/CD (MCP Tester or language-specific test frameworks).
Why test locally?
Validate tool discovery (tools/list) and invocation (tools/call) before deploying.
Catch protocol/JSON-RPC issues early.
Automate checks to prevent regressions in CI/CD.
Summary of approaches
Method
Best for
Quick example
Manual STDIO via Postman
Fast interactive spot checks of STDIO MCP servers
Use Postman transport type STDIO and run node /full/path/to/server.js
MCP Inspector gives a web UI tailored to MCP servers. It’s excellent for exploring tool metadata, trying inputs, and seeing raw JSON-RPC traffic.Quick start:
Automated tests are essential for CI. The pattern below spawns your server, writes JSON-RPC messages to STDIN, captures STDOUT, and asserts correct behavior.Example test helper (compact):
#!/usr/bin/env nodeimport { spawn } from "child_process";import { strict as assert } from "assert";class MCPTester { constructor(entry = "server.js") { this.entry = entry; this.server = null; this._buffer = ""; } spawnServer() { this.server = spawn("node", [this.entry], { stdio: ["pipe", "pipe", "inherit"] }); // Capture stdout chunks and buffer them for JSON parsing this.server.stdout.on("data", (chunk) => { this._buffer += chunk.toString(); }); // Wait a short time to allow server startup logs; in real tests use a readiness probe return new Promise((resolve) => setTimeout(resolve, 200)); } async sendMessage(message) { if (!this.server) throw new Error("Server not started"); const payload = JSON.stringify(message) + "\n"; // Clear buffer before sending this._buffer = ""; this.server.stdin.write(payload); // Wait for a matching response by ID return new Promise((resolve, reject) => { const cleanup = () => clearInterval(interval); const wrappedResolve = (value) => { clearTimeout(timeout); cleanup(); resolve(value); }; const wrappedReject = (err) => { clearTimeout(timeout); cleanup(); reject(err); }; const timeout = setTimeout(() => wrappedReject(new Error("Response timeout")), 2000); const onData = () => { // Try to find first JSON object in buffer try { // Attempt multiple parses in case startup logs precede JSON const start = this._buffer.indexOf("{"); const end = this._buffer.lastIndexOf("}"); if (start !== -1 && end !== -1 && end > start) { const candidate = this._buffer.slice(start, end + 1); const parsed = JSON.parse(candidate); if (parsed && parsed.id === message.id) { wrappedResolve(parsed); } } } catch (err) { // ignore parse errors until buffer contains full JSON } }; // Poll buffer periodically for responses (simple approach) const interval = setInterval(() => { try { onData(); } catch (e) { // Ignore } }, 50); }); } killServer() { if (this.server) { this.server.kill(); this.server = null; } } async testListTools() { await this.spawnServer(); try { const message = { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }; const response = await this.sendMessage(message); assert(response.jsonrpc === "2.0", "Response should have jsonrpc 2.0"); assert(response.id === 1, "Response should have matching id"); assert(response.result, "Response should have result"); assert(Array.isArray(response.result.tools), "Result should have tools array"); // This test expects exactly one tool (change as appropriate) assert(response.result.tools.length === 1, "Should have exactly one tool"); const tool = response.result.tools[0]; assert(tool.name === "add-integers", "Tool name should be add-integers"); assert(tool.description, "Tool should have description"); assert(tool.inputSchema, "Tool should have inputSchema"); assert(tool.inputSchema.properties.a, "Tool should require parameter 'a'"); assert(tool.inputSchema.properties.b, "Tool should require parameter 'b'"); } finally { this.killServer(); } } async testAddIntegersValid() { await this.spawnServer(); try { const message = { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "add-integers", arguments: { a: 5, b: 3 } } }; const response = await this.sendMessage(message); assert(response.jsonrpc === "2.0", "Response should have jsonrpc 2.0"); assert(response.id === 2, "Response should have matching id"); assert(response.result, "Response should have result"); assert(Array.isArray(response.result.content), "Result should have content array"); assert(response.result.content.length === 1, "Should have one content item"); assert(response.result.content[0].type === "text", "Content should be text type"); assert(response.result.content[0].text === "8", "5 + 3 should equal 8"); } finally { this.killServer(); } }}// Example runner(async () => { const tester = new MCPTester("server.js"); try { await tester.testListTools(); console.log("testListTools PASSED"); await tester.testAddIntegersValid(); console.log("testAddIntegersValid PASSED"); } catch (err) { console.error("Test failed:", err); process.exit(1); }})();
Notes on the example:
sendMessage uses a simple polling approach to extract JSON from a mixed stdout buffer. For production, implement a robust framing strategy (newline-delimited JSON, explicit frames, or length-prefix).
Add a proper readiness probe (e.g., wait for a specific log line or implement a ready JSON-RPC method) rather than fixed sleep timers.
Integrate these tests as part of npm test or your CI job so failures block merges.
Example console output (startup logs followed by the JSON-RPC response):
Basic MCP Server running on stdio{"result":{"tools":[{"name":"add-integers","description":"Add two integers and return the result","inputSchema":{"type":"object","properties":{"a":{"type":"integer","description":"First integer to add"},"b":{"type":"integer","description":"Second integer to add"}},"required":["a","b"]}}]},"jsonrpc":"2.0","id":1}
Use this approach for quick smoke tests or to write simple shell-based checks and automation.
Use Postman or MCP Inspector for interactive debugging and rapid exploration.
Add automated tests (like the MCP Tester example) to CI so regressions are caught early.
If your server is implemented in other languages (Python, Java, etc.), implement equivalent tests using that language’s test frameworks:
Python: pytest, unittest
Java: JUnit
Node: mocha, jest
For reliable automation:
Use a clear STDIO framing protocol (e.g., newline-delimited JSON).
Add explicit readiness checks before sending requests.
Capture and assert on both structured JSON-RPC responses and important logs.
Automated tests are essential. Manual checks are helpful for debugging, but CI/CD tests prevent regressions and give confidence when deploying changes.
If you want, I can adapt the automated test to use a line-delimited JSON framing strategy or convert the tester into a Jest/Mocha test suite ready for CI.