Skip to content

Commit c4b77c7

Browse files
Merge branch 'main' into test/spec-type-key-parity-assertions
2 parents fdff289 + 6711ed9 commit c4b77c7

8 files changed

Lines changed: 297 additions & 24 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/client': patch
3+
---
4+
5+
Always set `windowsHide` when spawning stdio server processes on Windows, not just in Electron environments. Prevents unwanted console windows in non-Electron Windows applications.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/client': minor
3+
---
4+
5+
Add `reconnectionScheduler` option to `StreamableHTTPClientTransport`. Lets non-persistent environments (serverless, mobile, desktop sleep/wake) override the default `setTimeout`-based SSE reconnection scheduling. The scheduler may return a cancel function that is invoked on `transport.close()`.

packages/client/src/client/stdio.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export class StdioClientTransport implements Transport {
126126
},
127127
stdio: ['pipe', 'pipe', this._serverParams.stderr ?? 'inherit'],
128128
shell: false,
129-
windowsHide: process.platform === 'win32' && isElectron(),
129+
windowsHide: process.platform === 'win32',
130130
cwd: this._serverParams.cwd
131131
});
132132

@@ -258,7 +258,3 @@ export class StdioClientTransport implements Transport {
258258
});
259259
}
260260
}
261-
262-
function isElectron() {
263-
return 'type' in process;
264-
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Type-checked examples for `streamableHttp.ts`.
3+
*
4+
* These examples are synced into JSDoc comments via the sync-snippets script.
5+
* Each function's region markers define the code snippet that appears in the docs.
6+
*
7+
* @module
8+
*/
9+
10+
/* eslint-disable unicorn/consistent-function-scoping -- examples must live inside region blocks */
11+
12+
import type { ReconnectionScheduler } from './streamableHttp.js';
13+
14+
// Stub for a hypothetical platform-specific background scheduling API
15+
declare const platformBackgroundTask: {
16+
schedule(callback: () => void, delay: number): number;
17+
cancel(id: number): void;
18+
};
19+
20+
/**
21+
* Example: Using a platform background-task API to schedule reconnections.
22+
*/
23+
function ReconnectionScheduler_basicUsage() {
24+
//#region ReconnectionScheduler_basicUsage
25+
const scheduler: ReconnectionScheduler = (reconnect, delay) => {
26+
const id = platformBackgroundTask.schedule(reconnect, delay);
27+
return () => platformBackgroundTask.cancel(id);
28+
};
29+
//#endregion ReconnectionScheduler_basicUsage
30+
return scheduler;
31+
}

packages/client/src/client/streamableHttp.ts

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,31 @@ export interface StreamableHTTPReconnectionOptions {
7878
maxRetries: number;
7979
}
8080

81+
/**
82+
* Custom scheduler for SSE stream reconnection attempts.
83+
*
84+
* Called instead of `setTimeout` when the transport needs to schedule a reconnection.
85+
* Useful in environments where `setTimeout` is unsuitable (serverless functions that
86+
* terminate before the timer fires, mobile apps that need platform background scheduling,
87+
* desktop apps handling sleep/wake).
88+
*
89+
* @param reconnect - Call this to perform the reconnection attempt.
90+
* @param delay - Suggested delay in milliseconds (from backoff calculation).
91+
* @param attemptCount - Zero-indexed retry attempt number.
92+
* @returns An optional cancel function. If returned, it will be called on
93+
* {@linkcode StreamableHTTPClientTransport.close | transport.close()} to abort the
94+
* pending reconnection.
95+
*
96+
* @example
97+
* ```ts source="./streamableHttp.examples.ts#ReconnectionScheduler_basicUsage"
98+
* const scheduler: ReconnectionScheduler = (reconnect, delay) => {
99+
* const id = platformBackgroundTask.schedule(reconnect, delay);
100+
* return () => platformBackgroundTask.cancel(id);
101+
* };
102+
* ```
103+
*/
104+
export type ReconnectionScheduler = (reconnect: () => void, delay: number, attemptCount: number) => (() => void) | void;
105+
81106
/**
82107
* Configuration options for the {@linkcode StreamableHTTPClientTransport}.
83108
*/
@@ -116,6 +141,12 @@ export type StreamableHTTPClientTransportOptions = {
116141
*/
117142
reconnectionOptions?: StreamableHTTPReconnectionOptions;
118143

144+
/**
145+
* Custom scheduler for reconnection attempts. If not provided, `setTimeout` is used.
146+
* See {@linkcode ReconnectionScheduler}.
147+
*/
148+
reconnectionScheduler?: ReconnectionScheduler;
149+
119150
/**
120151
* Session ID for the connection. This is used to identify the session on the server.
121152
* When not provided and connecting to a server that supports session IDs, the server will generate a new session ID.
@@ -150,7 +181,8 @@ export class StreamableHTTPClientTransport implements Transport {
150181
private _protocolVersion?: string;
151182
private _lastUpscopingHeader?: string; // Track last upscoping header to prevent infinite upscoping.
152183
private _serverRetryMs?: number; // Server-provided retry delay from SSE retry field
153-
private _reconnectionTimeout?: ReturnType<typeof setTimeout>;
184+
private readonly _reconnectionScheduler?: ReconnectionScheduler;
185+
private _cancelReconnection?: () => void;
154186

155187
onclose?: () => void;
156188
onerror?: (error: Error) => void;
@@ -172,6 +204,7 @@ export class StreamableHTTPClientTransport implements Transport {
172204
this._sessionId = opts?.sessionId;
173205
this._protocolVersion = opts?.protocolVersion;
174206
this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS;
207+
this._reconnectionScheduler = opts?.reconnectionScheduler;
175208
}
176209

177210
private async _commonHeaders(): Promise<Headers> {
@@ -305,15 +338,26 @@ export class StreamableHTTPClientTransport implements Transport {
305338
// Calculate next delay based on current attempt count
306339
const delay = this._getNextReconnectionDelay(attemptCount);
307340

308-
// Schedule the reconnection
309-
this._reconnectionTimeout = setTimeout(() => {
310-
// Use the last event ID to resume where we left off
341+
const reconnect = (): void => {
342+
this._cancelReconnection = undefined;
343+
if (this._abortController?.signal.aborted) return;
311344
this._startOrAuthSse(options).catch(error => {
312345
this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`));
313-
// Schedule another attempt if this one failed, incrementing the attempt counter
314-
this._scheduleReconnection(options, attemptCount + 1);
346+
try {
347+
this._scheduleReconnection(options, attemptCount + 1);
348+
} catch (scheduleError) {
349+
this.onerror?.(scheduleError instanceof Error ? scheduleError : new Error(String(scheduleError)));
350+
}
315351
});
316-
}, delay);
352+
};
353+
354+
if (this._reconnectionScheduler) {
355+
const cancel = this._reconnectionScheduler(reconnect, delay, attemptCount);
356+
this._cancelReconnection = typeof cancel === 'function' ? cancel : undefined;
357+
} else {
358+
const handle = setTimeout(reconnect, delay);
359+
this._cancelReconnection = () => clearTimeout(handle);
360+
}
317361
}
318362

319363
private _handleSseStream(stream: ReadableStream<Uint8Array> | null, options: StartSSEOptions, isReconnectable: boolean): void {
@@ -458,12 +502,13 @@ export class StreamableHTTPClientTransport implements Transport {
458502
}
459503

460504
async close(): Promise<void> {
461-
if (this._reconnectionTimeout) {
462-
clearTimeout(this._reconnectionTimeout);
463-
this._reconnectionTimeout = undefined;
505+
try {
506+
this._cancelReconnection?.();
507+
} finally {
508+
this._cancelReconnection = undefined;
509+
this._abortController?.abort();
510+
this.onclose?.();
464511
}
465-
this._abortController?.abort();
466-
this.onclose?.();
467512
}
468513

469514
async send(

packages/client/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,12 @@ export type { SSEClientTransportOptions } from './client/sse.js';
6262
export { SSEClientTransport, SseError } from './client/sse.js';
6363
export type { StdioServerParameters } from './client/stdio.js';
6464
export { DEFAULT_INHERITED_ENV_VARS, getDefaultEnvironment, StdioClientTransport } from './client/stdio.js';
65-
export type { StartSSEOptions, StreamableHTTPClientTransportOptions, StreamableHTTPReconnectionOptions } from './client/streamableHttp.js';
65+
export type {
66+
ReconnectionScheduler,
67+
StartSSEOptions,
68+
StreamableHTTPClientTransportOptions,
69+
StreamableHTTPReconnectionOptions
70+
} from './client/streamableHttp.js';
6671
export { StreamableHTTPClientTransport } from './client/streamableHttp.js';
6772
export { WebSocketClientTransport } from './client/websocket.js';
6873

packages/client/test/client/crossSpawn.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,4 +152,54 @@ describe('StdioClientTransport using cross-spawn', () => {
152152
// verify message is sent correctly
153153
expect(mockProcess.stdin.write).toHaveBeenCalled();
154154
});
155+
156+
describe('windowsHide', () => {
157+
const originalPlatform = process.platform;
158+
159+
afterEach(() => {
160+
Object.defineProperty(process, 'platform', {
161+
value: originalPlatform
162+
});
163+
});
164+
165+
test('should set windowsHide to true on Windows', async () => {
166+
Object.defineProperty(process, 'platform', {
167+
value: 'win32'
168+
});
169+
170+
const transport = new StdioClientTransport({
171+
command: 'test-command'
172+
});
173+
174+
await transport.start();
175+
176+
expect(mockSpawn).toHaveBeenCalledWith(
177+
'test-command',
178+
[],
179+
expect.objectContaining({
180+
windowsHide: true
181+
})
182+
);
183+
});
184+
185+
test('should set windowsHide to false on non-Windows', async () => {
186+
Object.defineProperty(process, 'platform', {
187+
value: 'linux'
188+
});
189+
190+
const transport = new StdioClientTransport({
191+
command: 'test-command'
192+
});
193+
194+
await transport.start();
195+
196+
expect(mockSpawn).toHaveBeenCalledWith(
197+
'test-command',
198+
[],
199+
expect.objectContaining({
200+
windowsHide: false
201+
})
202+
);
203+
});
204+
});
155205
});

0 commit comments

Comments
 (0)