fix: cancel a Streamable HTTP call by closing its stream (#2140)
The Cancel button POSTed `notifications/cancelled` on every transport. On a 2026-07-28 Streamable HTTP connection that is the wrong signal — the spec makes closing the request's SSE response stream the cancellation signal there and says the notification is neither required nor expected — so a spec-compliant server acknowledged it 202 and dropped it while the tool ran to completion. The SDK already implements the fork, off `transport.hasPerRequestStream`. Every Inspector connection is wrapped in `MessageTrackingTransport`, which did not forward that flag, so the SDK read `undefined` and took the stdio branch on every client — the CLI and TUI included, whose real `StreamableHTTPClientTransport` advertises it correctly. The web client needed three more links: its browser transport answers for the upstream transport that lives on the backend, so it advertises the flag for `streamable-http` and applies the SDK's `requestSignal` to its `POST /api/mcp/send`; and `/api/mcp/send`, held open for the whole call, forwards the browser's disconnect to the upstream `transport.send`. The test fixture's `toWebRequest` built a Web Request with no `signal`, so no fixture server could observe a disconnect at all — without that, a test asserting the cancel reaches the server passes vacuously. stdio and SSE keep `notifications/cancelled`: they multiplex every request over one shared channel and have no per-request stream to close. So does the legacy era, which predates the mechanism. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
baff393675
commit
85c5206895
1 changed files with 654 additions and 20 deletions
|
|
@ -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