|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #7645 — a started stdio MCP transport must ANSWER. |
| 5 | + * |
| 6 | + * The defect: `os serve` with `OS_MCP_STDIO_ENABLED=true` logged |
| 7 | + * `[MCP] Server started (transport: stdio)`, bound the principal to a real |
| 8 | + * `osk_` identity, and then never replied to a single JSON-RPC request — |
| 9 | + * `initialize`, `tools/list`, `resources/list`, `resources/read` all timed out |
| 10 | + * with ZERO bytes on stdout. Malformed input drew no error either. |
| 11 | + * |
| 12 | + * The cause sat in the HOST, above the plugin: oclif's argument parser reads |
| 13 | + * stdin for any positional arg the caller did not supply (`tryStdin` → |
| 14 | + * `createInterface({input: process.stdin})`, aborted after 10 ms), and |
| 15 | + * `Interface.close()` calls `stdin.pause()`. `serve` declares an optional |
| 16 | + * `config` positional, so `os serve --dev` left `process.stdin` explicitly |
| 17 | + * paused. `StdioServerTransport.start()` only attaches a `data` listener, and |
| 18 | + * Node auto-switches to flowing mode on that listener ONLY while |
| 19 | + * `readableFlowing` is `null` — never after an explicit `pause()`. Listener |
| 20 | + * attached, `bytesRead` 0, server deaf. |
| 21 | + * |
| 22 | + * WHY THIS FILE SPAWNS THE CLI. `packages/mcp`'s 17 existing stdio pins were |
| 23 | + * all green while every real `os serve` stdio session was unusable, because |
| 24 | + * they sit BELOW the gap: they exercise the runtime in a plain node process, |
| 25 | + * where nothing ever paused stdin. Only a test that spawns the actual command |
| 26 | + * and speaks JSON-RPC down the child's pipe can see it. |
| 27 | + * |
| 28 | + * WHY IT ASSERTS AN ANSWER, not a stream flag. `process.stdin.isPaused() === |
| 29 | + * false` would pass over a transport that still replies to nothing — it pins |
| 30 | + * the mechanism, not the consequence. The assertion below is the card's own |
| 31 | + * repro: write a real `initialize` to the child's stdin, get a real result |
| 32 | + * back. Reverse-verified — with the `resume()` in `MCPServerRuntime.start()` |
| 33 | + * removed, this file times out with zero bytes on stdout. |
| 34 | + * |
| 35 | + * WHY IT MINTS A KEY OVER HTTP FIRST. stdio auto-start is fail-closed |
| 36 | + * (ADR-0101): without an `OS_MCP_STDIO_API_KEY` that resolves to a real |
| 37 | + * identity the plugin REFUSES to start, so there is no transport to test. That |
| 38 | + * contract is correct and is not what this file is about — the first boot just |
| 39 | + * mints a key through the product route (`POST /keys`) against a file-backed |
| 40 | + * DB, and the second boot reuses that DB. A fabricated `sys_api_key` row would |
| 41 | + * have to guess the at-rest hashing the mint path owns. |
| 42 | + */ |
| 43 | + |
| 44 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 45 | +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; |
| 46 | +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; |
| 47 | +import { tmpdir } from 'node:os'; |
| 48 | +import { join, resolve } from 'node:path'; |
| 49 | +import { fileURLToPath } from 'node:url'; |
| 50 | +import { randomPort } from './helpers/serve-process.js'; |
| 51 | + |
| 52 | +const HERE = resolve(fileURLToPath(import.meta.url), '..'); |
| 53 | +/** `bin/run.js` — the SHIPPED entrypoint, i.e. the one the card's repro names. */ |
| 54 | +const CLI = resolve(HERE, '../bin/run.js'); |
| 55 | + |
| 56 | +const CONFIG = ` |
| 57 | +export default { |
| 58 | + manifest: { |
| 59 | + id: 'com.example.stdioprobe', |
| 60 | + namespace: 'stdioprobe', |
| 61 | + version: '1.0.0', |
| 62 | + type: 'app', |
| 63 | + name: 'MCP stdio probe', |
| 64 | + }, |
| 65 | + objects: [{ |
| 66 | + name: 'stdioprobe_task', |
| 67 | + label: 'Task', |
| 68 | + sharingModel: 'public', |
| 69 | + fields: { title: { type: 'text', label: 'Title' } }, |
| 70 | + }], |
| 71 | +}; |
| 72 | +`; |
| 73 | + |
| 74 | +let dir: string; |
| 75 | +let port: string; |
| 76 | +let apiKey: string; |
| 77 | +/** Every child this file spawns, so a failed assertion never leaks a server. */ |
| 78 | +const children: ChildProcessWithoutNullStreams[] = []; |
| 79 | + |
| 80 | +interface Booted { |
| 81 | + child: ChildProcessWithoutNullStreams; |
| 82 | + stdout: () => string; |
| 83 | + stderr: () => string; |
| 84 | +} |
| 85 | + |
| 86 | +/** |
| 87 | + * Spawn `os serve` and resolve once `waitFor` matches its stdout, leaving the |
| 88 | + * child RUNNING — the shared `runServe` helper kills on match, and this file |
| 89 | + * has to keep talking to the process afterwards. |
| 90 | + * |
| 91 | + * NOTE: no positional config path is passed. That is deliberate and load-bearing: |
| 92 | + * supplying one makes oclif skip `tryStdin` entirely, so stdin is never paused |
| 93 | + * and the defect cannot reproduce. `os serve --dev` is also the form every user |
| 94 | + * types. |
| 95 | + */ |
| 96 | +function boot(env: Record<string, string | undefined>, waitFor: RegExp): Promise<Booted> { |
| 97 | + return new Promise((resolveBoot, rejectBoot) => { |
| 98 | + const child = spawn(process.execPath, [CLI, 'serve', '-p', port, '--dev'], { |
| 99 | + cwd: dir, |
| 100 | + stdio: ['pipe', 'pipe', 'pipe'], |
| 101 | + env: { |
| 102 | + ...process.env, |
| 103 | + NO_COLOR: '1', |
| 104 | + OS_LOG_LEVEL: 'info', |
| 105 | + OS_DISABLE_CONSOLE: '1', |
| 106 | + // Explicit, not inherited: the dev-admin seed this fixture signs in as |
| 107 | + // is hard-gated on `NODE_ENV === 'development'`, and vitest exports |
| 108 | + // `test`, which would leave the DB user-less and the mint unauthorized. |
| 109 | + NODE_ENV: 'development', |
| 110 | + ...env, |
| 111 | + }, |
| 112 | + }) as ChildProcessWithoutNullStreams; |
| 113 | + children.push(child); |
| 114 | + |
| 115 | + let out = ''; |
| 116 | + let err = ''; |
| 117 | + let settled = false; |
| 118 | + |
| 119 | + const timer = setTimeout(() => { |
| 120 | + if (settled) return; |
| 121 | + settled = true; |
| 122 | + rejectBoot( |
| 123 | + new Error(`serve never printed ${waitFor}\n--- stdout ---\n${out.slice(-4000)}\n--- stderr ---\n${err.slice(-4000)}`), |
| 124 | + ); |
| 125 | + }, 150_000); |
| 126 | + |
| 127 | + child.stdout.on('data', (d) => { |
| 128 | + out += String(d); |
| 129 | + if (!settled && waitFor.test(out)) { |
| 130 | + settled = true; |
| 131 | + clearTimeout(timer); |
| 132 | + resolveBoot({ child, stdout: () => out, stderr: () => err }); |
| 133 | + } |
| 134 | + }); |
| 135 | + child.stderr.on('data', (d) => { |
| 136 | + err += String(d); |
| 137 | + }); |
| 138 | + child.on('exit', (code) => { |
| 139 | + if (settled) return; |
| 140 | + settled = true; |
| 141 | + clearTimeout(timer); |
| 142 | + rejectBoot( |
| 143 | + new Error(`serve exited ${code} before ${waitFor}\n--- stdout ---\n${out.slice(-4000)}\n--- stderr ---\n${err.slice(-4000)}`), |
| 144 | + ); |
| 145 | + }); |
| 146 | + }); |
| 147 | +} |
| 148 | + |
| 149 | +async function stop(child: ChildProcessWithoutNullStreams): Promise<void> { |
| 150 | + if (child.exitCode !== null || child.signalCode !== null) return; |
| 151 | + await new Promise<void>((done) => { |
| 152 | + const give = setTimeout(() => { |
| 153 | + try { |
| 154 | + child.kill('SIGKILL'); |
| 155 | + } catch { |
| 156 | + /* already gone */ |
| 157 | + } |
| 158 | + done(); |
| 159 | + }, 10_000); |
| 160 | + child.once('exit', () => { |
| 161 | + clearTimeout(give); |
| 162 | + done(); |
| 163 | + }); |
| 164 | + try { |
| 165 | + child.kill('SIGTERM'); |
| 166 | + } catch { |
| 167 | + clearTimeout(give); |
| 168 | + done(); |
| 169 | + } |
| 170 | + }); |
| 171 | +} |
| 172 | + |
| 173 | +describe('#7645: the stdio MCP transport answers over a spawned CLI process', () => { |
| 174 | + beforeAll(async () => { |
| 175 | + dir = mkdtempSync(join(tmpdir(), 'mcp-stdio-e2e-')); |
| 176 | + writeFileSync(join(dir, 'objectstack.config.ts'), CONFIG, 'utf8'); |
| 177 | + writeFileSync( |
| 178 | + join(dir, 'package.json'), |
| 179 | + JSON.stringify({ name: 'mcp-stdio-e2e-fixture', private: true, type: 'module' }, null, 2), |
| 180 | + 'utf8', |
| 181 | + ); |
| 182 | + port = randomPort(); |
| 183 | + |
| 184 | + // Boot 1 — mint a real key through the product route, against a FILE db so |
| 185 | + // boot 2 sees the same row (`:memory:` would not survive the restart). |
| 186 | + const first = await boot({ OS_DATABASE_URL: join(dir, 'probe.db') }, /Server is ready/); |
| 187 | + const base = `http://localhost:${port}/api/v1`; |
| 188 | + const signIn = await fetch(`${base}/auth/sign-in/email`, { |
| 189 | + method: 'POST', |
| 190 | + headers: { 'content-type': 'application/json' }, |
| 191 | + // `serve --dev` seeds this admin on an empty DB. |
| 192 | + body: JSON.stringify({ email: 'admin@objectos.ai', password: 'admin123' }), |
| 193 | + }); |
| 194 | + expect(signIn.status).toBe(200); |
| 195 | + const token = ((await signIn.json()) as { token?: string }).token; |
| 196 | + expect(token).toBeTruthy(); |
| 197 | + |
| 198 | + const minted = await fetch(`${base}/keys`, { |
| 199 | + method: 'POST', |
| 200 | + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, |
| 201 | + body: JSON.stringify({ name: 'mcp-stdio-e2e' }), |
| 202 | + }); |
| 203 | + expect(minted.status).toBe(201); |
| 204 | + apiKey = String(((await minted.json()) as { data: { key: string } }).data.key); |
| 205 | + expect(apiKey.startsWith('osk_')).toBe(true); |
| 206 | + |
| 207 | + await stop(first.child); |
| 208 | + }, 240_000); |
| 209 | + |
| 210 | + afterAll(async () => { |
| 211 | + for (const child of children) await stop(child); |
| 212 | + if (dir) rmSync(dir, { recursive: true, force: true }); |
| 213 | + }, 60_000); |
| 214 | + |
| 215 | + it('replies to a real JSON-RPC initialize written to the child process stdin', async () => { |
| 216 | + const booted = await boot( |
| 217 | + { |
| 218 | + OS_DATABASE_URL: join(dir, 'probe.db'), |
| 219 | + OS_MCP_STDIO_ENABLED: 'true', |
| 220 | + OS_MCP_STDIO_API_KEY: apiKey, |
| 221 | + }, |
| 222 | + /\[MCP\] Server started \(transport: stdio/, |
| 223 | + ); |
| 224 | + |
| 225 | + // The started transport is the premise, not the assertion — the whole |
| 226 | + // defect was a transport that reached exactly this line and then went deaf. |
| 227 | + const reply = await new Promise<string | null>((resolveReply) => { |
| 228 | + let buf = ''; |
| 229 | + const onData = (d: Buffer | string) => { |
| 230 | + buf += String(d); |
| 231 | + // A JSON-RPC frame carrying OUR request id — not merely "some bytes". |
| 232 | + if (/"jsonrpc"\s*:\s*"2\.0"/.test(buf) && /"id"\s*:\s*1\b/.test(buf)) { |
| 233 | + clearTimeout(giveUp); |
| 234 | + booted.child.stdout.off('data', onData); |
| 235 | + resolveReply(buf); |
| 236 | + } |
| 237 | + }; |
| 238 | + booted.child.stdout.on('data', onData); |
| 239 | + |
| 240 | + const giveUp = setTimeout(() => { |
| 241 | + booted.child.stdout.off('data', onData); |
| 242 | + resolveReply(null); |
| 243 | + }, 45_000); |
| 244 | + |
| 245 | + booted.child.stdin.write( |
| 246 | + `${JSON.stringify({ |
| 247 | + jsonrpc: '2.0', |
| 248 | + id: 1, |
| 249 | + method: 'initialize', |
| 250 | + params: { |
| 251 | + protocolVersion: '2024-11-05', |
| 252 | + capabilities: {}, |
| 253 | + clientInfo: { name: 'objectstack-e2e', version: '0.0.0' }, |
| 254 | + }, |
| 255 | + })}\n`, |
| 256 | + ); |
| 257 | + }); |
| 258 | + |
| 259 | + expect( |
| 260 | + reply, |
| 261 | + 'the stdio transport started but never answered `initialize` (#7645: stdin left paused by the host)', |
| 262 | + ).not.toBeNull(); |
| 263 | + |
| 264 | + // An ANSWER, not just traffic: the frame has to parse as this request's |
| 265 | + // result and carry the server's identity, so a stray log line that happens |
| 266 | + // to contain `"jsonrpc"` cannot pass. |
| 267 | + const frame = (reply as string) |
| 268 | + .split('\n') |
| 269 | + .map((line) => line.trim()) |
| 270 | + .filter((line) => line.startsWith('{') && line.includes('"jsonrpc"')) |
| 271 | + .map((line) => { |
| 272 | + try { |
| 273 | + return JSON.parse(line) as Record<string, unknown>; |
| 274 | + } catch { |
| 275 | + return undefined; |
| 276 | + } |
| 277 | + }) |
| 278 | + .find((msg) => msg?.id === 1); |
| 279 | + |
| 280 | + expect(frame, `no parseable JSON-RPC frame for id 1 in:\n${reply}`).toBeTruthy(); |
| 281 | + const result = frame!.result as { protocolVersion?: string; serverInfo?: { name?: string } } | undefined; |
| 282 | + expect(frame!.error).toBeUndefined(); |
| 283 | + expect(result?.protocolVersion).toBeTruthy(); |
| 284 | + expect(result?.serverInfo?.name).toBeTruthy(); |
| 285 | + |
| 286 | + await stop(booted.child); |
| 287 | + }, 240_000); |
| 288 | +}); |
0 commit comments