At 21:30:27 UTC on 2026-04-17 I committed what I thought was the fix, and v0.1.7 was on npm thirty seconds later. The SSE stream still didn't move. No error, no stack trace, just a client waiting on frames that never arrived. Finding the actual problem took another 49 minutes and five more commits, and it turned out to be a regex that assumed the wrong line ending.
To be precise about that number: the hang had been in the code since 13:25 that afternoon and on npm since 21:18. The 49 minutes is how long the hunt took once I was actually hunting.
What mcpier is
mcpier ("Pier," tagline "MCPs, simpler") is a self-hosted control plane for MCP servers. If you run Model Context Protocol servers on more than one machine, you end up with your OpenAI, Gemini, and GitHub keys in plaintext in ~/.claude.json on every box, and rotating one means editing N files by hand. Pier keeps one YAML manifest on a homelab server, encrypts secrets at rest with AES-256-GCM, and hands clients a URL instead of a key. The CLI is npm i -g mcpier (binary pier); the server runs from ghcr.io/spranab/mcpier on port 8420. MIT, one maintainer, me. Code is at github.com/spranab/mcpier, the CLI at npmjs.com/package/mcpier.
The night before
The first session started at 20:34:48 UTC on 2026-04-16 with v0.1.1 and ran until 01:50 the next morning. v0.1.2 went out at 20:49. v0.1.3, at 20:59:45, fixed a bug where every unmatched route, including a plain 404 on /api/whatever, came back as:
{"statusCode":500,"error":"Internal Server Error","message":"reply.sendFile is not a function"} I'd registered fastifyStatic with decorateReply: false, so Fastify never attached sendFile to the reply, and then my 404 handler called reply.sendFile("index.html") anyway. I found it by running the published CLI against the public ghcr image.
After midnight, the commit message for the 01:12:13 fix calls it the "first production brainstorm-mcp call." brainstorm-mcp declares four optional API-key secrets (required: false), the user had only set a subset, and Pier answered with 500 {"error":"missing secrets: gemini_key, anthropic_key"}. The proxy's secret check didn't carry the required/optional metadata at all. Now it injects an empty string for anything missing and lets the MCP subprocess decide what it needs, which is what the local-install path already did.
v0.1.4 was a real feature, not just a fix. Commit 89fbad505 at 01:21:06 added an activation resolver: workspace config beats user profile, which beats a formula's advisory auto-activate triggers, so Pier stops dumping every installed MCP's tools into the agent's context. The commit message frames it as fixing "the token-budget explosion from a dynamic catalog," and it came with 11 new tests, 27 passing total. It also says the design was "validated by a real GPT-5.4 + DeepSeek brainstorm through this very Pier," so Pier helped design part of itself.
The publish didn't go through. tsup had bundled yaml straight into the CLI, yaml does require('process') internally, and the binary crashed on startup with Dynamic require of "process" is not supported, pointing at ../../node_modules/yaml/dist/compose/composer.js. CI's tarball check (install the packed tarball fresh, run the binary) caught it before anything reached npm. Commit e2b7bf3d0 at 01:23:20 made yaml a real dependency, I moved the v0.1.4 tag onto that commit, and 0.1.4 published at 01:24:34. Nothing on the npm registry shows the first attempt ever happened.
v0.1.5 (c58220f87, published 01:39:28) came from trying to sync a config into a live Claude Code session. The writer emitted "transport"; Claude Code reads "type". Before: {"transport": "sse", "url": ".../mcp/foo"}. After: {"type": "sse", "url": ".../mcp/foo/sse"}, because the URL also needed a /sse suffix. Separately, spawning a Python MCP server in the k8s pod failed with spawn uvx ENOENT since the runtime image only had Node. Installing uv fixed it and added roughly 40MB I didn't love adding.
The last commit of that session was 63c6d0bba at 01:50:09, a cache pre-warming change. Then nothing for about eleven and a half hours.
The afternoon feature
At 13:25:33, commit f3a1226f2 added the thing that broke. Some upstream MCP servers send an event: endpoint SSE frame carrying an upstream-relative path for the client's follow-up POSTs. Pier forwarded that path verbatim, so the POST went around the proxy instead of through it. I wrote a rewriteSseEndpoint stream transformer to fix the path in flight. It went out as v0.1.6 at 21:18:28, almost eight hours after the commit.
The hunt
My first guess (47147871f, 21:30:27) was framework plumbing. Node's native fetch hands back Response.body as a WHATWG ReadableStream, Fastify's reply.send() doesn't know what to do with one, so maybe the headers just never flushed. I wrapped it in Readable.fromWeb() and published v0.1.7. Same silence.
Second guess, also plumbing (8f366d724, 21:41:21). I kept the fromWeb wrap and added a hijack of reply.raw, Fastify's escape hatch to the socket, piping straight into it. That's the pattern the stdio-SSE path already used, and that path worked. Server-only change, so no version bump, just a new ghcr image.
Then three commits of looking instead of guessing. One of them, ffd7301c7, moved the hijack ahead of writeHead as well as adding logging, so it wasn't pure instrumentation. At 21:59:57 I turned the rewriter off to check whether the raw plumbing worked on its own, and at 22:09:16 I turned it back on with logging on the read side.
At 22:19:19, 8c8fec5d7 had the answer. The upstream I was testing against, my own yantrikdb deploy, sends CRLF. The rewriter assumed LF. [^\n]+ captured the data value up to the \n, which dragged a trailing \r into the capture, and the terminator pattern \n\n never matched the real terminator, \r\n\r\n. A failed match isn't an exception. The transformer just never emitted anything.
# frame shape on the wire from a CRLF upstream event: endpoint\r\ndata: <path>\r\n\r\n # before # after data value: [^\n]+ data value: [^\r\n]+ terminator: \n\n terminator: \r?\n\r?\n The fix also preserves whichever line ending the upstream actually used, and the debug logging came out in the same commit.
Whose name is on which commit
Four commits in this story carry Co-Authored-By: Claude Opus 4.7 (1M context): f3a1226f2 (the rewriter), 47147871f and 8f366d724 (both wrong guesses), and 8c8fec5d7 (the real fix). The plain bugfixes from the night before (88a35376f, e38692c9b, e2b7bf3d0, c58220f87) don't have it. Neither do the three debug commits.
So the AI pair shows up on the feature that broke and on every attempt to fix it, and it's missing from the stretch where I was reading logs. I don't think the lesson is that the model wrote a bad regex. I approved it. My read is that the two co-authored fix attempts were both confident theories about Fastify and web streams, written before anyone had looked at the bytes coming in, and that order of operations is what cost the time. Log the input first, then theorize. I'd hold any pair to that, human or not.
Still on 0.1.7
npm view mcpier version still says 0.1.7. publish.yml only runs on a published GitHub Release, and the workflow comment says it plainly: "Only a GitHub Release publishes to npm. Tags on their own do nothing." The ghcr job in ci.yml runs on every push to main and tags each image twice, latest plus a short git SHA. So the server does get an identifier per commit, just not a number anyone chose on purpose, and the CRLF fix lives in the server image under one of those SHAs.
That's fine by me. The npm package has no proxy code in it at all; its dependencies are commander, kleur, inquirer, and yaml. CLI 0.1.7 isn't half-fixed, it just tells you nothing about the state of the server, and cutting a CLI release for a server bug would have been noise.
What I think was actually sloppy is the testing. The repo had tests before any of this: config.test.mjs, crypto.test.mjs, manifest.test.mjs, smoke-test scripts, and a CI job running npm test plus a Docker smoke test. The activation resolver got 11 new tests the same night. The SSE rewriter got none. I checked the full tree today, 2026-11-05, and nothing covers rewriteSseEndpoint or the CRLF case, then or since. The one piece of code in this whole stretch that fails without saying anything is the one piece with no test. The only thing that proved the fix was a manual run that night against my own deploy. Config parsing has fixtures. A regex sitting between every SSE upstream and every client doesn't.
For scale: as of today mcpier has 1 GitHub star, 0 forks, 0 open issues, and 24 npm downloads in the last 30 days, 17 of them in the past week. The last push was 2026-09-05.
Pranab Sarkar, Independent Researcher


