Skip to content

Commit 7182362

Browse files
hotlongclaude
andauthored
fix(mcp): the stdio transport answers again — resume the stdin it owns (#7645) (#7914)
`objectstack serve` with `OS_MCP_STDIO_ENABLED=true` logged `[MCP] Server started (transport: stdio)`, bound the transport to a real `osk_` identity, and then never answered a single request: `initialize`, `tools/list`, `resources/list` and `resources/read` all timed out with zero bytes on stdout. The pause came from the host, above the plugin. oclif's argument parser reads stdin for any positional argument the caller did not supply (`tryStdin` -> `createInterface({input: process.stdin})`, aborted after 10ms), and `Interface.close()` calls `stdin.pause()`. `serve` declares an optional `config` positional, so plain `objectstack serve --dev` left `process.stdin` explicitly paused before the kernel booted. `StdioServerTransport.start()` only attaches a `data` listener, and Node auto-switches a stream to flowing mode on that listener only while `readableFlowing` is still `null` — never after an explicit `pause()`. Listener attached, `bytesRead` 0, transport deaf. `MCPServerRuntime.start()` now resumes `process.stdin` immediately after `connect()` — the moment the transport takes ownership of it, and after the transport's reader is attached so no byte can flow unread. The resume lives in the runtime rather than in the CLI's argument definitions because the pause is not oclif-specific: any host that touched stdin before `start()` leaves the transport equally deaf. Pinned by a spawned-CLI e2e that writes a real `initialize` down the child's stdin and requires a real result back — the consequence, not the stream flag. Reverse-verified: with the resume removed it times out with zero bytes. The ADR-0101 fail-closed startup contract is untouched. Claude-Session: https://claude.ai/code/session_01Qxkv33VVJAYJptjGmjTzFG Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0e79785 commit 7182362

3 files changed

Lines changed: 360 additions & 0 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/mcp": patch
3+
---
4+
5+
fix(mcp): the stdio MCP transport answers again — resume the stdin it just took ownership of (#7645)
6+
7+
`objectstack serve` with `OS_MCP_STDIO_ENABLED=true` logged
8+
`[MCP] Server started (transport: stdio)`, bound the transport to a real `osk_`
9+
identity — and then **never answered a single request**. `initialize`,
10+
`tools/list`, `resources/list` and `resources/read` all timed out with **zero
11+
bytes on stdout**; malformed input drew no error either. Every stdio MCP session
12+
against the CLI was unusable, and the failure was silent on both sides: the
13+
server looked started, the client just waited.
14+
15+
The pause came from the **host**, above the plugin. oclif's argument parser
16+
reads stdin for any positional argument the caller did not supply (`tryStdin` →
17+
`createInterface({input: process.stdin})`, aborted after 10 ms), and
18+
`Interface.close()` calls `stdin.pause()`. `serve` declares an optional `config`
19+
positional, so plain `objectstack serve --dev` left `process.stdin` explicitly
20+
paused before the kernel ever booted. `StdioServerTransport.start()` only
21+
attaches a `data` listener, and Node auto-switches a stream to flowing mode on
22+
that listener **only while `readableFlowing` is still `null`** — never after an
23+
explicit `pause()`. Listener attached, `bytesRead` stuck at 0, transport deaf.
24+
25+
`MCPServerRuntime.start()` now resumes `process.stdin` immediately after
26+
`connect()`, which is the moment the transport takes ownership of it (after, so
27+
the transport's reader is attached before any byte can flow). The resume lives
28+
in the runtime rather than in the CLI's argument definitions because the pause
29+
is not oclif-specific: any host that touched stdin before `start()` — a readline
30+
prompt, a supervisor, an embedding process — left the transport equally deaf,
31+
and this is the one place that knows a long-lived stdio transport was just
32+
attached.
33+
34+
Measured, both directions: `objectstack serve --dev` (no config path, parser
35+
reads stdin) went from timing out to answering `initialize`, while `objectstack
36+
serve objectstack.config.ts --dev` (parser never touches stdin) answered before
37+
and after. The HTTP transport at `/api/v1/mcp` is unaffected — it is served
38+
per-request and never touches stdin.
39+
40+
Not changed, and deliberately so: the ADR-0101 fail-closed startup contract.
41+
stdio enabled without `OS_MCP_STDIO_API_KEY` still throws at plugin start, an
42+
unknown/revoked key still refuses with no anonymous-but-serving fallback, and a
43+
member key still binds the principal to that member.
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
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+
});

‎packages/mcp/src/mcp-server-runtime.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,6 +1022,35 @@ export class MCPServerRuntime {
10221022
if (this.config.transport === 'stdio') {
10231023
this.transport = new StdioServerTransport();
10241024
await this.mcpServer.connect(this.transport);
1025+
// [#7645] The transport now OWNS this process's stdin — so make sure it
1026+
// is actually flowing. `StdioServerTransport.start()` only attaches a
1027+
// `data` listener, and Node auto-switches a stream to flowing mode on
1028+
// that listener ONLY while `readableFlowing` is still `null`. Once
1029+
// something has explicitly called `pause()`, the flag is `false` and a
1030+
// later `data` listener does NOT resume it: the listener is attached,
1031+
// `bytesRead` stays 0, and the server is started-but-permanently-deaf.
1032+
//
1033+
// That is not hypothetical. Under `objectstack serve`, oclif's argument
1034+
// parser reads stdin for any positional arg the user did not supply
1035+
// (`tryStdin` → `createInterface({input: process.stdin})`, aborted after
1036+
// 10 ms), and `Interface.close()` calls `stdin.pause()`. `serve` declares
1037+
// an optional `config` positional, so `os serve --dev` (no path) left
1038+
// stdin paused and EVERY `initialize` / `tools/list` / `resources/read`
1039+
// timed out with zero bytes on stdout — while the same command WITH the
1040+
// path (parser never touches stdin) answered fine. Measured both ways.
1041+
//
1042+
// The resume lives here rather than in the CLI because the pause is not
1043+
// oclif-specific: any host that touched stdin before `start()` (a
1044+
// readline prompt, a supervisor, an embedding process) leaves it paused,
1045+
// and every one of them yields the same silent deafness. This is the one
1046+
// place that knows a long-lived stdio transport was just attached.
1047+
//
1048+
// Resumed AFTER `connect()` on purpose: `connect()` attaches the
1049+
// transport's `data` listener, so no byte can flow before there is a
1050+
// reader for it.
1051+
if (typeof process !== 'undefined' && typeof process.stdin?.resume === 'function') {
1052+
process.stdin.resume();
1053+
}
10251054
this.started = true;
10261055
logger?.info(`[MCP] Server started (transport: stdio, name: ${this.config.name})`);
10271056
} else {

0 commit comments

Comments
 (0)