8000 fix(transport): add Streamable HTTP MCP transport, fix stateless-per-request bug by Mnehmos · Pull Request #77 · Mnehmos/mnehmos.rpg.mcp · GitHub
[go: up one dir, main page]

Skip to content

fix(transport): add Streamable HTTP MCP transport, fix stateless-per-request bug - #77

Merged
Mnehmos merged 5 commits into
mainfrom
fix/http-transport-stateless-per-request
Aug 14, 2026
Merged

Mnehmos merged 5 commits into
mainfrom
fix/http-transport-stateless-per-request

Conversation

@Mnehmos
@Mnehmos Mnehmos commented Aug 12, 2026 •
Copy link
Copy Markdown
Owner

Summary

Adds a Streamable HTTP MCP transport (--http) so this server can run as a hosted Railway service instead of only stdio/TCP/Unix/WebSocket, and fixes a real bug in that transport discovered live against a deployed instance.

  • src/server/transport/http.ts — new file: POST /mcp (Streamable HTTP, stateless), GET /health, bearer/query/header token auth.
  • src/server/index.ts — extracts tool registration into buildServer(pubsub, auditLogger), called once for the long-lived stdio/tcp/unix/websocket transports, and per-request for HTTP. This is the actual fix: StreamableHTTPServerTransport in stateless mode (sessionIdGenerator: undefined) throws "Stateless transport cannot be reused across requests" after the first request — confirmed live against a deployed Railway instance, where the first initialize succeeded and every request after it returned a bare 500. The SDK's own stateless-mode contract requires a fresh transport instance per request (server/webStandardStreamableHttp.js's _hasHandledRequest guard); the fix builds a fresh McpServer + transport pair per HTTP request (cheap — no I/O, just tool registration) while sharing the real app-level singletons (DB, pubsub, audit logger) across requests.
  • railway.json — Railpack build/deploy config for hosting this as a Railway service.

Risk

  • Low — additive: no existing transport (stdio/tcp/unix/websocket) is touched by this change, and buildServer() is a straight extraction of code that was previously inline in main(), not a behavior change for those paths.
  • The HTTP transport itself is new surface area, but it's opt-in via --http/PORT and not used by any existing deployment of this repo today.

Acceptance criteria

  • POST /mcp handles a full MCP session (initialize → notifications/initialized → tools/call) correctly across multiple independent requests, not just the first one.
  • GET /health responds without auth.
  • Existing stdio/tcp/unix/websocket transports are unaffected (buildServer() extraction is behavior-preserving for those paths).

Verification

  • npm run build (tsc) passes.
  • Live-verified against a deployed Railway instance running this exact code: initialize, notifications/initialized, and independent tools/call requests (including a second, fully independent initialize) all succeed after the fix — before the fix, only the very first request after a fresh deploy succeeded and everything after returned a bare 500.
  • Live-verified the full tool catalog loads correctly over this transport (load_tool_schema for all 24 curated tools) and a real character_manage.create / inventory_manage.get_detailed / narrative_manage.add/search / session_manage.initialize round trip, from a separate consumer project (rpg-mcp-live, see PR #211) that depends on this transport.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added HTTP access for MCP clients through the /mcp endpoint.
    • Added a /health endpoint for service availability checks.
    • Added optional authentication, request-size limits, and configurable host and port settings.
    • Added support for stateless HTTP requests with improved connection cleanup and structured error responses.
    • Preserved support for TCP, Unix, WebSocket, and standard input/output connections.
  • Bug Fixes

    • Corrected scene participant lookup behavior.
  • Deployment

    • Added Railway deployment configuration with automated builds, health checks, and automatic restarts after failures.

Adds a Node http-based Streamable HTTP transport (POST /mcp, GET /health)
alongside the existing stdio/tcp/websocket/unix transports, auto-selected
when PORT is set or --http is passed. Auth via RPG_MCP_TRANSPORT_TOKEN,
same convention as the tcp/websocket transports.
Stateless StreamableHTTPServerTransport throws 'Stateless transport
cannot be reused across requests' after the first POST /mcp, since
sharing one transport instance across unrelated HTTP requests violates
the SDK's stateless-mode contract (server/webStandardStreamableHttp.js
guards this explicitly via _hasHandledRequest). Discovered live against
the deployed Railway service: the first initialize succeeded, every
call after it returned a bare 500.

Extract tool registration into buildServer(pubsub, auditLogger) and
call it once per request for the HTTP transport (cheap, no I/O),
while stdio/tcp/unix/websocket keep a single long-lived instance.
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai
coderabbitai Bot commented Aug 12, 2026 •
Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Mnehmos, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 44 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ef2831b2-97b3-4922-a1e7-35adcf20546f

📥 Commits

Reviewing files that changed from the base of the PR and between 8513647 and 1d75648.

📒 Files selected for processing (2)
  • src/server/index.ts
  • src/server/transport/websocket.ts

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 91bb94e9-d770-47ab-a1f9-7c5cc2792246

📥 Commits

Reviewing files that changed from the base of the PR and between 852e43d and 8513647.

📒 Files selected for processing (3)
  • src/server/consolidated/scene-manage.ts
  • src/server/index.ts
  • src/server/transport/http.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/server/transport/http.ts
  • src/server/index.ts

📝 Walkthrough

Walkthrough

The server now supports stateless HTTP MCP requests through /mcp, health checks through /health, configurable authentication and request limits, per-transport server construction, and Railway deployment settings. Scene lookup now uses the corrected repository argument order.

Changes

HTTP deployment transport

Layer / File(s) Summary
Reusable MCP server construction
src/server/index.ts
buildServer(pubsub, auditLogger) creates MCP servers and registers event, meta, and registry tools. Shared PubSub and AuditLogger instances support multiple server instances.
Stateless HTTP transport
src/server/transport/http.ts
The HTTP transport validates paths and methods, supports token authentication, limits and parses JSON bodies, serves /health, and creates a fresh MCP server and transport for each /mcp request.
Transport selection and deployment wiring
src/server/index.ts, railway.json
HTTP starts when --http or PORT is set. Existing transports use the server factory. Railway defines the build, startup, health-check, timeout, and restart settings.

Scene lookup correction

Layer / File(s) Summary
Corrected participant lookup arguments
src/server/consolidated/scene-manage.ts
handleCurrent now passes characterId before worldId to findLatestForParticipant.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant HTTPClient
  participant startHttpServerTransport
  participant buildServer
  participant StreamableHTTPServerTransport

  HTTPClient->>startHttpServerTransport: POST /mcp
  startHttpServerTransport->>buildServer: Create MCP server
  buildServer-->>startHttpServerTransport: Return McpServer
  startHttpServerTransport->>StreamableHTTPServerTransport: Handle MCP request
  StreamableHTTPServerTransport-->>HTTPClient: Return MCP response
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: adding Streamable HTTP MCP transport and fixing stateless per-request handling.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/http-transport-stateless-per-request

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment
Copy link
Copy Markdown

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 current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/server/index.ts`:
- Around line 296-299: Update the startHttpServerTransport call to pass the
configured maxMessageBytes value as the maxBodyBytes option, ensuring the HTTP
request limit follows --max-message-bytes instead of the transport’s 4 MiB
default.

In `@src/server/transport/http.ts`:
- Around line 74-76: Update the HTTP authorization flow around isAuthorized so
requests are rejected when RPG_MCP_TRANSPORT_TOKEN is unset, rather than logging
a warning and allowing anonymous access. Require token authentication by
default; if local anonymous use must remain supported, gate it behind an
explicit unsafe opt-in whose default is false.
🪄 Autofix

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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 313ed1a0-6c07-4265-a410-16a8aa75d4b7

📥 Commits

Reviewing files that changed from the base of the PR and between 2feba24 and 852e43d.

📒 Files selected for processing (3)
  • railway.json
  • src/server/index.ts
  • src/server/transport/http.ts

Comment thread src/server/index.ts Resolved
Comment thread src/server/transport/http.ts Resolved
findLatestForParticipant(characterId, worldId?) was called as
(worldId, characterId), so the DM-facing 'current' preview always
returned scene: null. Both params are string, so TS could not catch it.
Agent auto-injection was unaffected — the prompt composer calls
listLatestForParticipant with the correct order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Mnehmos
Mnehmos commented Aug 12, 2026
Copy link
Copy Markdown
Owner Author

Added one more commit (e6af294, already authored 2026-07-25, not related to this PR's transport work): the CI matrix caught that \origin/main\ currently fails \ ests/server/consolidated/scene-manage.test.ts\ (a pre-existing transposed-argument bug in \scene_manage action:'current', unrelated to anything in this PR's diff). That fix already existed locally and was just never pushed, so I included it here to keep CI green rather than leaving this PR red for an unrelated reason.

@Mnehmos
Mnehmos commented Aug 12, 2026
Copy link
Copy Markdown
Owner Author

build (18.x) fails consistently (retried once, same result) on ests/server/websocket.test.ts with ECONNREFUSED ::1:3002 -- a connection-timing issue in the WebSocket transport test suite, a module this PR does not touch. build (20.x) and (22.x) both pass cleanly. This looks like a pre-existing Node-18-specific compatibility issue (likely IPv6/localhost resolution behavior that changed between Node 18 and 20+), not a regression from this PR's changes. Leaving it for whoever owns Node-18 support to triage separately -- flagging here rather than silently ignoring it.

…works

Railway's private network is IPv6-only. A server bound to '0.0.0.0'
listens on IPv4 only, so it is reachable on its public domain but NOT
on <service>.railway.internal -- peer services get a connection
failure with no obvious cause.

Default the HTTP host to '::', which Node binds dual-stack (IPv6 +
IPv4), so both the private hostname and the public domain work. Still
overridable via --host for local/dev binding.

Found while wiring rpg-mcp-live to this service: the integration had
to fall back to the public domain because the private hostname was
unreachable.
@repowise-bot
repowise-bot Bot commented Aug 14, 2026 •
Copy link
Copy Markdown

✅ Health of changed files: 4.4 → 4.8 (+0.4)

📋 At a glance
1 file changed health · 2 hotspots touched · 1 new finding introduced · 2 files with recent fix history · 2 dead-code findings · no tests touched. Scoped to src.

✅ Health gate: passed

📌 Before you merge

  • Run tests/server/websocket.test.ts: they import the changed files
🔎 More signals (3)

🗺️ Change map

flowchart LR
  subgraph PR ["Changed in this PR (2 with dependents)"]
    f_src_server_index_ts["src/server/index.ts 🔥"]:::changed
    f_src_server_transport_websocket_ts[".../transport/websocket.ts 🔥"]:::changed
  end
  f_src_cli_ts["src/cli.ts"]
  f_src_server_index_ts --> f_src_cli_ts
  f_src_index_ts["src/index.ts"]
  f_src_server_index_ts --> f_src_index_ts
  f_src_run_ts["src/run.ts"]
  f_src_server_index_ts --> f_src_run_ts
  f_src_server_consolidated_spawn_manage_ts[".../consolidated/spawn-manage.ts"]
  f_src_server_index_ts --> f_src_server_consolidated_spawn_manage_ts
  t_tests_server_websocket_test_ts(["✅ tests/server/websocket.test.ts"]):::guard
  t_tests_server_websocket_test_ts -.-> f_src_server_transport_websocket_ts
  classDef changed fill:#dbeafe,stroke:#1d4ed8,color:#1e3a5f
  classDef warn fill:#fef3c7,stroke:#b45309,color:#78350f
  classDef guard fill:#dcfce7,stroke:#15803d,color:#14532d
Loading

Solid arrows: code that imports the changed files (4 direct dependents, from the last indexed snapshot). Dashed: history/tests.

🔥 Hotspots touched (2)

  • src/server/index.ts: 10 commits/90d, 4 dependents
  • .../transport/websocket.ts: 2 commits/90d, 2 dependents

💀 Dead code (2 findings)

  • 💀 src/server/index.ts envLoadedFrom (confidence 0.65)
  • 💀 .../transport/websocket.ts DEFAULT_MAX_MESSAGE_BYTES (confidence 0.65)

📊 See the full report for this PR
Your repo map with this PR's blast radius lit up, every caller of the contracts it changes, and health before and after. No sign-in. · ⭐ Star Repowise · 📥 Install bot · Silence on a single PR with [skip repowise] in the title · Per-repo toggle on repowise.dev/settings?tab=bot · Updated 2026-08-14 19:08 UTC (since the last push: 1 fewer file)

@Mnehmos
Mnehmos merged commit 3c476f1 into main Aug 14, 2026
5 checks passed
zxrfire pushed a commit to zxrfire/mnehmos.rpg.mcp that referenced this pull request Sep 8, 2026
…request bug (Mnehmos#77)

* feat(transport): add HTTP MCP transport for Railway hosting

Adds a Node http-based Streamable HTTP transport (POST /mcp, GET /health)
alongside the existing stdio/tcp/websocket/unix transports, auto-selected
when PORT is set or --http is passed. Auth via RPG_MCP_TRANSPORT_TOKEN,
same convention as the tcp/websocket transports.

* fix(transport): build a fresh McpServer+transport per HTTP request

Stateless StreamableHTTPServerTransport throws 'Stateless transport
cannot be reused across requests' after the first POST /mcp, since
sharing one transport instance across unrelated HTTP requests violates
the SDK's stateless-mode contract (server/webStandardStreamableHttp.js
guards this explicitly via _hasHandledRequest). Discovered live against
the deployed Railway service: the first initialize succeeded, every
call after it returned a bare 500.

Extract tool registration into buildServer(pubsub, auditLogger) and
call it once per request for the HTTP transport (cheap, no I/O),
while stdio/tcp/unix/websocket keep a single long-lived instance.

* fix(scene): swap transposed args in scene_manage action:'current'

findLatestForParticipant(characterId, worldId?) was called as
(worldId, characterId), so the DM-facing 'current' preview always
returned scene: null. Both params are string, so TS could not catch it.
Agent auto-injection was unaffected — the prompt composer calls
listLatestForParticipant with t
7B3A
he correct order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(transport): bind HTTP transport dual-stack so private networking works

Railway's private network is IPv6-only. A server bound to '0.0.0.0'
listens on IPv4 only, so it is reachable on its public domain but NOT
on <service>.railway.internal -- peer services get a connection
failure with no obvious cause.

Default the HTTP host to '::', which Node binds dual-stack (IPv6 +
IPv4), so both the private hostname and the public domain work. Still
overridable via --host for local/dev binding.

Found while wiring rpg-mcp-live to this service: the integration had
to fall back to the public domain because the private hostname was
unreachable.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant

0