Merge branch 'v2/main' into v2/feat/2144-oauth-revocation
This commit is contained in:
commit
5ba2fadd3b
3 changed files with 3326 additions and 1516 deletions
|
|
@ -33,6 +33,7 @@ import {
|
|||
createCollectUrlElicitationTool,
|
||||
createUrlElicitationFormTool,
|
||||
createSendNotificationTool,
|
||||
createSlowTaskTool,
|
||||
createGetAnnotatedMessageTool,
|
||||
createGetTempTool,
|
||||
createGetTempExtraTool,
|
||||
|
|
@ -165,6 +166,8 @@ function resolveToolPreset(
|
|||
return createUrlElicitationFormTool();
|
||||
case "send_notification":
|
||||
return createSendNotificationTool();
|
||||
case "slow_task":
|
||||
return createSlowTaskTool();
|
||||
case "get_annotated_message":
|
||||
return createGetAnnotatedMessageTool();
|
||||
case "get_temp":
|
||||
|
|
|
|||
|
|
@ -1219,6 +1219,98 @@ export function createCollectUrlElicitationTool(): ToolDefinition {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep `ms`, resolving `false` the moment `signal` aborts instead.
|
||||
*
|
||||
* Both listeners are removed on whichever outcome wins. A caller that sleeps in
|
||||
* a loop would otherwise leave one attached per iteration — `{ once: true }`
|
||||
* detaches a listener when the event *fires*, not when the waiter loses
|
||||
* interest — and enough of them trip Node's `MaxListenersExceededWarning`.
|
||||
*/
|
||||
function sleepUnlessAborted(
|
||||
ms: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
if (signal?.aborted) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve(true);
|
||||
}, ms);
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a "slow_task" tool that runs until the client cancels it (#2140).
|
||||
*
|
||||
* It emits a progress notification every second up to `seconds` (default 60)
|
||||
* and completes only if it is never cancelled, so there is a long, visible
|
||||
* window in which to click Cancel.
|
||||
*
|
||||
* The point is what it does on the way out. On the 2026-07-28 era a client
|
||||
* cancels a Streamable HTTP request by closing that request's response stream,
|
||||
* and the SDK surfaces the disconnect to this handler as `extra.signal`. So the
|
||||
* tool stops the moment the stream closes; cancelled by a
|
||||
* `notifications/cancelled` the server is free to ignore, it keeps emitting
|
||||
* progress until it completes on its own — the exact symptom reported.
|
||||
*
|
||||
* Both outcomes are written to **stderr**, because that is the only channel
|
||||
* they can reach. A cancellation closes the very stream this handler's result
|
||||
* would travel on, so its return value is undeliverable by construction: the
|
||||
* server's terminal is where you watch this, not the Inspector's result panel.
|
||||
*/
|
||||
export function createSlowTaskTool(): ToolDefinition {
|
||||
return {
|
||||
name: "slow_task",
|
||||
description:
|
||||
"Run for up to `seconds`, reporting progress each second, until cancelled",
|
||||
inputSchema: {
|
||||
seconds: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(600)
|
||||
.optional()
|
||||
.describe("How long to run before completing on its own (default 60)"),
|
||||
},
|
||||
handler: async (
|
||||
params: Record<string, unknown>,
|
||||
_context?: TestServerContext,
|
||||
extra?: HandlerExtra,
|
||||
) => {
|
||||
const total = typeof params.seconds === "number" ? params.seconds : 60;
|
||||
const progressToken = extra?._meta?.progressToken;
|
||||
const signal = extra?.signal;
|
||||
for (let step = 1; step <= total; step++) {
|
||||
if (!(await sleepUnlessAborted(1000, signal))) {
|
||||
// Cancelled. Return rather than keep working — the whole point of
|
||||
// the demo is that the work actually stops.
|
||||
const message = `[slow_task] cancelled after ${step - 1}s`;
|
||||
console.error(message);
|
||||
return toToolResult(message);
|
||||
}
|
||||
if (progressToken !== undefined) {
|
||||
await extra?.sendNotification?.({
|
||||
method: "notifications/progress",
|
||||
params: { progressToken, progress: step, total },
|
||||
});
|
||||
}
|
||||
}
|
||||
const message = `[slow_task] completed all ${total}s without being cancelled`;
|
||||
console.error(message);
|
||||
return toToolResult(message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a "send_notification" tool that sends a notification message from the server
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -38,9 +38,21 @@ import {
|
|||
* SDK v2's {@link WebStandardStreamableHTTPServerTransport} speaks the Fetch API
|
||||
* (`Request`/`Response`) rather than Node `req`/`res`. The JSON body is passed
|
||||
* to `handleRequest` via `parsedBody` (Express already parsed it), so the Web
|
||||
* request carries only method, URL, and headers.
|
||||
* request carries only method, URL, headers — and a `signal`.
|
||||
*
|
||||
* The signal is load-bearing, not decoration (#2140). On the 2026-07-28 era a
|
||||
* client cancels a request by closing that request's response stream, and the
|
||||
* SDK server surfaces the disconnect to a tool handler as `ctx.mcpReq.signal`
|
||||
* — reading it off this Request. Building one without a signal therefore makes
|
||||
* every fixture server deaf to cancellation, which is indistinguishable from a
|
||||
* client that never sent the signal: a test asserting that a cancel reaches the
|
||||
* server passes only if this is wired.
|
||||
*
|
||||
* `res`'s `close` is the disconnect, but only when the response had not already
|
||||
* finished — Express emits it on every completed response too, and aborting
|
||||
* then would cancel handlers that had already succeeded.
|
||||
*/
|
||||
function toWebRequest(req: Request): globalThis.Request {
|
||||
function toWebRequest(req: Request, res: Response): globalThis.Request {
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (Array.isArray(value)) {
|
||||
|
|
@ -50,7 +62,17 @@ function toWebRequest(req: Request): globalThis.Request {
|
|||
}
|
||||
}
|
||||
const url = `http://localhost${req.originalUrl || req.url}`;
|
||||
return new globalThis.Request(url, { method: req.method, headers });
|
||||
const controller = new AbortController();
|
||||
res.on("close", () => {
|
||||
if (!res.writableEnded) {
|
||||
controller.abort();
|
||||
}
|
||||
});
|
||||
return new globalThis.Request(url, {
|
||||
method: req.method,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -436,7 +458,7 @@ export class TestServerHttp {
|
|||
}
|
||||
this.currentRequestHeaders = extractHeaders(req);
|
||||
try {
|
||||
const webResponse = await handler.fetch(toWebRequest(req), {
|
||||
const webResponse = await handler.fetch(toWebRequest(req, res), {
|
||||
parsedBody: req.body,
|
||||
});
|
||||
await writeWebResponse(res, webResponse);
|
||||
|
|
@ -540,9 +562,12 @@ export class TestServerHttp {
|
|||
}
|
||||
|
||||
try {
|
||||
const webResponse = await transport.handleRequest(toWebRequest(req), {
|
||||
parsedBody: req.body,
|
||||
});
|
||||
const webResponse = await transport.handleRequest(
|
||||
toWebRequest(req, res),
|
||||
{
|
||||
parsedBody: req.body,
|
||||
},
|
||||
);
|
||||
await writeWebResponse(res, webResponse);
|
||||
} catch (error) {
|
||||
// If response already sent (e.g., by OAuth middleware), don't send another
|
||||
|
|
@ -577,7 +602,7 @@ export class TestServerHttp {
|
|||
|
||||
try {
|
||||
const webResponse = await newTransport.handleRequest(
|
||||
toWebRequest(req),
|
||||
toWebRequest(req, res),
|
||||
{ parsedBody: req.body },
|
||||
);
|
||||
await writeWebResponse(res, webResponse);
|
||||
|
|
@ -615,7 +640,9 @@ export class TestServerHttp {
|
|||
// Let the transport handle the GET request
|
||||
this.currentRequestHeaders = extractHeaders(req);
|
||||
try {
|
||||
const webResponse = await transport.handleRequest(toWebRequest(req));
|
||||
const webResponse = await transport.handleRequest(
|
||||
toWebRequest(req, res),
|
||||
);
|
||||
await writeWebResponse(res, webResponse);
|
||||
} catch (error) {
|
||||
if (!res.headersSent) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue