|
| 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