@@ -7,6 +7,7 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
77import { afterEach , describe , expect , it } from 'vitest' ;
88const CLI_PATH = join ( process . cwd ( ) , 'build/cli.js' ) ;
99const MCP_IDLE_TIMEOUT_MS = 1_000 ;
10+ const MCP_BASELINE_WAIT_MS = 2_000 ;
1011const MCP_CONNECT_TIMEOUT_MS = 10_000 ;
1112const MCP_EXIT_WAIT_MS = 8_000 ;
1213const 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+
84110async function cleanupActiveProcess ( ) : Promise < void > {
85111 const client = activeClient ;
86112 const child = activeChild ;
@@ -101,6 +127,58 @@ afterEach(async () => {
101127} ) ;
102128
103129describe ( '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