Skip to content

Commit f8566a0

Browse files
feat(connector-mcp): a declarative stdio MCP transport runs in the declaring app's root (#22501)
Fixes #22423 Clause-②: yes (widening) ## What changes This is the `connector-mcp` half of the split that triage ruled on the card (comment `6076155500`: "**(a), not (b).**"). The host half landed in PR #22465: `ConnectorProviderContext.resolvePackagePath`, which `service-automation` hands to every provider factory, anchored at its `packageRoot`. - **`mcp-provider.ts`, `createMcpProviderFactory`.** For a stdio transport, the `declarativeStdio` policy check runs first, unchanged. Then, when the host provides `resolvePackagePath`, the factory sets the transport's `cwd` to `await ctx.resolvePackagePath('.')`. When the host does not provide it, the transport is built exactly as before, with no `cwd` key. 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 exported `McpTransport` gains an optional `cwd`. `defaultClientFactory` passes it to the MCP SDK's `StdioClientTransport` (installed `@modelcontextprotocol/sdk` 1.31.0). The SDK hands it to `cross-spawn` as the spawn `cwd`. Left undefined, the child inherits the host's directory, as before. - **The anchor is the host's alone.** `normalizeTransport` still reads only `command`, `args` and `env` from `providerConfig.transport`, so an authored `cwd` there is never used. A pin below holds that. The declarative accept set is unchanged, and so is the `declarativeStdio` policy. - **Hand-wired transports** (`new ConnectorMcpPlugin({ transport })`, `createMcpConnector`) get a `cwd` only if their author passes one. `Clause-②: yes (widening)`: `McpTransport` is re-exported from `index.ts`, and its stdio variant gains an optional key. The changeset is `minor` for `@objectstack/connector-mcp`. ## Reproduction: the `os verify` door (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 with `pnpm 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" counts `connector instance 'showcase_mcp_tools' (provider 'mcp') upstream unavailable`. Verify boots the composition twice, so a clean run prints 2 materialized lines. | tree | process cwd | materialized | degraded | `Cannot find module .../scripts/mcp-fixture.mjs` | verdict line | |---|---|---|---|---|---| | base `35ef501e1` | repository root | 0 | 6 | 6 (resolved at the repository root) | `verify passed` | | base `35ef501e1` | `examples/app-showcase` | 2 | 0 | 0 | `verify passed` | | `b75c4acfb` | repository root | 2 | 0 | 0 | `verify passed` | | `b75c4acfb` | a scratch directory outside the repository (absolute `--app`) | 2 | 0 | 0 | `verify passed` | | `b75c4acfb` | `examples/app-showcase` | 2 | 0 | 0 | `verify passed` | | head `f9cf8b4df` (after merging `origin/main` `e148ca984`) | repository root | 2 | 0 | 0 | `verify passed` | Wall time per run was 66 to 73 s on the shared box. ## Pins | file | pin | what it holds | |---|---|---| | `mcp-provider.test.ts` | sets the stdio cwd to the host's `resolvePackagePath('.')` | the resolver is asked `'.'` once; `cwd` is its answer; `command` and `args` reach the transport as written | | `mcp-provider.test.ts` | keeps the transport exactly as before without `resolvePackagePath` | the transport has no `cwd` key | | `mcp-provider.test.ts` | never honours an authored `providerConfig.transport.cwd` | with a host the cwd is the host's answer; without one there is no `cwd` key | | `mcp-provider.test.ts` | does not consult the resolver for an http transport | the resolver is not called | | `mcp-provider.test.ts` | judges the `declarativeStdio` policy first | a denied command is rejected and the resolver is never called | | `mcp-provider.test.ts` | a resolver failure is a configuration fault | a plain error, not upstream-unavailable, and no client built | | `mcp-stdio-cwd.test.ts` (real spawns through the SDK, no injected client) | a relative script arg resolves against the given cwd | `node ./scripts/server.mjs`, with `cwd` set to the fixture app, connects; the child reports its cwd as the app | | same | the same relative arg with no cwd | rejects with `ErrorCode.ConnectionClosed`: the child exits before the handshake | | same | a relative command path resolves against the given cwd | `./bin/node-here` (a symlink to this node) connects | | same | control: an absolute command and an absolute script | launch with `cwd` set to another directory, and the child reports that directory; launch without it and the child reports the process cwd | | same | control: a bare executable with a cwd | `node` is still found on `PATH` | | same | a declarative instance booted from another directory | `LiteKernel` + `AutomationServicePlugin({ packageRoot })` + `ConnectorMcpPlugin({ declarativeStdio: ['node'] })`, with a declared `provider: 'mcp'` entry whose `args` are relative. The connector registers `ready` with its action, and the child runs in `packageRoot`. 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 `mkdtemp` directory, which is never the test process's cwd, and its one tool reports `process.cwd()`. No file outside `connector-mcp` is read. ## Ablations (each committed first, mutated through `scripts/ablation-replace.mjs`, restored to a blob equal to HEAD with `git diff HEAD` empty) All legs ran at `b75c4acfb` (the merge brought nothing under `connector-mcp`). The subjects are imported relatively from `src`, so no build or `dist/` leg applies. | leg | mutation | red | still green | |---|---|---|---| | A | factory sets no `cwd` (resolver still called) | sets-cwd, authored-cwd (with-host leg), boot from another directory | 23 others | | B | `defaultClientFactory` drops `cwd: transport.cwd` | relative arg, relative command, both controls (they launch, but report the process cwd), boot from another directory | 21 others | | C | `normalizeTransport` passes an authored `cwd` through | authored-cwd | 18 others in `mcp-provider.test.ts` | | D | no resolver falls back to `cwd: process.cwd()` | no-resolver, authored-cwd | 17 others | | E | resolver called before the policy check | policy-first, plus sets-cwd (the resolver is asked twice) | 17 others | | F | resolver called for an http transport | http | 18 others | | G | a resolver failure rethrown as `ConnectorUpstreamUnavailableError` | resolver-failure | 18 others | The 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 --noEmit` clean, and `check: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`). - Lint, narrowed to the 4 changed `.ts` files: `eslint --no-inline-config --format json` reads 4 files, 0 errors, 0 warnings. All 4 fall under the `packages/**` config block. The narrowing cannot move a verdict on an untouched file, because `eslint.config.mjs` enables no type-aware linting (`--print-config` shows `parserOptions` `{ ecmaVersion, sourceType }`, no `project`). The repository-wide `pnpm lint` is CI's. - Not run locally: the `packages/cli` integration tier and the rest of the dogfood suite. They are declared to CI. ## Acceptance notes - **A showcase comment goes stale.** `examples/app-showcase/src/system/connectors/index.ts`, above the `showcase_mcp_tools` transport, says the path "is relative to the app's working directory (`os dev`/`serve` run from the app root)". It now resolves against the app root whatever the working directory. `examples/**` is outside this card's surface. Carrier: none. - **A dogfood workaround becomes unnecessary.** `packages/qa/dogfood/test/showcase-declarative-mcp.dogfood.test.ts` chdirs into the showcase so that the fixture command resolves. With a `packageRoot` handed to the automation plugin, that chdir is no longer needed for the MCP half. This was not measured, because the harness's `packageRoot` wiring was not read. Carrier: none. - **The docs say nothing about where a relative path resolves.** `content/docs/automation/connectors.mdx` documents `{ kind: 'stdio', command, args?, env? }` and does not say where a declarative stdio transport's relative paths resolve. Carrier: none, docs lane. - **Boundary: relative `PATH` entries.** A `PATH` entry 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. - **Boundary: a root that does not exist.** `resolvePackagePath` does not check that the root exists. A spawn whose `cwd` is missing reports `spawn ... ENOENT`, which reads like a missing command. The CLI and serve hosts pass the directory that holds `objectstack.config.ts`, which exists. A host passing a missing root was not measured. - **Unchanged: unknown `providerConfig.transport` keys are dropped silently.** That is today's behaviour for every unknown key, and an authored `cwd` is one of them (pinned). No producer was measured writing one. - Windows was not measured; CI runs Linux. --- _Generated by [Claude Code](https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 94379f4 commit f8566a0

5 files changed

Lines changed: 311 additions & 1 deletion

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/connector-mcp": minor
3+
---
4+
5+
feat(connector-mcp): a declarative stdio MCP transport runs in the declaring app's root, so its relative command and args resolve there wherever the server was started
6+
7+
Clause-②: yes (widening)
8+
9+
- **What changes.** For a declarative `provider: 'mcp'` connector with a stdio transport, the `mcp` provider factory now sets the child process's working directory to the app's root. It reads that root from the host through `ConnectorProviderContext.resolvePackagePath('.')`. Relative paths in the entry now resolve against the same root as the app's other relative refs, such as the `openapi` provider's `providerConfig.spec`. That covers a relative `command` path (`./bin/server`) and a script the launched program opens itself (`args: ['./scripts/server.mjs']` with `command: 'node'`). Before this change they resolved against the directory the server was started from. A boot from any other directory, such as `os verify --app examples/app-showcase/objectstack.config.ts` from the repository root, therefore registered the connector degraded, with no actions.
10+
- **What does not change.** A bare executable (`node`, `npx`) is still looked up on `PATH`, and an absolute command or argument resolves as before. The `declarativeStdio` policy judges the `command` string exactly as written, and it runs before the root is resolved. `providerConfig.transport` gains no key: the working directory comes from the host, and an authored `cwd` there is not used. A host that does not provide `resolvePackagePath` starts the child exactly as before, in the host's current directory. A server that opens relative files of its own now opens them under the app's root.
11+
- **New on the exported type.** The stdio variant of `McpTransport` gains an optional `cwd`, which the default client passes to the MCP SDK's `StdioClientTransport`. A hand-wired transport (`new ConnectorMcpPlugin({ transport })` or `createMcpConnector`) may set it. Left out, the child inherits the host's current directory, as before.
12+
- **Nothing to migrate.** An app that started its server from its own directory sees no difference, because both anchors were the same directory.

‎packages/connectors/connector-mcp/src/mcp-connector.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,21 @@ export type McpTransport =
3939
args?: string[];
4040
/** Environment variables for the child process — carries credentials. */
4141
env?: Record<string, string>;
42+
/**
43+
* Working directory of the child process. A relative `command` path
44+
* (one with a path separator, e.g. `./bin/server`) resolves against
45+
* it, and so does a relative path the launched program resolves
46+
* itself, such as the script in `node ./server.mjs`. A bare
47+
* executable name (`node`, `npx`) is still looked up on `PATH`, and an
48+
* absolute path is unaffected. Omitted, the child inherits the host
49+
* process's current directory.
50+
*
51+
* The `mcp` provider factory sets it for a **declarative** instance to
52+
* the root of the app that declared it (ADR-0097), so one app's
53+
* relative paths resolve against one anchor wherever the server was
54+
* started. A hand-wired transport keeps whatever its author passes.
55+
*/
56+
cwd?: string;
4257
}
4358
| {
4459
kind: 'http';
@@ -174,6 +189,8 @@ async function defaultClientFactory(
174189
command: transport.command,
175190
args: transport.args,
176191
env: transport.env,
192+
// `undefined` inherits the host's current directory, as before.
193+
cwd: transport.cwd,
177194
}),
178195
);
179196
} else {

‎packages/connectors/connector-mcp/src/mcp-provider.test.ts‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,110 @@ describe('mcp provider declarative stdio policy (#3055)', () => {
191191
expect(mat.def.name).toBe('github');
192192
});
193193
});
194+
195+
// ── #22423 — a declarative stdio transport runs in the declaring app's root ──
196+
//
197+
// One anchor for an app's relative paths: the host's
198+
// `ConnectorProviderContext.resolvePackagePath('.')` (the same root the
199+
// `openapi` provider's `providerConfig.spec` is read under) becomes the stdio
200+
// child's working directory. The anchor is the host's alone; an authored
201+
// `providerConfig.transport.cwd` never reaches the transport. A host without
202+
// the member gets the transport exactly as before: no `cwd` key at all.
203+
204+
describe('mcp provider: declarative stdio working directory (#22423)', () => {
205+
const ROOT = '/srv/apps/my-app';
206+
const stdioCfg = { transport: { kind: 'stdio', command: 'node', args: ['./scripts/server.mjs'] } };
207+
208+
function recordingResolver(answer = ROOT) {
209+
const asked: string[] = [];
210+
const resolvePackagePath = async (relativePath: string) => {
211+
asked.push(relativePath);
212+
return answer;
213+
};
214+
return { resolvePackagePath, asked };
215+
}
216+
217+
it("sets the stdio cwd to the host's resolvePackagePath('.'), leaving command and args as written", async () => {
218+
const { factory: clientFactory, seen } = fakeClientFactory();
219+
const { resolvePackagePath, asked } = recordingResolver();
220+
const factory = createMcpProviderFactory({ clientFactory, declarativeStdio: ['node'] });
221+
222+
await factory(ctx({ providerConfig: stdioCfg, resolvePackagePath }));
223+
224+
expect(asked).toEqual(['.']);
225+
const t = seen.transport;
226+
expect(t?.kind).toBe('stdio');
227+
if (t?.kind !== 'stdio') return;
228+
expect(t.cwd).toBe(ROOT);
229+
expect(t.command).toBe('node');
230+
expect(t.args).toEqual(['./scripts/server.mjs']);
231+
});
232+
233+
it('keeps the transport exactly as before when the host provides no resolvePackagePath', async () => {
234+
const { factory: clientFactory, seen } = fakeClientFactory();
235+
const factory = createMcpProviderFactory({ clientFactory, declarativeStdio: ['node'] });
236+
237+
await factory(ctx({ providerConfig: stdioCfg }));
238+
239+
expect(seen.transport?.kind).toBe('stdio');
240+
expect(Object.keys(seen.transport ?? {})).not.toContain('cwd');
241+
});
242+
243+
it('never honours an authored providerConfig.transport.cwd: the anchor is the host\'s', async () => {
244+
const authored = { transport: { ...stdioCfg.transport, cwd: '/somewhere/else' } };
245+
246+
const withHost = fakeClientFactory();
247+
const { resolvePackagePath } = recordingResolver();
248+
await createMcpProviderFactory({ clientFactory: withHost.factory, declarativeStdio: ['node'] })(
249+
ctx({ providerConfig: authored, resolvePackagePath }),
250+
);
251+
expect(withHost.seen.transport?.kind === 'stdio' && withHost.seen.transport.cwd).toBe(ROOT);
252+
253+
const withoutHost = fakeClientFactory();
254+
await createMcpProviderFactory({ clientFactory: withoutHost.factory, declarativeStdio: ['node'] })(
255+
ctx({ providerConfig: authored }),
256+
);
257+
expect(Object.keys(withoutHost.seen.transport ?? {})).not.toContain('cwd');
258+
});
259+
260+
it('does not consult the resolver for an http transport', async () => {
261+
const { factory: clientFactory, seen } = fakeClientFactory();
262+
const { resolvePackagePath, asked } = recordingResolver();
263+
const factory = createMcpProviderFactory({ clientFactory });
264+
265+
await factory(
266+
ctx({ providerConfig: { transport: { kind: 'http', url: 'https://mcp.example.com' } }, resolvePackagePath }),
267+
);
268+
269+
expect(asked).toEqual([]);
270+
expect(Object.keys(seen.transport ?? {})).not.toContain('cwd');
271+
});
272+
273+
it('judges the declarativeStdio policy first: a denied command never reaches the resolver', async () => {
274+
const { factory: clientFactory, seen } = fakeClientFactory();
275+
const { resolvePackagePath, asked } = recordingResolver();
276+
const factory = createMcpProviderFactory({ clientFactory }); // default deny
277+
278+
await expect(factory(ctx({ providerConfig: stdioCfg, resolvePackagePath }))).rejects.toThrow(
279+
/stdio transports are disabled by default/,
280+
);
281+
expect(asked).toEqual([]);
282+
expect(seen.transport).toBeUndefined();
283+
});
284+
285+
it('treats a resolver failure as a configuration fault (plain, not upstream-unavailable), before any connection', async () => {
286+
const { factory: clientFactory, seen } = fakeClientFactory();
287+
const factory = createMcpProviderFactory({ clientFactory, declarativeStdio: ['node'] });
288+
const resolvePackagePath = async (): Promise<string> => {
289+
throw new Error('host cannot resolve paths here');
290+
};
291+
292+
const err: unknown = await Promise.resolve(factory(ctx({ providerConfig: stdioCfg, resolvePackagePath }))).catch(
293+
(e: unknown) => e,
294+
);
295+
296+
expect(err).toBeInstanceOf(Error);
297+
expect(isConnectorUpstreamUnavailable(err)).toBe(false);
298+
expect(seen.transport).toBeUndefined();
299+
});
300+
});

‎packages/connectors/connector-mcp/src/mcp-provider.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,18 @@ function assertDeclarativeStdioAllowed(
162162
* Stdio transports on declarative instances are policy-gated (default deny) —
163163
* see {@link McpDeclarativeStdioPolicy} (#3055).
164164
*
165+
* A declarative stdio transport runs **in the declaring app's root** (#22423):
166+
* when the host hands `ConnectorProviderContext.resolvePackagePath`, the
167+
* child's working directory is `resolvePackagePath('.')`, so the app's relative
168+
* `command` and `args` resolve against the same anchor as its other relative
169+
* refs (the `openapi` provider's `providerConfig.spec`, read through
170+
* `loadPackageFile`), not against the directory the server was started from.
171+
* The anchor is the host's: `providerConfig.transport` carries no working
172+
* directory, and an author cannot write one. A host without the member keeps
173+
* the previous behaviour — no `cwd`, so the child inherits the host's current
174+
* directory. The policy check runs first and is unchanged: it judges the
175+
* `command` string exactly as written.
176+
*
165177
* The connection is opened at materialization. Faults are classified (#3017):
166178
* an invalid transport shape is a *configuration* fault and throws plain —
167179
* fatal at boot per the ADR-0097 fail-loud contract — while a connect /
@@ -173,9 +185,15 @@ function assertDeclarativeStdioAllowed(
173185
export function createMcpProviderFactory(deps: McpProviderDeps = {}): ConnectorProviderFactory {
174186
return async (ctx) => {
175187
const cfg = (ctx.providerConfig ?? {}) as McpProviderConfig;
176-
const transport = normalizeTransport(cfg.transport, ctx.name, ctx.auth);
188+
let transport = normalizeTransport(cfg.transport, ctx.name, ctx.auth);
177189
if (transport.kind === 'stdio') {
178190
assertDeclarativeStdioAllowed(deps.declarativeStdio, transport.command, ctx.name);
191+
// One anchor for the app's relative paths (#22423). A resolver that
192+
// throws is a host fault: it propagates plain, a configuration error
193+
// (fatal at boot), never upstream-unavailable.
194+
if (ctx.resolvePackagePath) {
195+
transport = { ...transport, cwd: await ctx.resolvePackagePath('.') };
196+
}
179197
}
180198
const includeList = Array.isArray(cfg.include)
181199
? cfg.include.filter((x): x is string => typeof x === 'string')
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #22423 — the stdio transport's working directory, measured on REAL spawns.
4+
//
5+
// Nothing here injects a client: `createMcpConnector` without a `clientFactory`
6+
// runs the SDK-backed default, which launches the child through the MCP SDK's
7+
// `StdioClientTransport`. The server is a dependency-free fixture written into a
8+
// fresh temp directory that is never this process's own cwd, so a relative path
9+
// resolved against the wrong directory fails to launch. The fixture reports the
10+
// directory it really runs in as its one tool's description, so each case reads
11+
// the child's cwd back instead of inferring it.
12+
//
13+
// The last block boots the real composition seam: the automation service's
14+
// declarative materializer (anchored at `packageRoot`) → the `mcp` provider
15+
// factory → the stdio transport, from a process whose cwd is not the app's.
16+
17+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
18+
import { tmpdir } from 'node:os';
19+
import { join } from 'node:path';
20+
import { afterAll, describe, expect, it } from 'vitest';
21+
import { ErrorCode } from '@modelcontextprotocol/sdk/types.js';
22+
import { LiteKernel } from '@objectstack/core';
23+
import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation';
24+
import { ConnectorMcpPlugin } from './connector-mcp-plugin.js';
25+
import { createMcpConnector, type McpConnectorBundle, type McpTransport } from './mcp-connector.js';
26+
27+
/** A minimal MCP stdio server: newline-delimited JSON-RPC, no imports beyond node. */
28+
const FIXTURE_SERVER = `
29+
import { createInterface } from 'node:readline';
30+
const send = (m) => process.stdout.write(JSON.stringify(m) + '\\n');
31+
createInterface({ input: process.stdin }).on('line', (line) => {
32+
if (!line.trim()) return;
33+
const msg = JSON.parse(line);
34+
if (msg.id === undefined) return; // a notification
35+
if (msg.method === 'initialize') {
36+
send({ jsonrpc: '2.0', id: msg.id, result: {
37+
protocolVersion: msg.params.protocolVersion,
38+
capabilities: { tools: {} },
39+
serverInfo: { name: 'cwd-fixture', version: '1.0.0' },
40+
} });
41+
} else if (msg.method === 'tools/list') {
42+
send({ jsonrpc: '2.0', id: msg.id, result: { tools: [
43+
{ name: 'where_am_i', description: process.cwd(), inputSchema: { type: 'object' } },
44+
] } });
45+
} else {
46+
send({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'method not found' } });
47+
}
48+
});
49+
`;
50+
51+
// realpath: the child's process.cwd() reports the resolved directory.
52+
const scratch = realpathSync(mkdtempSync(join(tmpdir(), 'connector-mcp-cwd-')));
53+
/** The "app": holds the server script and a relative launcher. */
54+
const APP = join(scratch, 'app');
55+
/** A second directory that holds nothing — a cwd for the absolute-path controls. */
56+
const ELSEWHERE = join(scratch, 'elsewhere');
57+
mkdirSync(join(APP, 'scripts'), { recursive: true });
58+
mkdirSync(ELSEWHERE);
59+
writeFileSync(join(APP, 'scripts', 'server.mjs'), FIXTURE_SERVER);
60+
// A relative COMMAND: `./bin/node-here` is this very node binary.
61+
mkdirSync(join(APP, 'bin'));
62+
symlinkSync(process.execPath, join(APP, 'bin', 'node-here'));
63+
64+
afterAll(() => {
65+
rmSync(scratch, { recursive: true, force: true });
66+
});
67+
68+
/** Connect with the real SDK client, read the child's cwd, and tear down. */
69+
async function childCwd(transport: McpTransport): Promise<string | undefined> {
70+
let bundle: McpConnectorBundle | undefined;
71+
try {
72+
bundle = await createMcpConnector({ name: 'cwd_probe', transport });
73+
expect(bundle.def.actions?.map((a) => a.key)).toEqual(['where_am_i']);
74+
return bundle.def.actions?.[0]?.description;
75+
} finally {
76+
await bundle?.close();
77+
}
78+
}
79+
80+
describe('stdio transport cwd — real spawns through the SDK (#22423)', () => {
81+
it('the fixture directory is not this process cwd (otherwise nothing below discriminates)', () => {
82+
expect(realpathSync(process.cwd())).not.toBe(APP);
83+
});
84+
85+
it('a relative script arg resolves against the given cwd', async () => {
86+
await expect(
87+
childCwd({ kind: 'stdio', command: 'node', args: ['./scripts/server.mjs'], cwd: APP }),
88+
).resolves.toBe(APP);
89+
});
90+
91+
it('the same relative arg with no cwd resolves against the host cwd: the child exits before the handshake', async () => {
92+
await expect(
93+
childCwd({ kind: 'stdio', command: 'node', args: ['./scripts/server.mjs'] }),
94+
).rejects.toMatchObject({ code: ErrorCode.ConnectionClosed });
95+
});
96+
97+
it('a relative command path resolves against the given cwd', async () => {
98+
await expect(
99+
childCwd({ kind: 'stdio', command: './bin/node-here', args: ['./scripts/server.mjs'], cwd: APP }),
100+
).resolves.toBe(APP);
101+
});
102+
103+
it('control: an absolute command and an absolute script are unaffected by cwd', async () => {
104+
const absolute = { kind: 'stdio' as const, command: process.execPath, args: [join(APP, 'scripts', 'server.mjs')] };
105+
await expect(childCwd({ ...absolute, cwd: ELSEWHERE })).resolves.toBe(ELSEWHERE);
106+
await expect(childCwd({ ...absolute })).resolves.toBe(realpathSync(process.cwd()));
107+
});
108+
109+
it('control: a bare executable still resolves through PATH when a cwd is given', async () => {
110+
await expect(
111+
childCwd({ kind: 'stdio', command: 'node', args: [join(APP, 'scripts', 'server.mjs')], cwd: ELSEWHERE }),
112+
).resolves.toBe(ELSEWHERE);
113+
});
114+
});
115+
116+
describe('declarative mcp instance booted from another directory (#22423)', () => {
117+
it("registers its actions, the stdio child running in the automation service's packageRoot", async () => {
118+
const declared = [
119+
{
120+
name: 'cwd_fixture',
121+
label: 'Cwd Fixture',
122+
type: 'api',
123+
provider: 'mcp',
124+
providerConfig: {
125+
transport: { kind: 'stdio', command: 'node', args: ['./scripts/server.mjs'] },
126+
},
127+
},
128+
];
129+
const kernel = new LiteKernel({ logger: { level: 'silent' } } as never);
130+
kernel.use(new AutomationServicePlugin({ packageRoot: APP }));
131+
kernel.use({
132+
name: 'test.connector-metadata',
133+
type: 'standard',
134+
version: '1.0.0',
135+
dependencies: ['com.objectstack.service-automation'],
136+
async init(ctx: { registerService(name: string, service: unknown): void }) {
137+
ctx.registerService('objectql', {
138+
registry: { listItems: (type: string) => (type === 'connector' ? declared : []) },
139+
});
140+
},
141+
async start() {},
142+
} as never);
143+
kernel.use(new ConnectorMcpPlugin({ declarativeStdio: ['node'] }));
144+
try {
145+
await kernel.bootstrap();
146+
const engine = kernel.getService('automation') as AutomationEngine;
147+
expect(engine.getConnectorDegradedReason('cwd_fixture')).toBeUndefined();
148+
const descriptor = engine.getConnectorDescriptors().find((d) => d.name === 'cwd_fixture');
149+
expect(descriptor?.state).toBe('ready');
150+
expect(descriptor?.actions.map((a) => a.key)).toEqual(['where_am_i']);
151+
expect(descriptor?.actions[0]?.description).toBe(APP);
152+
} finally {
153+
await kernel.shutdown();
154+
}
155+
});
156+
});

0 commit comments

Comments
 (0)