From 761aa7530cf9dd8d190e8aa9ee202b4e56b875ad Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Thu, 11 Jun 2026 03:56:33 +0200 Subject: [PATCH] Harden server stability and fix MCP protocol hygiene MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the WordPress server intermittently showing as disconnected in Claude Desktop, and two protocol-level bugs found while auditing it: - capabilities.tools was a map of full tool objects, which serialized every tool's zod schema internals into the initialize response (~65KB per connection). It's now the spec-correct capability flags object; tool advertisement happens through server.tool() registration. initialize drops to ~164 bytes. - server.tool() was never passed the tool description, so tools/list served all 43 tools with no descriptions at all — clients picked tools on names alone. Descriptions are now registered. - unhandledRejection no longer kills the process: a stray background rejection surfaced to clients as an unexplained disconnect. It now logs loudly (with stack) and the server continues. uncaughtException still exits but logs the full stack first. - The process logs an exit trace ([SHUTDOWN] code N) so future "transport closed unexpectedly" reports can be correlated with a cause on the server side. - cli.ts wrote its startup banner to stdout, which belongs to the JSON-RPC channel of the inherited-stdio child server; moved to stderr. - Add CI workflow (job "test": npm ci, tsc --noEmit, build) matching the org template's expected status check. Verified by stdio handshake probe: initialize 164 bytes, zero zod internals, 43/43 tools with descriptions, SIGTERM leaves shutdown traces. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 20 +++++++++++++++++ src/cli.ts | 4 +++- src/server.ts | 47 +++++++++++++++++++--------------------- 3 files changed, 45 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..78b9a27 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + name: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npx tsc --noEmit + - run: npm run build diff --git a/src/cli.ts b/src/cli.ts index a9dd3f3..201f274 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -23,7 +23,9 @@ function checkEnvironmentVariables() { // Main function to run the MCP server async function main() { - console.log('Starting WordPress MCP Server...'); + // stderr only: the spawned server inherits this process's stdio, so stdout + // belongs to the MCP JSON-RPC channel and must stay clean. + console.error('Starting WordPress MCP Server...'); // Check for .env file in current directory const envPath = path.join(process.cwd(), '.env'); diff --git a/src/server.ts b/src/server.ts index 27229e0..0530560 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,16 +10,17 @@ import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; -// Create MCP server instance +// Create MCP server instance. +// capabilities.tools must be the capability flags object, NOT a map of tool +// definitions — server.tool() registration below handles tool advertisement. +// Stuffing tool objects here serialized their zod schemas into every +// initialize response (~65KB of internals per connection). const server = new McpServer({ name: "wordpress", version: "0.0.1" }, { capabilities: { - tools: allTools.reduce((acc, tool) => { - acc[tool.name] = tool; - return acc; - }, {} as Record) + tools: {} } }); @@ -40,22 +41,10 @@ for (const tool of allTools) { }; }; - // console.log(`Registering tool: ${tool.name}`); - // console.log(`Input schema: ${JSON.stringify(tool.inputSchema)}`); - - // const zodSchema = z.any().optional(); - // const jsonSchema = zodToJsonSchema(z.object(tool.inputSchema.properties as z.ZodRawShape)); - - // const schema = z.object(tool.inputSchema as z.ZodRawShape).catchall(z.unknown()); - - // The inputSchema is already in JSON Schema format with properties - // server.tool(tool.name, tool.inputSchema.shape, wrappedHandler); - // const zodSchema = z.any().optional(); - // const jsonSchema = zodToJsonSchema(z.object(tool.inputSchema.properties as z.ZodRawShape)); - // const parsedSchema = z.any().optional().parse(jsonSchema); - - const zodSchema = z.object(tool.inputSchema.properties as z.ZodRawShape); - server.tool(tool.name, zodSchema.shape, wrappedHandler) + // Tool modules define inputSchema.properties as zod shapes (see CLAUDE.md); + // passing raw JSON Schema here collapses the published schema to {}. + const zodSchema = z.object(tool.inputSchema.properties as z.ZodRawShape); + server.tool(tool.name, tool.description ?? '', zodSchema.shape, wrappedHandler) } @@ -100,12 +89,20 @@ process.on('SIGINT', () => { process.exit(0); }); process.on('uncaughtException', (error) => { - process.stderr.write(`[FATAL] Uncaught exception: ${error}\n`); + process.stderr.write(`[FATAL] Uncaught exception: ${error instanceof Error ? error.stack || error.message : error}\n`); process.exit(1); }); -process.on('unhandledRejection', (error) => { - process.stderr.write(`[FATAL] Unhandled rejection: ${error}\n`); - process.exit(1); +// Do NOT exit on unhandled rejections: a stray promise rejection (e.g. a +// background axios call outside a handler's try/catch) is not worth killing +// the whole server over — that surfaces in clients as the server abruptly +// disconnecting. Log it loudly instead. +process.on('unhandledRejection', (reason) => { + process.stderr.write(`[ERROR] Unhandled rejection (server continuing): ${reason instanceof Error ? reason.stack || reason.message : reason}\n`); +}); +// Always leave a trace of WHY the process is exiting, so client-side +// "transport closed unexpectedly" messages can be correlated with a cause. +process.on('exit', (code) => { + process.stderr.write(`[SHUTDOWN] Process exiting with code ${code}\n`); }); main().catch((error) => {