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..7975f9c5194 --- /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' 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())); + }); + + 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(); + } + }); +});