Analysis
What Breaks When MCP Servers Move from stdio to HTTP

Moving an MCP server from local stdio to shared HTTP introduces identity, session, context-budget, cleanup, and health-reporting problems. A tool call working locally proves very little about the shared service.
Everything worked until another user arrived
Registering an MCP tool is ordinary application code. It receives arguments, calls an API, and returns a result. The harder questions arrived later: Which identity made the request? What survives a process restart? How much data can one tool put into the model’s context? What does “healthy” prove? What happens when ten clients share the same upstream token?
The answers differed across BookStack, GitLab, and MediaWiki, but the pattern did not. The integration failed at the point where one system handed responsibility to another.
| Server | What looked healthy | What was actually broken | Boundary I changed |
|---|---|---|---|
| BookStack MCP | Search returned results | Per-result lookups stampeded one shared API token until the container exhausted memory | Process-wide cache and concurrency limit |
| GitLab MCP | The process and health endpoint stayed up | A close callback recursed; later, stale client session IDs could not recover after deployment | Idempotent teardown and explicit stale-session response |
| MediaWiki MCP | Public search and page reads still worked | The authenticated cookie session had expired, so protected writes had lost their identity | Authentication asserted by MediaWiki on the write |
BookStack: one token meant one budget
BookStack search results needed book slugs. My enhancement path fetched those details item by item, and several uncached results could launch many API requests at once. The shared service token allowed 180 requests per minute. BookStack began returning 429 responses with retry delays. Pending work accumulated until Node hit its heap limit and exited with code 134.
The first repair looked correct but still lived at the wrong scope. I replaced the per-result requests with one bulk listing and gave concurrent callers one in-flight promise. The cache, however, belonged to each BookStack client object. The MCP server creates a client per session, so new sessions repeated the warm-up and spent the same shared rate-limit budget.
The final cache lives at process scope, keyed by BookStack base URL. A process-wide semaphore limits the remaining upstream work. The scope now matches the resource: one token shared by the process gets one shared cache and one shared concurrency ceiling.
The same project changed my view of response size. Tool descriptions and results consume the same context window as the actual question. I measured common responses against a live BookStack and removed redundant fields instead of making the model sift through them. The numbers and the abandoned-session fixes are in Why my BookStack MCP server starts read-only.
GitLab: a session closed itself until the stack ran out
GitLab MCP had a close helper that looked sensible: find the session, close its server, then delete it from the map. Closing the server closed the transport. The transport invoked its onclose callback, which called the same helper while the session was still in the map. The call entered itself until Node reported Maximum call stack size exceeded.
I fixed the order, not the recursion limit. The session is deleted from the registry before cleanup calls code that can call back. Any re-entrant close sees that the work is already complete and returns.
const session = sessions.get(sessionId);
if (!session) return;
sessions.delete(sessionId);
void session.server.close();
A deployment exposed a second lifecycle problem. The new process had no memory of sessions created by the old one, while clients still carried their previous session IDs. Returning a generic error did not tell those clients how to recover. The correct response for the MCP revision in use was HTTP 404, which tells a compliant client to initialize again without the stale ID.
The 2026 protocol later removed protocol-level sessions in favor of stateless requests. Compatibility code still needs the old path, but I no longer treat an invisible in-memory transport session as harmless bookkeeping. The regression tests and specification timeline are in Two session bugs that broke my GitLab MCP server.
MediaWiki: an anonymous success hid an authentication failure
MediaWiki MCP logged in with a bot password and kept the resulting cookies. My client set a loggedIn flag after startup and treated that boolean as truth for the life of the process. MediaWiki’s server-side session did not agree. It could expire while the local flag remained true.
Public reads made the failure deceptive. Search and page retrieval continued anonymously, so ordinary smoke tests passed. Protected writes had already lost the identity they needed.
I moved the check to the system that owns the truth. Write requests use MediaWiki’s assert=user. If the cookie no longer represents a registered account, MediaWiki rejects the request. The client can perform one bounded login repair, obtain a new CSRF token, retry once, and then surface the error.
The project also had an unrelated container lie: a published port pointed at a process bound only to loopback. The container could run and expose port 8009 while callers outside the container reached nothing. That is why the MediaWiki case study treats authentication, listener reachability, process health, and bounded sessions as four different checks.
Read-only is the starting architecture
A write tool should not appear merely because the upstream API has a matching endpoint. BookStack MCP exposes read and write capabilities, but write access is off by default. In the multi-user path, identity selects a restricted tool set and a read-only upstream credential. If tool registration is wrong later, the credential still cannot perform the write.
I apply the same rule outside documentation systems. A local model in Home Assistant can interpret a request, but I expose a narrow set of devices and prefer tested scenes over improvised multi-device state. The assistant’s fluency does not expand the authority of the tool behind it.
My health checks now answer smaller questions
I do not want one green badge labeled “MCP healthy.” I want separate signals:
- Process: Is the program running?
- Listener: Can a caller reach the service through the same network path a real client uses?
- Read: Can the configured identity retrieve an expected resource?
- Write identity: Does the upstream system still recognize the authenticated session or token?
- Capacity: Are sessions, queues, memory, and upstream concurrency bounded?
- Recovery: Can a client recover after the process forgets its previous state?
A successful public read proves a public read. A health request made from inside a container proves that one in-container path. A running process says nothing about an unbounded map of abandoned sessions. The label should never claim more than the probe tested.
The checklist I reuse
- Start with the smallest read-only tool set that answers a real question.
- Give each deployment its own scoped upstream credential; keep primary user passwords out of the integration.
- Bind network listeners narrowly and test them from the caller’s side of the boundary.
- Make cleanup idempotent before calling code that can call back.
- Bound session age, session count, request concurrency, retry count, and response size separately.
- Test stale credentials, stale client state, dropped connections, rate limits, and restarts—not only clean startup.
- Let the upstream system confirm identity and permissions at the operation that needs them.
I still build MCP servers because putting a narrow interface in front of an existing system can remove a great deal of friction. I no longer judge one by the number of tools it registers. The question is whether the integration stays honest when the upstream API, client, network, credential, or process stops cooperating.
Disclosure: I maintain the three MCP servers discussed here.