From 9772f18750898f9c34462cfec39a3784f99b5613 Mon Sep 17 00:00:00 2001 From: corsacca Date: Wed, 3 Jun 2026 14:46:15 +0100 Subject: [PATCH] fix mcp crash loop from node-mock-http mock socket missing destroy() --- server/plugins/00-process-guards.ts | 32 ++++++++++++++++++++++++++ server/plugins/01-patch-mock-socket.ts | 28 ++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 server/plugins/00-process-guards.ts create mode 100644 server/plugins/01-patch-mock-socket.ts diff --git a/server/plugins/00-process-guards.ts b/server/plugins/00-process-guards.ts new file mode 100644 index 00000000..d6a3e2f9 --- /dev/null +++ b/server/plugins/00-process-guards.ts @@ -0,0 +1,32 @@ +// Process-level safety net for the production Bun runtime. +// +// @hono/node-server (pulled in via srvx) runs a request-body "drain" timer: +// when a response is sent without the request body being fully read, a +// setTimeout(forceClose) eventually calls incoming.socket.destroySoon(). +// Under the Bun build that socket is a unenv polyfill whose destroySoon() +// calls this.destroy() — and unenv's getDuplex() loses the non-enumerable +// `destroy` method (Object.assign skips it), so the call throws +// `TypeError: this.destroy is not a function`. The throw originates in a +// detached timer with no try/catch, so it surfaces as an uncaughtException +// and Bun's default is to exit(1) — taking the whole server down and putting +// it into a deploy-restart crash loop. /mcp triggers it on every +// unauthenticated POST (it 401s before reading the body), so an MCP client's +// connect attempts alone keep killing the origin. +// +// This guard keeps the process alive when a detached timer throws. It is a +// containment net, not the cure — the real fix is to stop bundling unenv's +// node stubs under Bun so a native socket (with a working destroy) is used. +// +// Production runtime only: during the build-time prerender pass and in dev we +// want errors to surface (fail the build / show the crash) rather than be +// swallowed. +export default defineNitroPlugin(() => { + if (import.meta.dev || import.meta.prerender) return + + process.on('uncaughtException', (err) => { + console.error('[process-guard] uncaughtException (kept alive):', err) + }) + process.on('unhandledRejection', (reason) => { + console.error('[process-guard] unhandledRejection (kept alive):', reason) + }) +}) diff --git a/server/plugins/01-patch-mock-socket.ts b/server/plugins/01-patch-mock-socket.ts new file mode 100644 index 00000000..be0aa1e4 --- /dev/null +++ b/server/plugins/01-patch-mock-socket.ts @@ -0,0 +1,28 @@ +import { IncomingMessage } from 'node-mock-http' + +// Backfill the missing `destroy()` on node-mock-http's mock Socket. +// +// The bun preset serves via Bun.serve and fakes Node's HTTP API with +// node-mock-http. Its mock Socket exposes destroySoon() — which calls +// this.destroy() — but is missing destroy() itself: an +// Object.assign-over-non-enumerable-methods bug drops it from the prototype. +// The MCP SDK bridges requests through @hono/node-server, whose request-body +// drain calls socket.destroySoon() ~500ms after each request, so the missing +// destroy() throws `TypeError: this.destroy is not a function` and — coming +// from a detached timer — takes the whole process down (deploy-restart crash +// loop). 00-process-guards.ts is the backstop; this removes the crash at the +// source so it never fires. +// +// node-mock-http is already at its latest (1.0.4) and the MCP SDK pins +// @hono/node-server to 1.x (whose newest still calls destroySoon() unguarded), +// so there's no upgrade that fixes this — hence the runtime prototype patch. +// Purely additive (only fills in a missing method), so it's safe in every mode. +export default defineNitroPlugin(() => { + const socketProto = Object.getPrototypeOf(new IncomingMessage().socket) + if (socketProto && typeof socketProto.destroy !== 'function') { + socketProto.destroy = function (this: { destroyed: boolean }) { + this.destroyed = true + return this + } + } +})