refactor: bind tool and resource invocations to the server that received them - #242
refactor: bind tool and resource invocations to the server that received them#242Valiunia wants to merge 2 commits into
Conversation
BaseTool stored its target McpServer in a single mutable instance field assigned by installTo(), and the callback registered with the SDK read that field at call time instead of capturing the server it was registered on. The tool instances exported from @mapbox/mcp-server/tools are module-level singletons, so installing one instance into a second McpServer overwrote the field and redirected the first server's callbacks to the second — affecting logging, sampling in ground_location_tool, and elicitations in search_and_geocode_tool and directions_tool. Each invocation now resolves its server through an AsyncLocalStorage scope entered by the registered callback, so concurrent calls arriving through different servers each observe their own. this.server remains as a fallback for run() called outside a registered callback, which keeps existing direct-invocation callers and their tests working unchanged. Single-server applications, including the server this package ships, are unaffected: both lookups resolve to the same object. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BaseResource had the same shared-server-field pattern as BaseTool: the handlers registered by installTo() read a mutable this.server at call time rather than capturing the server they were registered on, and the instances in ALL_RESOURCES are module-level singletons. Installing one into a second McpServer redirected the first server's handlers. Only log() read the field, so the effect was misdirected log messages rather than misdirected client interaction, but the hazard is the same and resources are part of the published @mapbox/mcp-server/resources export. Applies the same per-invocation AsyncLocalStorage binding, with this.server retained as the fallback for direct read() calls. Both registration paths (plain URI and ResourceTemplate) are covered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Nice fix. This is a real footgun in the published /tools and /resources exports and AsyncLocalStorage is the right tool for it. Went through this locally: tsc clean, eslint clean on the changed files, full suite is 919/920 (the one failure is the pre-existing urlSafety.test.ts issue on main, unrelated to this). I also reverted the BaseTool.ts wrapper back to the old form just to make sure the new regression tests actually catch the bug and not just pass trivially, they do, fails with "expected 'b' to be 'a'" like the PR description says. Two things worth a comment, neither should block merging:
Good to merge as is. |
What changed
BaseToolandBaseResourceeach stored the MCP server they were installed into in a single mutable instance field (this.server, assigned byinstallTo()), and the callbacks they registered with the SDK read that field at call time rather than capturing the server they were registered on. Both now resolve the server per invocation through anAsyncLocalStoragescope entered by the registered callback, exposed as a newactiveServeraccessor.Readers updated to use it:
BaseTool.log(), and the three tools that communicate with the client mid-request —search_and_geocode_toolanddirections_tool(elicitations),ground_location_tool(sampling).BaseResource.log(). Both of its registration paths, plain URI andResourceTemplate, enter the scope.BasePromptneeds no change — it holds no server reference and is stateless.registerOptimizationV2Task()already takes its server as a parameter and closes over it correctly.Why
The pre-configured instances exported from
@mapbox/mcp-server/toolsand@mapbox/mcp-server/resourcesare module-level singletons:getCoreTools()andgetAllResources()return the same objects on every call. An application that installs one of those instances into more than oneMcpServerin a single process — for example one server per connected session — hits last-install-wins behavior. The secondinstallTo()overwrites the shared field, and a call arriving through the callback registered on the first server dereferences that field and talks to the second.For resources the effect is misdirected log messages. For tools it is more visible: an elicitation prompt is delivered to a different client than the one that made the request, and that client's answer determines the result the original caller receives.
Nothing in this repository does that today. The server shipped by the package creates one
McpServerper process against a single stdio client (src/index.ts:97,265), and the documented import example installs into a single server, so there is no behavior change for any usage we ship or document. But both subpaths are published exports and a server-per-session process is a reasonable way to build on them, so the shared field is a footgun worth removing. The failure mode is silent — nothing throws, the call simply resolves against the wrong client — which is exactly the kind of thing that survives review if it ever does get introduced.Approach
this.serveris deliberately retained as a fallback forrun()/read()invoked directly rather than through a registered callback. That keeps the existing direct-invocation API and its tests working untouched (the suite calls.run()directly in ~490 places, and several tests inject a mock by assigning the field). The tradeoff is that direct callers sharing one instance across servers still observe the last-installed server; the code comments say so. Passing the server explicitly throughrun()would cover that case too, but it changes the signature of all 20 implementations and every mock-injection site, for a path that has no MCP callback to bind to in the first place.The ~12-line binding pattern is duplicated across the two base classes rather than extracted into a shared helper. They are otherwise independent hierarchies with different registration shapes and different handler signatures, and a shared mixin seemed like more indirection than the duplication costs. Happy to extract it if reviewers disagree.
How to verify
npm test— 920 tests pass. Seven new cases acrosstest/tools/BaseTool.test.tsandtest/resources/BaseResource.test.ts:await, so a binding that only holds until the first suspension point failsrun()/read()falls back to the installed serverTo confirm these are genuine regression tests, revert either callback wrapper to its previous form and re-run — the first two in each file fail with
expected 'b' to be 'a'. I checked this for both classes.Also verified:
tsc --noEmitclean,eslintclean on changed files, tshy ESM/CJS build succeeds.Notes for review
AsyncLocalStorageis Node core, so no new dependency, and it adds a per-invocation context cost that is negligible next to the outbound HTTP call these tools make.@opentelemetry/context-async-hooks, already in the dependency tree, uses the same mechanism to propagate spans.awaitchain, the lookup returnsundefinedand the fallback yields today's behavior, so the failure mode is no worse than the current state.as anyfor the fake-server scaffolding, consistent with the mock style already inBaseTool.test.ts.🤖 Generated with Claude Code