Skip to content

Fix MCP HTTP transport lifecycle#59

Open
JohnONolan wants to merge 1 commit intoTryGhost:mainfrom
JohnONolan:codex/fix-mcp-http-lifecycle
Open

Fix MCP HTTP transport lifecycle#59
JohnONolan wants to merge 1 commit intoTryGhost:mainfrom
JohnONolan:codex/fix-mcp-http-lifecycle

Conversation

@JohnONolan
Copy link
Copy Markdown
Member

Summary

This fixes the HTTP MCP transport lifecycle so ghst mcp http no longer reuses a stateless StreamableHTTPServerTransport across requests.

Root Cause

The MCP SDK requires a fresh transport instance for each request in stateless HTTP mode. The previous implementation created one long-lived stateless transport and reused it for the lifetime of the process, which caused follow-up HTTP requests to fail with 500 Internal Server Error.

Changes

  • pass a server factory into the HTTP MCP runner so a fresh MCP server can be created per request
  • create and clean up a new stateless HTTP transport per request
  • expand allowed CORS headers/methods for MCP HTTP traffic
  • add a real integration test covering initialize -> notifications/initialized -> tools/list
  • update transport tests to match the new lifecycle

Validation

  • pnpm lint
  • pnpm typecheck
  • pnpm test
  • pnpm build

Closes #51

Create a fresh MCP server and stateless HTTP transport per request so follow-up HTTP calls no longer reuse an invalid transport instance.

Add a real MCP HTTP integration test for initialize plus follow-up requests and update transport unit tests for the new lifecycle.

Closes TryGhost#51
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 30, 2026

Walkthrough

The pull request refactors the MCP HTTP transport to accept a factory function that creates a fresh server per request instead of accepting a pre-created server instance. In src/mcp/transports/http.ts, runMcpHttp now instantiates a new server and StreamableHTTPServerTransport for each incoming HTTP request with added async error handling, cleanup logic using Promise.allSettled, and updated CORS headers. The test-injection interfaces in src/commands/mcp.ts are updated to reflect the factory function signature. Existing tests are updated to use the new pattern, and a new integration test validates the HTTP transport flow with MCP JSON-RPC initialization.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed The description is well-structured and directly related to the changeset, explaining the root cause, changes made, and validation steps.
Linked Issues check ✅ Passed The PR addresses issue #51 by refactoring the HTTP MCP transport to create fresh server and transport instances per request, preventing the 500 errors caused by transport reuse.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the HTTP MCP transport lifecycle: factory pattern for server creation, per-request transport instantiation, CORS header updates, and related test updates.
Title check ✅ Passed The title 'Fix MCP HTTP transport lifecycle' accurately summarizes the main change: addressing the HTTP MCP transport lifecycle issue where the server was reusing a stateless transport across requests instead of creating fresh instances per request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

@JohnONolan JohnONolan marked this pull request as ready for review March 30, 2026 11:42
@JohnONolan JohnONolan changed the title [codex] Fix MCP HTTP transport lifecycle Fix MCP HTTP transport lifecycle Mar 30, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/mcp/transports/http.ts`:
- Around line 102-124: The per-request setup (creation of requestServer via
createServer(), instantiation of StreamableHTTPServerTransport, and the
cleanedUp/cleanup definitions and res.once('close'...) hook) must be moved
inside the protected try block so any exceptions are caught; modify the handler
so requestServer, transport and cleanup are created after entering try, then
call await requestServer.connect(transport) and await
transport.handleRequest(req, res) there, and in the catch block call cleanup()
and send a 500 response before returning; keep the res.once('close', () => void
cleanup()) registration using the newly created cleanup function and ensure
cleanup is idempotent using the existing cleanedUp flag.

In `@tests/mcp-http-integration.test.ts`:
- Around line 92-93: The test is brittle because it asserts an exact protocol
version string; update the assertion around initializeResponse to verify the
response contains a protocolVersion key with a valid value instead of a fixed
date. For example, parse await initializeResponse.text() or JSON.parse(await
initializeResponse.text()) and assert that the protocolVersion property exists
and is a non-empty string (or matches an ISO-date regex) rather than expecting
the exact literal "2025-11-25"; change the expect on initializeResponse to check
presence/format of protocolVersion using the initializeResponse variable and any
JSON parsing helper used in the test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a595a924-93fd-4398-a86f-0c8fd4050b2b

📥 Commits

Reviewing files that changed from the base of the PR and between 94df0fa and b6b0ec6.

📒 Files selected for processing (4)
  • src/commands/mcp.ts
  • src/mcp/transports/http.ts
  • tests/mcp-http-integration.test.ts
  • tests/mcp-transports.test.ts

Comment on lines +102 to +124
const requestServer = createServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
let cleanedUp = false;
const cleanup = async () => {
if (cleanedUp) {
return;
}

cleanedUp = true;
await Promise.allSettled([transport.close(), requestServer.close()]);
};

res.once('close', () => {
void cleanup();
});

try {
await requestServer.connect(transport);
await transport.handleRequest(req, res);
} catch {
await cleanup();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

cat -n src/mcp/transports/http.ts | sed -n '95,135p'

Repository: TryGhost/ghst

Length of output: 1372


🏁 Script executed:

# Check Node.js version handling of unhandled rejections
web_search Node.js 20 unhandled promise rejections default behavior

Repository: TryGhost/ghst

Length of output: 107


🌐 Web query:

Node.js 20 unhandled promise rejections default behavior throw

💡 Result:

In Node.js 20, the default behavior for unhandled promise rejections is to throw them as uncaught exceptions, causing the process to crash with exit code 1. This has been the default since Node.js 15, controlled by the --unhandled-rejections=throw mode (equivalent to no flag specified).

Citations:


🏁 Script executed:

# Check if createServer can fail and what StreamableHTTPServerTransport is
fd -t f 'http\.ts' src/mcp/transports/

Repository: TryGhost/ghst

Length of output: 83


🏁 Script executed:

# Find StreamableHTTPServerTransport definition to understand if constructor can fail
rg "class StreamableHTTPServerTransport" -A 10

Repository: TryGhost/ghst

Length of output: 39


🏁 Script executed:

# Check if createServer is the standard Node.js http.createServer or custom
rg "createServer" src/mcp/transports/http.ts | head -20

Repository: TryGhost/ghst

Length of output: 191


🏁 Script executed:

# Check for error handlers on the http server and context around createServer call
cat -n src/mcp/transports/http.ts | sed -n '1,110p'

Repository: TryGhost/ghst

Length of output: 4264


🏁 Script executed:

# Check the broader context - is there error handling at the server level or listener level?
cat -n src/mcp/transports/http.ts | sed -n '55,90p'

Repository: TryGhost/ghst

Length of output: 1690


🏁 Script executed:

# Find StreamableHTTPServerTransport to understand if constructor can fail
rg "StreamableHTTPServerTransport" src/mcp/transports/http.ts

Repository: TryGhost/ghst

Length of output: 214


🏁 Script executed:

# Check imports to find where StreamableHTTPServerTransport comes from
head -40 src/mcp/transports/http.ts

Repository: TryGhost/ghst

Length of output: 1275


🏁 Script executed:

# Check if there's any process-level error handling or server error listeners
cat -n src/mcp/transports/http.ts | sed -n '130,160p'

Repository: TryGhost/ghst

Length of output: 1010


🏁 Script executed:

# Check the rest of the file to understand the full context and any additional error handling
cat -n src/mcp/transports/http.ts

Repository: TryGhost/ghst

Length of output: 6108


Move per-request setup inside the protected path.

Lines 102–105 execute before the try block at line 120. If createServer() or the StreamableHTTPServerTransport constructor throw, the error becomes an unhandled rejection in the async request listener. In Node 20, unhandled rejections default to terminating the process. Move setup into the try block to ensure errors are caught and handled with a 500 response.

🔧 Suggested fix
-    const requestServer = createServer();
-    const transport = new StreamableHTTPServerTransport({
-      sessionIdGenerator: undefined,
-    });
+    let requestServer: McpServer | undefined;
+    let transport: StreamableHTTPServerTransport | undefined;
     let cleanedUp = false;
     const cleanup = async () => {
       if (cleanedUp) {
         return;
       }

       cleanedUp = true;
-      await Promise.allSettled([transport.close(), requestServer.close()]);
+      const pending: Array<Promise<unknown>> = [];
+      if (transport) {
+        pending.push(transport.close());
+      }
+      if (requestServer) {
+        pending.push(requestServer.close());
+      }
+      await Promise.allSettled(pending);
     };

     res.once('close', () => {
       void cleanup();
     });

     try {
+      requestServer = createServer();
+      transport = new StreamableHTTPServerTransport({
+        sessionIdGenerator: undefined,
+      });
       await requestServer.connect(transport);
       await transport.handleRequest(req, res);
     } catch {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/mcp/transports/http.ts` around lines 102 - 124, The per-request setup
(creation of requestServer via createServer(), instantiation of
StreamableHTTPServerTransport, and the cleanedUp/cleanup definitions and
res.once('close'...) hook) must be moved inside the protected try block so any
exceptions are caught; modify the handler so requestServer, transport and
cleanup are created after entering try, then call await
requestServer.connect(transport) and await transport.handleRequest(req, res)
there, and in the catch block call cleanup() and send a 500 response before
returning; keep the res.once('close', () => void cleanup()) registration using
the newly created cleanup function and ensure cleanup is idempotent using the
existing cleanedUp flag.

Comment on lines +92 to +93
expect(initializeResponse.status).toBe(200);
expect(await initializeResponse.text()).toContain('"protocolVersion":"2025-11-25"');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's see the test file around lines 92-93
cd tests && wc -l mcp-http-integration.test.ts

Repository: TryGhost/ghst

Length of output: 89


🏁 Script executed:

# Read the relevant section of the test file
sed -n '85,100p' tests/mcp-http-integration.test.ts

Repository: TryGhost/ghst

Length of output: 557


🏁 Script executed:

# Let's also check the broader context to understand the test better
sed -n '80,110p' tests/mcp-http-integration.test.ts

Repository: TryGhost/ghst

Length of output: 968


🌐 Web query:

MCP protocol version negotiation initialize response specification

💡 Result:

The Model Context Protocol (MCP) performs version negotiation during the initialization phase of the client-server lifecycle. The client sends an 'initialize' JSON-RPC request with its supported protocolVersion (e.g., "2025-11-25"), clientInfo, and capabilities. The server responds with an 'initialize' response containing the negotiated protocolVersion (which may match or differ from the client's request; client disconnects if unsupported), serverInfo, capabilities, and optional instructions. Example initialize request (client to server): { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-11-25", "capabilities": { ... }, "clientInfo": { "name": "ExampleClient", "version": "1.0.0" } } } Example initialize response (server to client): { "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2025-11-25", "capabilities": { ... }, "serverInfo": { "name": "ExampleServer", "version": "1.0.0" }, "instructions": "Optional instructions for the client" } } After response, client sends 'notifications/initialized' notification. For HTTP transports, subsequent requests include MCP-Protocol-Version header with the negotiated version. Current protocol version as of sources: 2025-11-25. Schema defined in TypeScript at github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-11-25/schema.ts.

Citations:


🏁 Script executed:

# Search for server implementation to see what versions it supports
fd -t f "\.ts$|\.js$" . --not-path "*/node_modules/*" | head -20

Repository: TryGhost/ghst

Length of output: 319


🏁 Script executed:

# Look for version-related configuration or constants in the codebase
rg "2025-11-25|protocolVersion" --type ts --type tsx -B 2 -A 2 | head -60

Repository: TryGhost/ghst

Length of output: 84


🏁 Script executed:

# Check the server/handler implementation
rg "initialize" --type ts --type tsx -B 3 -A 5 | head -80

Repository: TryGhost/ghst

Length of output: 84


🏁 Script executed:

# List files in repository structure
git ls-files | grep -E "\.(ts|js)$" | head -30

Repository: TryGhost/ghst

Length of output: 727


🏁 Script executed:

# Search for protocolVersion mentions
rg "protocolVersion" -i | head -50

Repository: TryGhost/ghst

Length of output: 330


🏁 Script executed:

# Look for the server implementation or handler
rg "method.*initialize" --type ts | head -30

Repository: TryGhost/ghst

Length of output: 264


🏁 Script executed:

# Check if there are constants or version definitions
rg "2025-11-25" | head -40

Repository: TryGhost/ghst

Length of output: 496


Don't pin this regression test to one specific protocol version.

The test assertion on line 93 checks for an exact version match "protocolVersion":"2025-11-25", but the MCP specification allows the server to return a different negotiated version if needed. This makes the test brittle—if the server is later updated to support version negotiation or backward compatibility, the assertion will fail even though the response is spec-compliant.

🔧 Suggested assertion
       expect(initializeResponse.status).toBe(200);
-      expect(await initializeResponse.text()).toContain('"protocolVersion":"2025-11-25"');
+      const initializeBody = await initializeResponse.json();
+      expect(typeof initializeBody.result?.protocolVersion).toBe('string');
+      expect(initializeBody.result.protocolVersion.length).toBeGreaterThan(0);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
expect(initializeResponse.status).toBe(200);
expect(await initializeResponse.text()).toContain('"protocolVersion":"2025-11-25"');
expect(initializeResponse.status).toBe(200);
const initializeBody = await initializeResponse.json();
expect(typeof initializeBody.result?.protocolVersion).toBe('string');
expect(initializeBody.result.protocolVersion.length).toBeGreaterThan(0);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/mcp-http-integration.test.ts` around lines 92 - 93, The test is brittle
because it asserts an exact protocol version string; update the assertion around
initializeResponse to verify the response contains a protocolVersion key with a
valid value instead of a fixed date. For example, parse await
initializeResponse.text() or JSON.parse(await initializeResponse.text()) and
assert that the protocolVersion property exists and is a non-empty string (or
matches an ISO-date regex) rather than expecting the exact literal "2025-11-25";
change the expect on initializeResponse to check presence/format of
protocolVersion using the initializeResponse variable and any JSON parsing
helper used in the test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP HTTP: 500 on protocolVersion 2025-11-25 (Claude Desktop / mcp-remote)

1 participant