Skip to content

feat(mcp): carry the directory link through the proxy - #1108

Open
codyde wants to merge 2 commits into
masterfrom
cody/mcp-proxy-link-context
Open

feat(mcp): carry the directory link through the proxy#1108
codyde wants to merge 2 commits into
masterfrom
cody/mcp-proxy-link-context

Conversation

@codyde

@codyde codyde commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Prerequisite for the RFC step that makes railway mcp launch the proxy by default. Companion backboard PR: railwayapp/mono#35787.

Problem

The local MCP server resolves projectId / environmentId / serviceId from railway link. The remote server runs on a different machine and never can — and projectId is a required parameter on every project-scoped remote tool.

Measured 2026-08-16: ~44% of successful local tool calls pass no projectId and rely entirely on the directory link. That's roughly 19,000 calls/day across list_deployments (5,510), get_logs (5,147), list_services, list_variables, environment_status, get_service_config, set_variables, deploy, list_domains, service_metrics.

Flipping the baseline command without this breaks every one of them, for ~6,759 users/day — 16x the current proxy population.

Solution

The proxy reads the link at startup — same source the local server uses, and the working directory is fixed for the life of the process — then fills in the parameters a tools/call left out.

Conservative on three axes, because a wrong injection is worse than none:

Guard Behavior
Only declared parameters The accepted set is learned from the server's own tools/list result, not a list baked into the CLI that would drift as tools change. A docs or workspace tool is untouched.
Never overwrite A value the caller supplied always wins.
Fail closed Nothing happens until tools/list has been seen, or when nothing is linked. An unknown tool is forwarded exactly as sent.

emit_json_line / emit_sse_event now return the message they emitted, so the tools/list result can be learned from either transport (the server may answer as JSON or SSE).

Why learn the schema instead of hardcoding

A static list of "tools that take a projectId" would be wrong the first time the remote surface adds or renames a tool, and the failure mode is sending a parameter the tool rejects. Reading inputSchema.properties off the listing the proxy already relays keeps the two in sync for free.

Sources covered

get_linked_project resolves three things; this covers the two that need no I/O:

Source Covered Note
RAILWAY_PROJECT_ID / ENVIRONMENT_ID / SERVICE_ID Yes Wins over the directory link, and discards a link naming a different project rather than pairing project A with project B's environment
railway link directory link Yes The common case
Project resolved from RAILWAY_TOKEN No Costs a GraphQL round trip; a bad token would put a 15s connect timeout in front of proxy startup while the harness waits. Those users must pass ids explicitly

Pressure-test findings

Adversarial review of this PR turned up and fixed:

  • Env-var targeting was ignored entirely — now covered, including the don't-mix rule above
  • JSON-RPC batches are forwarded untouched (method_of sees no method on a top-level array). Fail-closed and deliberate; batches are vanishingly rare
  • Malformed shapes cannot panic — non-object params, array arguments, missing tool name all return cleanly
  • Explicit null counts as absent and gets filled
  • A partial link injects only what it has, leaving the rest to the server's own defaults

Also checked and found safe: no lock is held across the handle_message await; a tools/call racing an in-flight tools/list simply gets no injection.

On injecting serviceId

Worth stating plainly, since it targets destructive tools. Local MCP already resolves serviceId from the link — 76% of successful remove_service calls, 63% of deploy, 59% of set_variables pass no explicit id. So this preserves existing behavior; omitting it would break the majority of those calls on the flip.

Test plan

  • 14 unit tests covering the above
  • Full suite: 1,143 passed, 0 failed
  • cargo build clean

Expected effect

Beyond unblocking the flip, this should land the remote path below local's current ~10% failure rate: the 1,718/day "no linked environment" failures also disappear, since remote resolves a missing environment to the project's production (local hard-errors instead).

🤖 Generated with Claude Code

codyde and others added 2 commits August 17, 2026 09:55
Prerequisite for making `railway mcp` launch the proxy by default.

The local MCP server resolves projectId/environmentId/serviceId from
`railway link`. The remote server runs on a different machine and never
can, and projectId is a required parameter on every project-scoped remote
tool. Measured on 2026-08-16: ~44% of successful local tool calls pass no
projectId and rely on the directory link — roughly 19,000 calls/day across
list_deployments, get_logs, list_services, list_variables,
environment_status, get_service_config, set_variables, deploy,
list_domains and service_metrics. Flipping the baseline command without
this would break all of them for ~6,759 users/day.

The proxy now reads the link at startup (same source the local server
uses, and the working directory is fixed for the process) and fills in
the parameters a `tools/call` left out.

Conservative on three axes, because a wrong injection is worse than none:

- Only parameters the tool declares. The accepted set is learned from the
  server's own tools/list result rather than a list baked into the CLI
  that would drift as the tool surface changes; a docs or workspace tool
  is untouched.
- Never overwrites a value the caller supplied.
- Nothing at all until tools/list has been seen, or when the directory is
  unlinked. An unknown tool is forwarded exactly as the harness sent it.

emit_json_line / emit_sse_event now return the message they emitted so
the tools/list result can be learned from either transport.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pressure-testing the injection surfaced two gaps against what
`get_linked_project` actually resolves.

RAILWAY_PROJECT_ID / ENVIRONMENT_ID / SERVICE_ID were ignored, so an
invocation targeting a project by env var got no injection at all. Now
covered, and env-var targeting discards a directory link that names a
different project rather than pairing project A with project B's
environment.

Resolving a project from a RAILWAY_TOKEN stays uncovered and is now
documented as such: it costs a GraphQL round trip, and a bad token would
put a 15s connect timeout in front of proxy startup while the harness
waits.

Also adds edge-case coverage: JSON-RPC batches are forwarded untouched
(method_of sees no method on an array — fail-closed and deliberate),
malformed params/arguments cannot panic, an explicit null counts as
absent, and a partial link injects only what it has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codyde codyde added the release/minor Author minor release label Aug 17, 2026
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