From 418a8fe04c78572384ffa8a791ee68f37bd4f0b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 14:28:50 +0000 Subject: [PATCH 1/2] feat(connector-mcp): run a declarative stdio transport in the declaring 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 --- .changeset/22423-mcp-stdio-package-cwd.md | 12 ++ .../connector-mcp/src/mcp-connector.ts | 17 ++ .../connector-mcp/src/mcp-provider.test.ts | 107 ++++++++++++ .../connector-mcp/src/mcp-provider.ts | 20 ++- .../connector-mcp/src/mcp-stdio-cwd.test.ts | 156 ++++++++++++++++++ 5 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 .changeset/22423-mcp-stdio-package-cwd.md create mode 100644 packages/connectors/connector-mcp/src/mcp-stdio-cwd.test.ts diff --git a/.changeset/22423-mcp-stdio-package-cwd.md b/.changeset/22423-mcp-stdio-package-cwd.md new file mode 100644 index 00000000000..93428bc947a --- /dev/null +++ b/.changeset/22423-mcp-stdio-package-cwd.md @@ -0,0 +1,12 @@ +--- +"@objectstack/connector-mcp": minor +--- + +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 + +Clause-②: yes (widening) + +- **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. +- **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. +- **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. +- **Nothing to migrate.** An app that started its server from its own directory sees no difference, because both anchors were the same directory. diff --git a/packages/connectors/connector-mcp/src/mcp-connector.ts b/packages/connectors/connector-mcp/src/mcp-connector.ts index 345e5764a56..d2f3ae9e627 100644 --- a/packages/connectors/connector-mcp/src/mcp-connector.ts +++ b/packages/connectors/connector-mcp/src/mcp-connector.ts @@ -39,6 +39,21 @@ export type McpTransport = args?: string[]; /** Environment variables for the child process — carries credentials. */ env?: Record; + /** + * Working directory of the child process. A relative `command` path + * (one with a path separator, e.g. `./bin/server`) resolves against + * it, and so does a relative path the launched program resolves + * itself, such as the script in `node ./server.mjs`. A bare + * executable name (`node`, `npx`) is still looked up on `PATH`, and an + * absolute path is unaffected. Omitted, the child inherits the host + * process's current directory. + * + * The `mcp` provider factory sets it for a **declarative** instance to + * the root of the app that declared it (ADR-0097), so one app's + * relative paths resolve against one anchor wherever the server was + * started. A hand-wired transport keeps whatever its author passes. + */ + cwd?: string; } | { kind: 'http'; @@ -174,6 +189,8 @@ async function defaultClientFactory( command: transport.command, args: transport.args, env: transport.env, + // `undefined` inherits the host's current directory, as before. + cwd: transport.cwd, }), ); } else { diff --git a/packages/connectors/connector-mcp/src/mcp-provider.test.ts b/packages/connectors/connector-mcp/src/mcp-provider.test.ts index ccb27bca929..20b4206e18c 100644 --- a/packages/connectors/connector-mcp/src/mcp-provider.test.ts +++ b/packages/connectors/connector-mcp/src/mcp-provider.test.ts @@ -191,3 +191,110 @@ describe('mcp provider declarative stdio policy (#3055)', () => { expect(mat.def.name).toBe('github'); }); }); + +// ── #22423 — a declarative stdio transport runs in the declaring app's root ── +// +// One anchor for an app's relative paths: the host's +// `ConnectorProviderContext.resolvePackagePath('.')` (the same root the +// `openapi` provider's `providerConfig.spec` is read under) becomes the stdio +// child's working directory. The anchor is the host's alone; an authored +// `providerConfig.transport.cwd` never reaches the transport. A host without +// the member gets the transport exactly as before: no `cwd` key at all. + +describe('mcp provider: declarative stdio working directory (#22423)', () => { + const ROOT = '/srv/apps/my-app'; + const stdioCfg = { transport: { kind: 'stdio', command: 'node', args: ['./scripts/server.mjs'] } }; + + function recordingResolver(answer = ROOT) { + const asked: string[] = []; + const resolvePackagePath = async (relativePath: string) => { + asked.push(relativePath); + return answer; + }; + return { resolvePackagePath, asked }; + } + + it("sets the stdio cwd to the host's resolvePackagePath('.'), leaving command and args as written", async () => { + const { factory: clientFactory, seen } = fakeClientFactory(); + const { resolvePackagePath, asked } = recordingResolver(); + const factory = createMcpProviderFactory({ clientFactory, declarativeStdio: ['node'] }); + + await factory(ctx({ providerConfig: stdioCfg, resolvePackagePath })); + + expect(asked).toEqual(['.']); + const t = seen.transport; + expect(t?.kind).toBe('stdio'); + if (t?.kind !== 'stdio') return; + expect(t.cwd).toBe(ROOT); + expect(t.command).toBe('node'); + expect(t.args).toEqual(['./scripts/server.mjs']); + }); + + it('keeps the transport exactly as before when the host provides no resolvePackagePath', async () => { + const { factory: clientFactory, seen } = fakeClientFactory(); + const factory = createMcpProviderFactory({ clientFactory, declarativeStdio: ['node'] }); + + await factory(ctx({ providerConfig: stdioCfg })); + + expect(seen.transport?.kind).toBe('stdio'); + expect(Object.keys(seen.transport ?? {})).not.toContain('cwd'); + }); + + it('never honours an authored providerConfig.transport.cwd: the anchor is the host\'s', async () => { + const authored = { transport: { ...stdioCfg.transport, cwd: '/somewhere/else' } }; + + const withHost = fakeClientFactory(); + const { resolvePackagePath } = recordingResolver(); + await createMcpProviderFactory({ clientFactory: withHost.factory, declarativeStdio: ['node'] })( + ctx({ providerConfig: authored, resolvePackagePath }), + ); + expect(withHost.seen.transport?.kind === 'stdio' && withHost.seen.transport.cwd).toBe(ROOT); + + const withoutHost = fakeClientFactory(); + await createMcpProviderFactory({ clientFactory: withoutHost.factory, declarativeStdio: ['node'] })( + ctx({ providerConfig: authored }), + ); + expect(Object.keys(withoutHost.seen.transport ?? {})).not.toContain('cwd'); + }); + + it('does not consult the resolver for an http transport', async () => { + const { factory: clientFactory, seen } = fakeClientFactory(); + const { resolvePackagePath, asked } = recordingResolver(); + const factory = createMcpProviderFactory({ clientFactory }); + + await factory( + ctx({ providerConfig: { transport: { kind: 'http', url: 'https://mcp.example.com' } }, resolvePackagePath }), + ); + + expect(asked).toEqual([]); + expect(Object.keys(seen.transport ?? {})).not.toContain('cwd'); + }); + + it('judges the declarativeStdio policy first: a denied command never reaches the resolver', async () => { + const { factory: clientFactory, seen } = fakeClientFactory(); + const { resolvePackagePath, asked } = recordingResolver(); + const factory = createMcpProviderFactory({ clientFactory }); // default deny + + await expect(factory(ctx({ providerConfig: stdioCfg, resolvePackagePath }))).rejects.toThrow( + /stdio transports are disabled by default/, + ); + expect(asked).toEqual([]); + expect(seen.transport).toBeUndefined(); + }); + + it('treats a resolver failure as a configuration fault (plain, not upstream-unavailable), before any connection', async () => { + const { factory: clientFactory, seen } = fakeClientFactory(); + const factory = createMcpProviderFactory({ clientFactory, declarativeStdio: ['node'] }); + const resolvePackagePath = async (): Promise => { + throw new Error('host cannot resolve paths here'); + }; + + const err: unknown = await Promise.resolve(factory(ctx({ providerConfig: stdioCfg, resolvePackagePath }))).catch( + (e: unknown) => e, + ); + + expect(err).toBeInstanceOf(Error); + expect(isConnectorUpstreamUnavailable(err)).toBe(false); + expect(seen.transport).toBeUndefined(); + }); +}); diff --git a/packages/connectors/connector-mcp/src/mcp-provider.ts b/packages/connectors/connector-mcp/src/mcp-provider.ts index 73687749231..b0be159209e 100644 --- a/packages/connectors/connector-mcp/src/mcp-provider.ts +++ b/packages/connectors/connector-mcp/src/mcp-provider.ts @@ -162,6 +162,18 @@ function assertDeclarativeStdioAllowed( * Stdio transports on declarative instances are policy-gated (default deny) — * see {@link McpDeclarativeStdioPolicy} (#3055). * + * A declarative stdio transport runs **in the declaring app's root** (#22423): + * when the host hands `ConnectorProviderContext.resolvePackagePath`, the + * child's working directory is `resolvePackagePath('.')`, so the app's relative + * `command` and `args` resolve against the same anchor as its other relative + * refs (the `openapi` provider's `providerConfig.spec`, read through + * `loadPackageFile`), not against the directory the server was started from. + * The anchor is the host's: `providerConfig.transport` carries no working + * directory, and an author cannot write one. A host without the member keeps + * the previous behaviour — no `cwd`, so the child inherits the host's current + * directory. The policy check runs first and is unchanged: it judges the + * `command` string exactly as written. + * * The connection is opened at materialization. Faults are classified (#3017): * an invalid transport shape is a *configuration* fault and throws plain — * fatal at boot per the ADR-0097 fail-loud contract — while a connect / @@ -173,9 +185,15 @@ function assertDeclarativeStdioAllowed( export function createMcpProviderFactory(deps: McpProviderDeps = {}): ConnectorProviderFactory { return async (ctx) => { const cfg = (ctx.providerConfig ?? {}) as McpProviderConfig; - const transport = normalizeTransport(cfg.transport, ctx.name, ctx.auth); + let transport = normalizeTransport(cfg.transport, ctx.name, ctx.auth); if (transport.kind === 'stdio') { assertDeclarativeStdioAllowed(deps.declarativeStdio, transport.command, ctx.name); + // One anchor for the app's relative paths (#22423). A resolver that + // throws is a host fault: it propagates plain, a configuration error + // (fatal at boot), never upstream-unavailable. + if (ctx.resolvePackagePath) { + transport = { ...transport, cwd: await ctx.resolvePackagePath('.') }; + } } const includeList = Array.isArray(cfg.include) ? cfg.include.filter((x): x is string => typeof x === 'string') diff --git a/packages/connectors/connector-mcp/src/mcp-stdio-cwd.test.ts b/packages/connectors/connector-mcp/src/mcp-stdio-cwd.test.ts new file mode 100644 index 00000000000..39fd942dab5 --- /dev/null +++ b/packages/connectors/connector-mcp/src/mcp-stdio-cwd.test.ts @@ -0,0 +1,156 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #22423 — the stdio transport's working directory, measured on REAL spawns. +// +// Nothing here injects a client: `createMcpConnector` without a `clientFactory` +// runs the SDK-backed default, which launches the child through the MCP SDK's +// `StdioClientTransport`. The server is a dependency-free fixture written into a +// fresh temp directory that is never this process's own cwd, so a relative path +// resolved against the wrong directory fails to launch. The fixture reports the +// directory it really runs in as its one tool's description, so each case reads +// the child's cwd back instead of inferring it. +// +// The last block boots the real composition seam: the automation service's +// declarative materializer (anchored at `packageRoot`) → the `mcp` provider +// factory → the stdio transport, from a process whose cwd is not the app's. + +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { ErrorCode } from '@modelcontextprotocol/sdk/types.js'; +import { LiteKernel } from '@objectstack/core'; +import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation'; +import { ConnectorMcpPlugin } from './connector-mcp-plugin.js'; +import { createMcpConnector, type McpConnectorBundle, type McpTransport } from './mcp-connector.js'; + +/** A minimal MCP stdio server: newline-delimited JSON-RPC, no imports beyond node. */ +const FIXTURE_SERVER = ` +import { createInterface } from 'node:readline'; +const send = (m) => process.stdout.write(JSON.stringify(m) + '\\n'); +createInterface({ input: process.stdin }).on('line', (line) => { + if (!line.trim()) return; + const msg = JSON.parse(line); + if (msg.id === undefined) return; // a notification + if (msg.method === 'initialize') { + send({ jsonrpc: '2.0', id: msg.id, result: { + protocolVersion: msg.params.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: 'cwd-fixture', version: '1.0.0' }, + } }); + } else if (msg.method === 'tools/list') { + send({ jsonrpc: '2.0', id: msg.id, result: { tools: [ + { name: 'where_am_i', description: process.cwd(), inputSchema: { type: 'object' } }, + ] } }); + } else { + send({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'method not found' } }); + } +}); +`; + +// realpath: the child's process.cwd() reports the resolved directory. +const scratch = realpathSync(mkdtempSync(join(tmpdir(), 'connector-mcp-cwd-'))); +/** The "app": holds the server script and a relative launcher. */ +const APP = join(scratch, 'app'); +/** A second directory that holds nothing — a cwd for the absolute-path controls. */ +const ELSEWHERE = join(scratch, 'elsewhere'); +mkdirSync(join(APP, 'scripts'), { recursive: true }); +mkdirSync(ELSEWHERE); +writeFileSync(join(APP, 'scripts', 'server.mjs'), FIXTURE_SERVER); +// A relative COMMAND: `./bin/node-here` is this very node binary. +mkdirSync(join(APP, 'bin')); +symlinkSync(process.execPath, join(APP, 'bin', 'node-here')); + +afterAll(() => { + rmSync(scratch, { recursive: true, force: true }); +}); + +/** Connect with the real SDK client, read the child's cwd, and tear down. */ +async function childCwd(transport: McpTransport): Promise { + let bundle: McpConnectorBundle | undefined; + try { + bundle = await createMcpConnector({ name: 'cwd_probe', transport }); + expect(bundle.def.actions?.map((a) => a.key)).toEqual(['where_am_i']); + return bundle.def.actions?.[0]?.description; + } finally { + await bundle?.close(); + } +} + +describe('stdio transport cwd — real spawns through the SDK (#22423)', () => { + it('the fixture directory is not this process cwd (otherwise nothing below discriminates)', () => { + expect(realpathSync(process.cwd())).not.toBe(APP); + }); + + it('a relative script arg resolves against the given cwd', async () => { + await expect( + childCwd({ kind: 'stdio', command: 'node', args: ['./scripts/server.mjs'], cwd: APP }), + ).resolves.toBe(APP); + }); + + it('the same relative arg with no cwd resolves against the host cwd: the child exits before the handshake', async () => { + await expect( + childCwd({ kind: 'stdio', command: 'node', args: ['./scripts/server.mjs'] }), + ).rejects.toMatchObject({ code: ErrorCode.ConnectionClosed }); + }); + + it('a relative command path resolves against the given cwd', async () => { + await expect( + childCwd({ kind: 'stdio', command: './bin/node-here', args: ['./scripts/server.mjs'], cwd: APP }), + ).resolves.toBe(APP); + }); + + it('control: an absolute command and an absolute script are unaffected by cwd', async () => { + const absolute = { kind: 'stdio', command: process.execPath, args: [join(APP, 'scripts', 'server.mjs')] } as const; + await expect(childCwd({ ...absolute, cwd: ELSEWHERE })).resolves.toBe(ELSEWHERE); + await expect(childCwd({ ...absolute })).resolves.toBe(realpathSync(process.cwd())); + }); + + it('control: a bare executable still resolves through PATH when a cwd is given', async () => { + await expect( + childCwd({ kind: 'stdio', command: 'node', args: [join(APP, 'scripts', 'server.mjs')], cwd: ELSEWHERE }), + ).resolves.toBe(ELSEWHERE); + }); +}); + +describe('declarative mcp instance booted from another directory (#22423)', () => { + it("registers its actions, the stdio child running in the automation service's packageRoot", async () => { + const declared = [ + { + name: 'cwd_fixture', + label: 'Cwd Fixture', + type: 'api', + provider: 'mcp', + providerConfig: { + transport: { kind: 'stdio', command: 'node', args: ['./scripts/server.mjs'] }, + }, + }, + ]; + const kernel = new LiteKernel({ logger: { level: 'silent' } } as never); + kernel.use(new AutomationServicePlugin({ packageRoot: APP })); + kernel.use({ + name: 'test.connector-metadata', + type: 'standard', + version: '1.0.0', + dependencies: ['com.objectstack.service-automation'], + async init(ctx: { registerService(name: string, service: unknown): void }) { + ctx.registerService('objectql', { + registry: { listItems: (type: string) => (type === 'connector' ? declared : []) }, + }); + }, + async start() {}, + } as never); + kernel.use(new ConnectorMcpPlugin({ declarativeStdio: ['node'] })); + try { + await kernel.bootstrap(); + const engine = kernel.getService('automation') as AutomationEngine; + expect(engine.getConnectorDegradedReason('cwd_fixture')).toBeUndefined(); + const descriptor = engine.getConnectorDescriptors().find((d) => d.name === 'cwd_fixture'); + expect(descriptor?.state).toBe('ready'); + expect(descriptor?.actions.map((a) => a.key)).toEqual(['where_am_i']); + expect(descriptor?.actions[0]?.description).toBe(APP); + } finally { + await kernel.shutdown(); + } + }); +}); From b75c4acfbf50dfabc765d942b521461c8ced5170 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 14:29:58 +0000 Subject: [PATCH 2/2] test(connector-mcp): keep the absolute-path control's args mutable for McpTransport Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude --- packages/connectors/connector-mcp/src/mcp-stdio-cwd.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/connectors/connector-mcp/src/mcp-stdio-cwd.test.ts b/packages/connectors/connector-mcp/src/mcp-stdio-cwd.test.ts index 39fd942dab5..7975f9c5194 100644 --- a/packages/connectors/connector-mcp/src/mcp-stdio-cwd.test.ts +++ b/packages/connectors/connector-mcp/src/mcp-stdio-cwd.test.ts @@ -101,7 +101,7 @@ describe('stdio transport cwd — real spawns through the SDK (#22423)', () => { }); it('control: an absolute command and an absolute script are unaffected by cwd', async () => { - const absolute = { kind: 'stdio', command: process.execPath, args: [join(APP, 'scripts', 'server.mjs')] } as const; + const absolute = { kind: 'stdio' as const, command: process.execPath, args: [join(APP, 'scripts', 'server.mjs')] }; await expect(childCwd({ ...absolute, cwd: ELSEWHERE })).resolves.toBe(ELSEWHERE); await expect(childCwd({ ...absolute })).resolves.toBe(realpathSync(process.cwd())); });