Skip to content

Commit 6bcf502

Browse files
cameroncookecodex
andcommitted
test(mcp): Add idle timeout baseline e2e coverage
Assert the real MCP server process remains running when no idle timeout is configured, then separately assert the opt-in timeout exits gracefully. Refs #394 Co-Authored-By: Codex <noreply@openai.com>
1 parent b382e48 commit 6bcf502

1 file changed

Lines changed: 78 additions & 0 deletions

File tree

src/smoke-tests/__tests__/e2e-mcp-idle-timeout.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
77
import { afterEach, describe, expect, it } from 'vitest';
88
const CLI_PATH = join(process.cwd(), 'build/cli.js');
99
const MCP_IDLE_TIMEOUT_MS = 1_000;
10+
const MCP_BASELINE_WAIT_MS = 2_000;
1011
const MCP_CONNECT_TIMEOUT_MS = 10_000;
1112
const MCP_EXIT_WAIT_MS = 8_000;
1213
const MCP_TEST_TIMEOUT_MS = 30_000;
@@ -81,6 +82,31 @@ function waitForExit(
8182
});
8283
}
8384

85+
function waitForUnexpectedExit(child: ChildProcess, timeoutMs: number): Promise<ChildExit | null> {
86+
if (child.exitCode !== null || child.signalCode !== null) {
87+
return Promise.resolve({ code: child.exitCode, signal: child.signalCode });
88+
}
89+
90+
return new Promise((resolve) => {
91+
const timeout = setTimeout(() => {
92+
cleanup();
93+
resolve(null);
94+
}, timeoutMs);
95+
96+
const cleanup = (): void => {
97+
clearTimeout(timeout);
98+
child.removeListener('close', onClose);
99+
};
100+
101+
const onClose = (code: number | null, signal: NodeJS.Signals | null): void => {
102+
cleanup();
103+
resolve({ code, signal });
104+
};
105+
106+
child.once('close', onClose);
107+
});
108+
}
109+
84110
async function cleanupActiveProcess(): Promise<void> {
85111
const client = activeClient;
86112
const child = activeChild;
@@ -101,6 +127,58 @@ afterEach(async () => {
101127
});
102128

103129
describe('MCP server idle timeout e2e', () => {
130+
it(
131+
'stays running when the idle timeout is not configured',
132+
async () => {
133+
if (!existsSync(CLI_PATH)) {
134+
throw new Error(
135+
'MCP idle timeout e2e test requires build/cli.js. Run npm run build first.',
136+
);
137+
}
138+
139+
const transport = new StdioClientTransport({
140+
command: 'node',
141+
args: [CLI_PATH, 'mcp'],
142+
cwd: process.cwd(),
143+
env: getSmokeTestEnv({
144+
SENTRY_DISABLED: 'true',
145+
XCODEBUILDMCP_ENABLED_WORKFLOWS: 'simulator',
146+
XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS: 'true',
147+
XCODEBUILDMCP_DISABLE_XCODE_AUTO_SYNC: '1',
148+
}),
149+
stderr: 'pipe',
150+
});
151+
const getStderr = collectOutput(transport.stderr as Readable | null);
152+
const client = new Client({ name: 'mcp-idle-timeout-baseline-client', version: '1.0.0' });
153+
154+
activeClient = client;
155+
try {
156+
await client.connect(transport, { timeout: MCP_CONNECT_TIMEOUT_MS });
157+
} catch (error) {
158+
activeChild = getTransportChildIfSpawned(transport);
159+
throw error;
160+
}
161+
const child = getTransportChild(transport);
162+
activeChild = child;
163+
164+
const tools = await client.listTools(undefined, { timeout: 10_000 });
165+
expect(tools.tools.length).toBeGreaterThan(0);
166+
167+
const unexpectedExit = await waitForUnexpectedExit(child, MCP_BASELINE_WAIT_MS);
168+
expect(unexpectedExit).toBeNull();
169+
expect(child.exitCode).toBeNull();
170+
expect(child.signalCode).toBeNull();
171+
expect(getStderr()).toContain('MCP idle shutdown disabled');
172+
173+
await client.close();
174+
activeClient = null;
175+
const exit = await waitForExit(child, 2_000, getStderr);
176+
activeChild = null;
177+
expect(exit).toEqual({ code: 0, signal: null });
178+
},
179+
MCP_TEST_TIMEOUT_MS,
180+
);
181+
104182
it(
105183
'exits gracefully after the opt-in idle timeout',
106184
async () => {

0 commit comments

Comments
 (0)