Repository navigation
feat(connector-mcp): a declarative stdio MCP transport runs in the declaring app's root - #22501
Conversation
…ng app's root
The mcp provider factory sets the stdio child's working directory to the
host's ConnectorProviderContext.resolvePackagePath('.'), so an app's
relative command and args resolve against the same root as its other
relative refs instead of the directory the server was started from. A
host without the member keeps today's behaviour (no cwd). McpTransport's
stdio variant gains an optional cwd, passed to the SDK's
StdioClientTransport.
Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ
Co-authored-by: Claude <noreply@anthropic.com>
…r McpTransport Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude <noreply@anthropic.com>
…p-stdio-package-cwd
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c87c050c7eaa6529a0ecb8a8e42ef27d820e755e && git checkout c87c050c7eaa6529a0ecb8a8e42ef27d820e755e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e148ca984258c7c0d162eefd04b1f0f77c3fa9fd f9cf8b4df941d0a0db3fc12406db9cd0d5604605 && git checkout -B drift-repro e148ca984258c7c0d162eefd04b1f0f77c3fa9fd && git merge --no-ff f9cf8b4df941d0a0db3fc12406db9cd0d5604605
node scripts/docs-audit/affected-docs.mjs --json e148ca984258c7c0d162eefd04b1f0f77c3fa9fd |
Contract reviewServed-tier: Inputs: card #22423 (body and all six comments, the triage grade ① Derived judgmentsPublic surface. Behavioural surface, each path.
Declarative Security reading. The working directory is Tests. 6 factory pins ( File list. 5 files: Check-runs on the head (latest per name, read at posting). Green: ② Semver level
Changeset prose against the head, sentence by sentence: the title (runs in the app's root; relative command and args resolve there wherever the server was started) holds for every host that hands ③ Boundary flagsDev deviations (report
Implemented-by: VERDICT: PASS |
Fixes #22423
Clause-②: yes (widening)
What changes
This is the
connector-mcphalf of the split that triage ruled on the card (comment6076155500: "(a), not (b)."). The host half landed in PR #22465:ConnectorProviderContext.resolvePackagePath, whichservice-automationhands to every provider factory, anchored at itspackageRoot.mcp-provider.ts,createMcpProviderFactory. For a stdio transport, thedeclarativeStdiopolicy check runs first, unchanged. Then, when the host providesresolvePackagePath, the factory sets the transport'scwdtoawait ctx.resolvePackagePath('.'). When the host does not provide it, the transport is built exactly as before, with nocwdkey. A resolver that throws propagates as a plain error. It is a configuration fault, fatal at boot, and never upstream-unavailable.mcp-connector.ts. The stdio variant of the exportedMcpTransportgains an optionalcwd.defaultClientFactorypasses it to the MCP SDK'sStdioClientTransport(installed@modelcontextprotocol/sdk1.31.0). The SDK hands it tocross-spawnas the spawncwd. Left undefined, the child inherits the host's directory, as before.normalizeTransportstill reads onlycommand,argsandenvfromproviderConfig.transport, so an authoredcwdthere is never used. A pin below holds that. The declarative accept set is unchanged, and so is thedeclarativeStdiopolicy.new ConnectorMcpPlugin({ transport }),createMcpConnector) get acwdonly if their author passes one.Clause-②: yes (widening):McpTransportis re-exported fromindex.ts, and its stdio variant gains an optional key. The changeset isminorfor@objectstack/connector-mcp.Reproduction: the
os verifydoor (CI's Dogfood Verify CLI command)node packages/cli/bin/run.js verify --app examples/app-showcase/objectstack.config.ts --rls. The CLI and showcase closures were built withpnpm turbo run build --filter=@objectstack/cli... --filter=@objectstack/example-showcase.... Counts are log lines. "materialized" counts[Automation] materialized connector instance 'showcase_mcp_tools' via provider 'mcp' (1 action(s)). "degraded" countsconnector instance 'showcase_mcp_tools' (provider 'mcp') upstream unavailable. Verify boots the composition twice, so a clean run prints 2 materialized lines.Cannot find module .../scripts/mcp-fixture.mjs35ef501e1verify passed35ef501e1examples/app-showcaseverify passedb75c4acfbverify passedb75c4acfb--app)verify passedb75c4acfbexamples/app-showcaseverify passedf9cf8b4df(after mergingorigin/maine148ca984)verify passedWall time per run was 66 to 73 s on the shared box.
Pins
mcp-provider.test.tsresolvePackagePath('.')'.'once;cwdis its answer;commandandargsreach the transport as writtenmcp-provider.test.tsresolvePackagePathcwdkeymcp-provider.test.tsproviderConfig.transport.cwdcwdkeymcp-provider.test.tsmcp-provider.test.tsdeclarativeStdiopolicy firstmcp-provider.test.tsmcp-stdio-cwd.test.ts(real spawns through the SDK, no injected client)node ./scripts/server.mjs, withcwdset to the fixture app, connects; the child reports its cwd as the appErrorCode.ConnectionClosed: the child exits before the handshake./bin/node-here(a symlink to this node) connectscwdset to another directory, and the child reports that directory; launch without it and the child reports the process cwdnodeis still found onPATHLiteKernel+AutomationServicePlugin({ packageRoot })+ConnectorMcpPlugin({ declarativeStdio: ['node'] }), with a declaredprovider: 'mcp'entry whoseargsare relative. The connector registersreadywith its action, and the child runs inpackageRoot. Test body 62 ms when green.The fixture is a dependency-free newline-delimited JSON-RPC MCP server. The test writes it into a fresh
mkdtempdirectory, which is never the test process's cwd, and its one tool reportsprocess.cwd(). No file outsideconnector-mcpis read.Ablations (each committed first, mutated through
scripts/ablation-replace.mjs, restored to a blob equal to HEAD withgit diff HEADempty)All legs ran at
b75c4acfb(the merge brought nothing underconnector-mcp). The subjects are imported relatively fromsrc, so no build ordist/leg applies.cwd(resolver still called)defaultClientFactorydropscwd: transport.cwdnormalizeTransportpasses an authoredcwdthroughmcp-provider.test.tscwd: process.cwd()ConnectorUpstreamUnavailableErrorThe first attempt at C through F was a no-op: each replacement contained its own anchor, so the anchor count did not fall, and the tool refused with exit 1 before running anything. Those readings were discarded, and the legs were re-run with anchors their replacements do not contain. The table shows the re-run.
Local verification (head
f9cf8b4df)pnpm --filter @objectstack/connector-mcp typecheck:tsc --noEmitclean, andcheck:test-typecheck: OK, with the ledger unchanged (1 file, 5 errors, 1 signature). The test program compiles 4 of 4 test files, measured with--listFiles.pnpm --filter @objectstack/connector-mcp test: 4 files, 36 tests passed.pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/showcase-declarative-mcp.dogfood.test.ts: 1 file, 2 tests passed. That test still chdirs into the showcase, so it reads the same as before.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran: 61 derived, 61 run, 0 NOT MEASURED, 0 unrun, every one exit 0. Also run, exit 0: the 11 declared wide-population families, and the 4 roster gates whose roster sits in a directory this diff touches (check-changeset-fixed,check:authz-resolver,check:error-code-casing,check:filter-alias-parity)..tsfiles:eslint --no-inline-config --format jsonreads 4 files, 0 errors, 0 warnings. All 4 fall under thepackages/**config block. The narrowing cannot move a verdict on an untouched file, becauseeslint.config.mjsenables no type-aware linting (--print-configshowsparserOptions{ ecmaVersion, sourceType }, noproject). The repository-widepnpm lintis CI's.packages/cliintegration tier and the rest of the dogfood suite. They are declared to CI.Acceptance notes
examples/app-showcase/src/system/connectors/index.ts, above theshowcase_mcp_toolstransport, says the path "is relative to the app's working directory (os dev/serverun from the app root)". It now resolves against the app root whatever the working directory.examples/**is outside this card's surface. Carrier: none.packages/qa/dogfood/test/showcase-declarative-mcp.dogfood.test.tschdirs into the showcase so that the fixture command resolves. With apackageRoothanded to the automation plugin, that chdir is no longer needed for the MCP half. This was not measured, because the harness'spackageRootwiring was not read. Carrier: none.content/docs/automation/connectors.mdxdocuments{ kind: 'stdio', command, args?, env? }and does not say where a declarative stdio transport's relative paths resolve. Carrier: none, docs lane.PATHentries. APATHentry that is relative (empty or.) is searched from the child's cwd, so for a declarative stdio transport such a lookup now searches the app root instead of the host's directory. No producer was measured.resolvePackagePathdoes not check that the root exists. A spawn whosecwdis missing reportsspawn ... ENOENT, which reads like a missing command. The CLI and serve hosts pass the directory that holdsobjectstack.config.ts, which exists. A host passing a missing root was not measured.providerConfig.transportkeys are dropped silently. That is today's behaviour for every unknown key, and an authoredcwdis one of them (pinned). No producer was measured writing one.Generated by Claude Code