Skip to content

feat(mcp): proxy the remote MCP server over stdio using the CLI login - #1026

Merged
codyde merged 2 commits into
masterfrom
cody/mcp-remote-proxy
Jul 27, 2026
Merged

feat(mcp): proxy the remote MCP server over stdio using the CLI login#1026
codyde merged 2 commits into
masterfrom
cody/mcp-remote-proxy

Conversation

@codyde

@codyde codyde commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

What

Adds railway mcp proxy: a local stdio bridge to mcp.railway.com that attaches a fresh Bearer token from the stored railway login on every request. MCP-capable editors (Claude Code, Cursor, Codex, OpenCode, Copilot, Factory Droid) can use the remote MCP server without the OAuth flow (dynamic client registration + browser consent) and without long-lived credentials in editor configs.

Today an editor pointed at mcp.railway.com runs the full MCP OAuth dance. For developers who already ran railway login, that's a redundant second auth ceremony — and pasting a token into an editor config can't substitute for it because remote access tokens expire hourly. The proxy makes the existing CLI login be the auth.

How it works

MCP client ──stdio/JSON-RPC──▶ railway mcp proxy ──HTTPS/Bearer──▶ mcp.railway.com
                                      │
                              ~/.railway/config.json
                          (auto-refreshed, lockfile-serialized)
  • Token freshness delegates to ensure_valid_token (the same lockfile-serialized refresh the rest of the CLI uses), so the proxy coexists safely with the local railway mcp server and other CLI processes.
  • SSE responses are re-framed as newline-delimited JSON-RPC through a single writer task (no interleaving); tool calls run concurrently after the handshake; transparent re-initialize + one retry on session loss.
  • Logged-out start heals without a restart: the proxy completes the MCP handshake and returns an actionable "run railway login" on each call; the next request after login succeeds automatically.
  • Bounded 5s shutdown so a stalled upstream can't orphan the process; JSON-RPC batch error paths answer every id.

Install surface

railway mcp install gains a three-way transport:

  • --remote → installs the proxy (railway mcp proxy), the new no-prompt default for CLI users.
  • --remote --oauth → keeps the plain-URL, editor-managed OAuth flow (for environments without the CLI: web clients, hosted agents, teammates who never installed railway).
  • no flag → local stdio server, unchanged.

Config matchers distinguish local / proxy / oauth entries (a bare "args contains mcp" check would misdetect a proxy install as local). Same three choices are plumbed through railway setup agent.

Testing

  • cargo build clean; cargo test mcp — 33 passing (SSE boundary/chunk-split parsing, stdout framing, batch handling, three-way matchers across JSON/opencode-array/codex-TOML config shapes).
  • Verified end-to-end against mcp.railway.com through the built binary: initialize → 27 tools → live tool calls.
  • Deep adversarial review pass; fixes for the logged-out heal, orphan-on-EOF, and batch error paths are included.

Scope

MCP files only — deliberately excludes the unrelated railway code work.

🤖 Generated with Claude Code

Adds `railway mcp proxy`: a stdio bridge to mcp.railway.com that attaches
a fresh Bearer token from the stored `railway login` on every request, so
MCP-capable editors can use the remote server without the OAuth (DCR +
browser consent) flow and without long-lived credentials in editor configs.

- Token freshness delegates to ensure_valid_token (lockfile-serialized, so
  the proxy coexists with the local `railway mcp` server); a proxy started
  logged-out completes the handshake with a login hint and heals on the
  next request after `railway login`, no restart needed.
- SSE responses are re-framed as newline-delimited JSON-RPC via a single
  writer task; concurrent tool calls after the handshake; transparent
  re-initialize + one retry on session loss; bounded 5s shutdown so a
  stalled upstream can't orphan the process; JSON-RPC batch error paths
  answer every id.
- `railway mcp install` gains a three-way transport: --remote now installs
  the proxy, --remote --oauth keeps the plain-URL editor OAuth flow, and
  the config matchers distinguish local/proxy/oauth entries (a bare
  "args contains mcp" check would misdetect proxy installs as local).
- Same choices plumbed through `railway setup agent`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codyde codyde added the release/minor Author minor release label Jul 25, 2026
Security review of the credential-injecting proxy. The token-handling core
was already sound (destination fixed at startup and not derivable from
server/harness input; reqwest strips the Bearer on cross-host redirect,
verified against the 0.12.28 source; TLS enforced outside Dev; no path
stringifies the credential). Closing the residual robustness/defense-in-depth
gaps it found:

- Bound every upstream response to MAX_RESPONSE_BYTES (32 MiB). reqwest's
  text()/bytes() have no size cap and the proxy sets no overall timeout, so a
  compromised edge or dev/http-override MITM streaming a boundary-less SSE
  body (or one giant event/body) could OOM a long-lived proxy. stream_sse and
  a new read_body_capped helper now abort past the ceiling.
- redirect(Policy::none()): an MCP JSON-RPC POST is never legitimately
  redirected. The Bearer is stripped cross-host, but refusing redirects
  outright also closes the same-host 307/308 body-resend and the
  redirected-response relay edges.
- Validate RAILWAY_MCP_URL: reject non-https outside Dev so the override
  can't send the Bearer over plaintext (the first hop isn't covered by the
  cross-host strip). Logic extracted to validate_mcp_override + unit test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codyde

codyde commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Security review (pre-merge)

Ran a dedicated threat-model pass on the proxy as a credential-handling component, since it injects a live OAuth Bearer on every request. Verified the redirect/header behavior against the actual reqwest 0.12.28 source, not from memory.

Core is sound — no changes needed:

  • Token can't be retargeted: the destination URL is fixed at startup and never derived from server- or harness-supplied input; every POST/DELETE/re-init uses it verbatim.
  • Bearer is stripped on cross-host redirect: confirmed in reqwest/src/redirect.rs (remove_sensitive_headers drops AUTHORIZATION when host/port differs), and it operates on the full outgoing header map including per-request headers.
  • TLS enforced outside the Dev environment (RAILWAY_ENV-gated, not remotely flippable); no log/error path stringifies the token or the Authorization header; token isn't written anywhere new; harness message content can't inject request headers; no cross-session token-context races (single-user, token re-read per request).

Hardened in 3416cc7:

  1. Bounded response buffers (32 MiB) — reqwest's text()/bytes() are uncapped and the proxy sets no overall timeout, so a compromised edge / dev-MITM streaming a boundary-less SSE body could OOM a long-lived proxy. stream_sse + a new read_body_capped now abort past the ceiling.
  2. redirect(Policy::none()) — an MCP JSON-RPC POST should never redirect; closes the same-host 307/308 body-resend and redirected-response-relay edges outright.
  3. RAILWAY_MCP_URL validation — reject non-https outside Dev so the override can't send the Bearer over plaintext (the first hop isn't covered by the cross-host strip).

34 tests pass; re-verified live streaming against mcp.railway.com (27 tools, tool calls stream fine) after the changes.

@codyde
codyde merged commit edf4f39 into master Jul 27, 2026
6 checks passed
@codyde
codyde deleted the cody/mcp-remote-proxy branch July 27, 2026 23:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release/minor Author minor release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant