feat(mcp): carry the directory link through the proxy - #1108
Open
codyde wants to merge 2 commits into
Open
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Prerequisite for the RFC step that makes
railway mcplaunch the proxy by default. Companion backboard PR: railwayapp/mono#35787.Problem
The local MCP server resolves
projectId/environmentId/serviceIdfromrailway link. The remote server runs on a different machine and never can — andprojectIdis a required parameter on every project-scoped remote tool.Measured 2026-08-16: ~44% of successful local tool calls pass no
projectIdand rely entirely on the directory link. That's roughly 19,000 calls/day acrosslist_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/callleft out.Conservative on three axes, because a wrong injection is worse than none:
tools/listresult, not a list baked into the CLI that would drift as tools change. A docs or workspace tool is untouched.tools/listhas been seen, or when nothing is linked. An unknown tool is forwarded exactly as sent.emit_json_line/emit_sse_eventnow return the message they emitted, so thetools/listresult 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.propertiesoff the listing the proxy already relays keeps the two in sync for free.Sources covered
get_linked_projectresolves three things; this covers the two that need no I/O:RAILWAY_PROJECT_ID/ENVIRONMENT_ID/SERVICE_IDrailway linkdirectory linkRAILWAY_TOKENPressure-test findings
Adversarial review of this PR turned up and fixed:
method_ofsees no method on a top-level array). Fail-closed and deliberate; batches are vanishingly rareparams, arrayarguments, missing tool name all return cleanlynullcounts as absent and gets filledAlso checked and found safe: no lock is held across the
handle_messageawait; atools/callracing an in-flighttools/listsimply gets no injection.On injecting
serviceIdWorth stating plainly, since it targets destructive tools. Local MCP already resolves
serviceIdfrom the link — 76% of successfulremove_servicecalls, 63% ofdeploy, 59% ofset_variablespass no explicit id. So this preserves existing behavior; omitting it would break the majority of those calls on the flip.Test plan
cargo buildcleanExpected 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