Workbench
Two Session Bugs Broke My GitLab MCP Server. Then MCP Deleted Sessions.

One GitLab MCP session closed and called itself again until Node ran out of stack. The next day, a perfectly healthy redeploy stranded individual clients with session IDs the new process had never seen. Both failures looked small in code. Both could take the connector away from the person using it.
The code is in ttpears/gitlab-mcp. I maintain that project, and these were production failures rather than examples invented for an article.
The first failure looked healthy from the outside
On July 9, 2026, the public OAuth path stopped completing new sessions. The process was still up. Its health check still answered. The useful part of the service was wedged.
The log told a stranger story: thousands of identical session-closed lines arrived in an instant, the remaining-session counter dropped below zero, and Node eventually reported RangeError: Maximum call stack size exceeded.
The close handler did three things in the wrong order:
- Look up the session.
- Close the session’s MCP server.
- Delete the session from the map.
That order seems reasonable until the SDK’s lifecycle is included. Closing the server closes its transport. Closing the transport invokes transport.onclose. That callback was the same handler already running, and the session was still in the map. It found the session, closed the server, and entered itself again.
server.close()
-> transport.close()
-> transport.onclose()
-> server.close()
-> ...
The fix in version 2.2.1 was not a lock or a special recursion guard. It was a change in ownership order:
const session = sessions.get(sessionId);
if (!session) return;
sessions.delete(sessionId); // make teardown true first
void session.server.close();
Any re-entrant close now checks the map, finds nothing, and returns. The teardown is idempotent because the registry says the session is already gone before cleanup calls code that can call back.
The second failure only broke one person at a time
The following day exposed a different lifecycle mistake. HTTP sessions lived in process memory. A deployment replaced the process, so every old session disappeared from the server. A browser or connector could still hold its previous Mcp-Session-Id.
That stale client behaved differently from a new client. A GET or DELETE received a generic 400 response. A POST could be attached to a fresh transport even though it carried an ID from the dead process. The client did not get the one signal it needed to start over, so the connector stayed broken for that user while new sessions worked normally.
The 2025-11-25 MCP transport specification was explicit about this case: after a server terminates a session, requests carrying that ID receive HTTP 404, and the client starts a new session without the stale ID.
I turned the route into four decisions
The old request handler mixed transport construction with session recovery. The fix in version 2.2.2 pulled the decision into a small pure function.
| Request state | Result |
|---|---|
| Known session ID | Reuse that session’s transport |
| Unknown or stale session ID | Return HTTP 404 with JSON-RPC code -32001 |
| No ID, POST initialize request | Create a session |
| No ID, anything else | Return HTTP 400 |
The order matters. An unknown ID is expired even if its POST body happens to look like an initialize request. Otherwise the server can graft stale client state onto a new transport and hide the recovery signal again.
The tests reproduce the failure, not just the happy path
The close regression test gives a fake server a close() method that synchronously calls the close helper again. Before the fix, that shape recursed. After the fix, the first call removes the map entry and the second call is a no-op.
The routing tests cover POST, GET, and DELETE with an unknown ID, including a stale-ID POST that also resembles initialization. They also verify that a new session can be created only by an initialize POST with no session ID.
I reran the two regression files against the current repository before publishing this page: 10 tests passed. That proves the decision helpers still behave as described. It does not pretend to reproduce every browser, proxy, or deployment race in a live system.
The protocol removed this entire class of bookkeeping
The timing is hard to miss. These fixes shipped on July 9 and July 10. The 2026-07-28 MCP specification arrived 18 days later with stateless, self-contained requests and no protocol-level session ID.
SEP-2567 explains the motivation: clients never converged on a predictable session lifetime, session state complicated load balancing and caching, and cleanup signals were unreliable. Application state can still exist, but it travels as an explicit handle in tool arguments rather than an invisible transport session.
That change does not make bad cleanup impossible. A server can still leak an application object or mishandle an explicit handle. It does remove the mandatory map of transport IDs, the restart recovery dance around Mcp-Session-Id, and the assumption that a session boundary means the same thing to every client.
What I changed in my own design rules
I took five rules away from these two incidents:
- Make state changes before callbacks. If cleanup can re-enter, the registry must already describe the post-cleanup world.
- Treat teardown as idempotent. A second close should be boring.
- Use the protocol’s recovery signal. A stale identifier is not a malformed new session; it is a missing old one.
- Test restart-shaped requests. A clean client connecting to a clean server misses the interesting failure.
- Do not confuse process health with service health. An endpoint returning 200 did not mean OAuth sessions could complete.
The newer sessionless protocol is the cleaner destination. GitLab MCP still needs compatibility with clients speaking older MCP revisions, so the fixed session path remains worth testing. Compatibility code is exactly where a bug can survive after the main design has moved on.
Disclosure: I maintain GitLab MCP. It is open-source software under the MIT license. The repository and specification links are not affiliate links. The hero image is an editorial illustration, not a screenshot of the service.