Skip to content

Commit 1cb92b6

Browse files
Merge pull request modelcontextprotocol#60 from modelcontextprotocol/justin/remove-abort-after-timeout
Replace `abortAfterTimeout` with `RequestOptions.timeout`
2 parents cfd3464 + 9328e9b commit 1cb92b6

File tree

8 files changed

+186
-41
lines changed

8 files changed

+186
-41
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# MCP TypeScript SDK
1+
# MCP TypeScript SDK ![NPM Version](https://img.shields.io/npm/v/%40modelcontextprotocol%2Fsdk)
22

33
TypeScript implementation of the Model Context Protocol (MCP), providing both client and server capabilities for integrating with LLM surfaces.
44

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@modelcontextprotocol/sdk",
3-
"version": "0.5.0",
3+
"version": "0.6.0",
44
"description": "Model Context Protocol implementation for TypeScript",
55
"license": "MIT",
66
"author": "Anthropic, PBC (https://anthropic.com)",

src/client/index.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
ListToolsRequestSchema,
1515
CreateMessageRequestSchema,
1616
ListRootsRequestSchema,
17+
ErrorCode,
1718
} from "../types.js";
1819
import { Transport } from "../shared/transport.js";
1920
import { Server } from "../server/index.js";
@@ -491,3 +492,58 @@ test("should handle client cancelling a request", async () => {
491492
// Request should be rejected
492493
await expect(listResourcesPromise).rejects.toBe("Cancelled by test");
493494
});
495+
496+
test("should handle request timeout", async () => {
497+
const server = new Server(
498+
{
499+
name: "test server",
500+
version: "1.0",
501+
},
502+
{
503+
capabilities: {
504+
resources: {},
505+
},
506+
},
507+
);
508+
509+
// Set up server with a delayed response
510+
server.setRequestHandler(
511+
ListResourcesRequestSchema,
512+
async (_request, extra) => {
513+
const timer = new Promise((resolve) => {
514+
const timeout = setTimeout(resolve, 100);
515+
extra.signal.addEventListener("abort", () => clearTimeout(timeout));
516+
});
517+
518+
await timer;
519+
return {
520+
resources: [],
521+
};
522+
},
523+
);
524+
525+
const [clientTransport, serverTransport] =
526+
InMemoryTransport.createLinkedPair();
527+
528+
const client = new Client(
529+
{
530+
name: "test client",
531+
version: "1.0",
532+
},
533+
{
534+
capabilities: {},
535+
},
536+
);
537+
538+
await Promise.all([
539+
client.connect(clientTransport),
540+
server.connect(serverTransport),
541+
]);
542+
543+
// Request with 0 msec timeout should fail immediately
544+
await expect(
545+
client.listResources(undefined, { timeout: 0 }),
546+
).rejects.toMatchObject({
547+
code: ErrorCode.RequestTimeout,
548+
});
549+
});

src/server/index.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
ListResourcesRequestSchema,
1515
ListToolsRequestSchema,
1616
SetLevelRequestSchema,
17+
ErrorCode,
1718
} from "../types.js";
1819
import { Transport } from "../shared/transport.js";
1920
import { InMemoryTransport } from "../inMemory.js";
@@ -475,3 +476,72 @@ test("should handle server cancelling a request", async () => {
475476
// Request should be rejected
476477
await expect(createMessagePromise).rejects.toBe("Cancelled by test");
477478
});
479+
test("should handle request timeout", async () => {
480+
const server = new Server(
481+
{
482+
name: "test server",
483+
version: "1.0",
484+
},
485+
{
486+
capabilities: {
487+
sampling: {},
488+
},
489+
},
490+
);
491+
492+
// Set up client that delays responses
493+
const client = new Client(
494+
{
495+
name: "test client",
496+
version: "1.0",
497+
},
498+
{
499+
capabilities: {
500+
sampling: {},
501+
},
502+
},
503+
);
504+
505+
client.setRequestHandler(
506+
CreateMessageRequestSchema,
507+
async (_request, extra) => {
508+
await new Promise((resolve, reject) => {
509+
const timeout = setTimeout(resolve, 100);
510+
extra.signal.addEventListener("abort", () => {
511+
clearTimeout(timeout);
512+
reject(extra.signal.reason);
513+
});
514+
});
515+
516+
return {
517+
model: "test",
518+
role: "assistant",
519+
content: {
520+
type: "text",
521+
text: "Test response",
522+
},
523+
};
524+
},
525+
);
526+
527+
const [clientTransport, serverTransport] =
528+
InMemoryTransport.createLinkedPair();
529+
530+
await Promise.all([
531+
client.connect(clientTransport),
532+
server.connect(serverTransport),
533+
]);
534+
535+
// Request with 0 msec timeout should fail immediately
536+
await expect(
537+
server.createMessage(
538+
{
539+
messages: [],
540+
maxTokens: 10,
541+
},
542+
{ timeout: 0 },
543+
),
544+
).rejects.toMatchObject({
545+
code: ErrorCode.RequestTimeout,
546+
});
547+
});

src/shared/protocol.ts

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ export type ProtocolOptions = {
3737
enforceStrictCapabilities?: boolean;
3838
};
3939

40+
/**
41+
* The default request timeout, in miliseconds.
42+
*/
43+
export const DEFAULT_REQUEST_TIMEOUT_MSEC = 60000;
44+
4045
/**
4146
* Options that can be given per request.
4247
*/
@@ -48,10 +53,15 @@ export type RequestOptions = {
4853

4954
/**
5055
* Can be used to cancel an in-flight request. This will cause an AbortError to be raised from request().
51-
*
52-
* Use abortAfterTimeout() to easily implement timeouts using this signal.
5356
*/
5457
signal?: AbortSignal;
58+
59+
/**
60+
* A timeout (in milliseconds) for this request. If exceeded, an McpError with code `RequestTimeout` will be raised from request().
61+
*
62+
* If not specified, `DEFAULT_REQUEST_TIMEOUT_MSEC` will be used as the timeout.
63+
*/
64+
timeout?: number;
5565
};
5666

5767
/**
@@ -381,7 +391,13 @@ export abstract class Protocol<
381391
};
382392
}
383393

394+
let timeoutId: ReturnType<typeof setTimeout> | undefined = undefined;
395+
384396
this._responseHandlers.set(messageId, (response) => {
397+
if (timeoutId !== undefined) {
398+
clearTimeout(timeoutId);
399+
}
400+
385401
if (options?.signal?.aborted) {
386402
return;
387403
}
@@ -398,24 +414,52 @@ export abstract class Protocol<
398414
}
399415
});
400416

401-
options?.signal?.addEventListener("abort", () => {
402-
const reason = options?.signal?.reason;
417+
const cancel = (reason: unknown) => {
403418
this._responseHandlers.delete(messageId);
404419
this._progressHandlers.delete(messageId);
405420

406-
this._transport?.send({
407-
jsonrpc: "2.0",
408-
method: "cancelled",
409-
params: {
410-
requestId: messageId,
411-
reason: String(reason),
412-
},
413-
});
421+
this._transport
422+
?.send({
423+
jsonrpc: "2.0",
424+
method: "cancelled",
425+
params: {
426+
requestId: messageId,
427+
reason: String(reason),
428+
},
429+
})
430+
.catch((error) =>
431+
this._onerror(new Error(`Failed to send cancellation: ${error}`)),
432+
);
414433

415434
reject(reason);
435+
};
436+
437+
options?.signal?.addEventListener("abort", () => {
438+
if (timeoutId !== undefined) {
439+
clearTimeout(timeoutId);
440+
}
441+
442+
cancel(options?.signal?.reason);
416443
});
417444

418-
this._transport.send(jsonrpcRequest).catch(reject);
445+
const timeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC;
446+
timeoutId = setTimeout(
447+
() =>
448+
cancel(
449+
new McpError(ErrorCode.RequestTimeout, "Request timed out", {
450+
timeout,
451+
}),
452+
),
453+
timeout,
454+
);
455+
456+
this._transport.send(jsonrpcRequest).catch((error) => {
457+
if (timeoutId !== undefined) {
458+
clearTimeout(timeoutId);
459+
}
460+
461+
reject(error);
462+
});
419463
});
420464
}
421465

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export const JSONRPCResponseSchema = z
105105
export enum ErrorCode {
106106
// SDK error codes
107107
ConnectionClosed = -1,
108+
RequestTimeout = -2,
108109

109110
// Standard JSON-RPC error codes
110111
ParseError = -32700,

src/utils.test.ts

Lines changed: 0 additions & 15 deletions
This file was deleted.

src/utils.ts

Lines changed: 0 additions & 11 deletions
This file was deleted.

0 commit comments

Comments
 (0)