feat(upstreams): mount upstream MCP servers as namespaced child processes - #17
feat(upstreams): mount upstream MCP servers as namespaced child processes#17dobrinyonkov wants to merge 5 commits into
Conversation
…sses
Adds an opt-in mounting layer that lets this server spawn another MCP
server as a child over stdio and re-publish its tools, resources, and
prompts under a configurable name prefix. The first registered upstream
is @ui5/webcomponents-react-mcp, exposed under the 'react' prefix —
customers install one MCP and get framework-specific React guidance
through the same connection.
What this changes for users:
- 'npm install @ui5/webcomponents-mcp-server' is enough to get the
React MCP's tools as well; they appear as 'react_*'.
- Native tools keep their existing names. No breaking changes to the
public surface.
- Adding a future framework MCP (Vue, Angular, …) is a one-entry
addition to src/upstreams/registry.ts plus a runtime dependency.
Implementation:
- src/upstreams/registry.ts — UPSTREAMS list (one entry today).
- src/upstreams/mount.ts — spawns each upstream via the SDK's
StdioClientTransport, lists its capabilities, converts JSON Schema
inputs to Zod, and re-registers each item on the parent server.
Resource URI ownership is tracked per mount so reads route to the
right child. Cross-upstream URI collisions fail fast at startup.
- src/upstreams/index.ts — barrel + UpstreamHandle type.
- src/index.ts — declares tools/resources/prompts capabilities,
awaits mountUpstreams() before connecting stdio, and binds child
transport cleanup to SIGINT / SIGTERM / exit so children die with
the parent.
Tests (test/upstreams.test.ts, serial):
- mountUpstream namespaces and forwards tools, resources, and prompts
against a fixture MCP server (test/fixtures/mock_upstream.mjs).
- mountUpstream against the real @ui5/webcomponents-react-mcp child
exposes the expected tools and llms.txt resource.
- mountUpstream throws on resource URI collision across upstreams.
Dependencies:
- Adds @ui5/webcomponents-react-mcp as a runtime dependency (it is
spawned and resolved via require.resolve).
- No other runtime dep changes.
Fixes raised in code review of feat/upstream-mounting:
- Apply prettier formatting (2-space indent per .prettierrc) to
mount.ts, registry.ts, mock_upstream.mjs. Was 4-space; would have
produced noisy diffs on next contributor's auto-format.
- mountUpstreams: detect duplicate prefixes in UPSTREAMS at startup
and throw a clear error. Cheap insurance for when the registry
grows past one entry.
- resolveUpstreamBin: when bin is an object with multiple entries,
prefer the one whose key matches the package basename (e.g.
'webcomponents-react-mcp' for '@ui5/webcomponents-react-mcp').
Falls back to the first entry, but no longer silently picks an
iteration-order-dependent one when a future upstream ships
multiple bins.
- Resource handler: add a comment explaining the verbatim-forward
trust model, matching tool/prompt forwarding.
- promptArgsToZodShape: cite the MCP spec for prompt-args being
string-only, to head off 'should this be typed?' churn.
- shutdown(): comment why markShutdown() must come before
transport.close() — close can fire onclose synchronously and
would otherwise trip the upstream fail-fast handler on a planned
exit.
- _resetForTesting: doc-comment that callers must also build a
fresh McpServer per mount, since this only resets module state.
Build clean, lint clean (no new errors), prettier --check clean,
41/41 tests pass including the 3 new upstream tests.
|
Thanks for the thorough review - all the substantive fixes are in Addressed in this commit
Pushed back on (with reasoning)
Deferred (follow-ups)
Build clean, lint clean (no new errors - the existing |
Correctness:
- Drop process.on('exit', shutdown). The exit handler runs
synchronously and ignores returned promises, so transport.close()
couldn't reliably finish IPC teardown there. SIGINT/SIGTERM cover
explicit teardown; child stdio servers receive SIGPIPE on next
write after the parent exits, which is sufficient cleanup.
- jsonValueSchemaToZod no longer applies .default(). The upstream
is the single source of truth for validation and default-
application — applying defaults twice could mask cases where
undefined is meaningful to the upstream tool. Added a comment
explaining why.
- jsonObjectSchemaToZodShape: cleaner contract — "undefined"
means "omit inputSchema; SDK skips parent-side validation",
which is what we want for type:object schemas with no declared
properties (free-form arg tools).
- mountUpstream: symmetric child cleanup on connect-handshake
failure, matching the post-connect path. No leaked children.
- Upstream client connection now reports own version (read once
from package.json), not '0.0.0'. Helpful in upstream debug logs.
API surface:
- mountUpstreams now accepts an optional 'upstreams' parameter
(defaults to UPSTREAMS) so the duplicate-prefix guard is
testable without exposing module state.
- _resetForTesting re-exported from the upstreams barrel, with a
doc comment marking it test-only. Test imports moved to the
barrel for consistency.
Documentation:
- registerTool cast: clearer rationale (SDK overload depends on
inputSchema presence; we choose dynamically based on upstream).
- Tool name construction: comment near `${prefix}_${tool.name}`
explaining the deliberate double-prefix policy and why we don't
strip a leading match.
Tests (3 → 6, all serial):
- Existing namespacing test now also exercises the richer
schema converter (enum / integer / array / optional with default)
via a second 'format' tool in the mock fixture.
- NEW: mounted tool is callable through the parent server (uses
InMemoryTransport.createLinkedPair to drive the parent end-to-end,
not just the upstream client).
- NEW: empty-capabilities upstream yields empty lists (covers the
capability-gating short-circuit).
- NEW: mountUpstreams throws on duplicate prefixes in the registry.
- Added test/fixtures/mock_empty_upstream.mjs.
- Both fixtures now handle SIGTERM/SIGINT cleanly so a hung test
isn't left lying around.
Lint clean, prettier --check clean, 44/44 tests pass.
|
Round-2 fixes pushed in Correctness fixes (🟡 medium)
Style / conventions (🟡)
Minor (🟢)
New tests (3 → 6)
Verification
|
Convention: ASCII hyphens only. Touches comments and string literals; no behavioral change. Tests still 44/44.
NakataCode
left a comment
There was a problem hiding this comment.
Findings
-
onclosefiresprocess.exit(1)during error-path cleanup, swallowing the error
src/upstreams/mount.ts:159, 168–173After
transport.oncloseis set todefaultOnUpstreamExit(L159), the catch block calls
await transport.close()(L168). The SDK fires the child processcloseevent inside that
await, which triggersonclose → process.exit(1). Thethrow erroron L173 never reaches
the caller. -
McpServerversion hardcoded as'0.0.1'while package is0.1.2
src/index.ts:12The PR introduced
OWN_VERSION/readOwnVersion()inmount.tsto fix the version reported
to child upstreams, but the parent server's own identity advertised to MCP clients was not
updated. -
No pagination for
listTools/listResources/listPrompts
src/upstreams/mount.ts:192, 231, 262All three SDK result types extend
PaginatedResultSchemaand includenextCursor. Only the
first page is fetched. For the current React MCP (5 tools) this is invisible, but any upstream
with more items than the default page size silently drops the rest with no error. -
Source comment incorrectly describes React MCP tool names
src/upstreams/mount.ts:195–196Comment says "the React MCP exports tools like
react_get_component_api" (pre-prefixed).
Verified against the actual package v2.22.2: the React MCP exportsget_component_api,
create_app, etc. with no prefix. The test assertions are correct; the comment is wrong. -
Duplicate import of
_resetForTesting
test/upstreams.test.ts:9–10Two separate import statements from the same module specifier
'../src/upstreams/index.js'.
Should be merged into one.
- mount.ts: detach transport.onclose/onerror before awaiting transport.close() in both connect and registerUpstream catch blocks. The SDK's stdio transport fires onclose synchronously from the child's 'close' event inside close(), so defaultOnUpstreamExit would call process.exit(1) and swallow the thrown error before it could reach the caller. - mount.ts: paginate listTools/listResources/listPrompts via new listAllPages helper. All three SDK list result types expose nextCursor; single-page reads silently drop items past the default page size. - mount.ts: fix misleading naming comment - upstream tools are expected to be unprefixed (React MCP exports get_component_api, not react_get_component_api). - index.ts: parent McpServer version now read from package.json, matching the pattern the PR already uses for the upstream-client identity. Was drifting behind (advertised '0.0.1' while package.json is at 0.1.2). - test/upstreams.test.ts: merge duplicate _resetForTesting import. - test/upstreams.test.ts: add regression test proving onUpstreamExit does NOT fire during error-path cleanup (verified to fail without the fix).
|
Found 2 issues: 1.
|
Summary
Closes: #16
Adds an opt-in upstream-mounting layer so this server can spawn another MCP server as a child over stdio and re-publish its tools, resources, and prompts under a configurable name prefix.
The first registered upstream is
@ui5/webcomponents-react-mcpunder thereactprefix. Net effect for users: one install of@ui5/webcomponents-mcp-servernow also surfaces the React MCP's tools asreact_*. No breaking change to the existing native surface.Why
The Web Components team ships this server. The React team ships their own. Future framework teams (Vue, Angular, …) will ship more. Customers shouldn't need to wire up N servers to get full UI5 webcomponents coverage - they install one MCP and the rest follows.
Adding a future framework MCP becomes a one-entry addition to
src/upstreams/registry.tsplus the new package as a runtime dependency.What changes
src/upstreams/registry.tsUPSTREAMSlist (one entry today, React).src/upstreams/mount.tsStdioClientTransport, lists capabilities, converts JSON Schema → Zod, re-registers tools/resources/prompts on the parent. URI ownership is tracked per mount; cross-upstream collisions fail fast at startup.src/upstreams/index.tsUpstreamHandletype.src/index.tstools/resources/promptscapabilities, awaitsmountUpstreams()before connecting stdio, binds child cleanup to SIGINT/SIGTERM/exit.package.json@ui5/webcomponents-react-mcpas a runtime dependency (spawned viarequire.resolve). No other runtime-dep changes..gitignore.ab/(umbrella for A/B-harness output, follow-up).Tests
3 new serial tests in
test/upstreams.test.ts:test/fixtures/mock_upstream.mjs) and asserts tools, resources, and prompts are namespaced and forwarded with the correct prefix.@ui5/webcomponents-react-mcpchild and asserts the expected tools and thellms.txtresource appear.Local run: 41/41 passing (38 existing + 3 new).
Compatibility
react_*prefix; native names stay as-is.Caveats / known follow-ups
react_react_get_component_api. Cosmetic, deterministic; can be cleaned up in a follow-up.Manual verification