[v2] Legacy-era cancel of a resumed request: notifications/cancelled inherits the request's resumptionToken, so the transport swallows the cancellation and opens a rogue resumed GET

Đang mở Phù hợp với người mới
#2,646 1 bình luận 0 reaction 0 người được giao Xem trên GitHub

Chưa có ai nhận issue này.

Đánh giá

Độ khó
2/5
Thời gian dự kiến
1-3 giờ
Mức phù hợp với người mới
88/100
Loại issue
Lỗi
Độ rõ ràng
Đặc tả rõ ràng
Mức độ hoạt động
Ít trao đổi
Công nghệ
typescript
Lĩnh vực
networking

Hướng nghiên cứu

Bắt đầu trong packages/core-internal/src/shared/protocol.ts, tại closure cancel quanh dòng 1454, sau đó kiểm tra _send và short-circuit resume của nó trong packages/client/src/client/streamableHttp.ts. Xác minh rằng việc hủy một request được resume từ thời legacy sẽ gửi notifications/cancelled dưới dạng POST thay vì mở một GET được resume, trong khi việc resume request thông thường vẫn không thay đổi.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Mô tả

v1 v2
What happened?

On a legacy-era (2025-11-25) Streamable HTTP connection, cancelling a request whose SSE stream has already resumed at least once (so the request carries a resumption token) never sends the cancellation to the server — instead the client opens a rogue resumed GET for the request it just cancelled.

Two halves, one on each side of the transport boundary:

  1. Protocol's cancel path forwards the original request's resumption options onto the cancellation notification. In packages/core-internal/src/shared/protocol.ts, the cancel closure (non-streamCloseCancels branch, ~line 1454) sends:

    this._transport?.send(
        this._envelopeOutbound({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: messageId, reason: String(reason) } }),
        { relatedRequestId, resumptionToken, onresumptiontoken }
    );
    

    resumptionToken here is the ORIGINAL request's token (from the caller's RequestOptions), not anything belonging to the notifications/cancelled message.

  2. StreamableHTTPClientTransport._send's resume short-circuit treats any send carrying a resumptionToken as a stream resume. Its first branch is if (resumptionToken) { this._startOrAuthSse({ resumptionToken, ... }); return; } — the message body is never POSTed.

Net effect on the legacy era whenever the original request had resumed (its Last-Event-ID is live) and then times out or is aborted:

  • The notifications/cancelled POST — the only spec cancellation signal on that era — is silently swallowed; the server keeps computing.
  • A GET with the old Last-Event-ID opens for a request the client has already settled; if the server later completes it, the replayed response surfaces for an unknown message id.

The modern era is unaffected (streamCloseCancels aborts the per-request stream instead of POSTing). Timeout-triggered cancels of never-resumed requests are also unaffected (resumptionToken undefined).

Repro path
  1. Legacy-era Streamable HTTP session; send a long-running request with onresumptiontoken capturing the token, server primes the SSE stream with an ID-bearing event and drops it so the client resumes (its internal resumptionToken is now set), request continues on the resumed stream.
  2. Let the request time out (or abort its signal).
  3. Observe the wire: no notifications/cancelled POST; instead a GET with Last-Event-ID for the cancelled request.
Two fix options
  • (a) protocol.ts (preferred): don't forward resumptionToken/onresumptiontoken on the cancellation send — they describe the original request's stream, not the notification. relatedRequestId can stay. One-line change, wire-visible only in that the cancellation actually goes out.
  • (b) transport-side: scope _send's resume short-circuit to messages that are requests (never notifications/responses), so a notification always POSTs regardless of stray resumption options. More defensive but leaves the odd protocol-side coupling in place.

Related context: the resume short-circuit path was touched by #2644 (observer threading), which is where this was noticed; the fix itself is deliberately not part of that PR since option (a) lives in core-internal.

SDK version

Read from main @ cc4b416 (packages/core-internal/src/shared/protocol.ts, packages/client/src/client/streamableHttp.ts); the same structure ships in @modelcontextprotocol/client@2.0.0 / core-internal 2.0.0.

Area

Client / Transports, core-internal


Generated by Claude Code

Ngôn ngữ chính
TypeScript
Star
13.4k
Fork
2.2k
Merge trung bình
3 ngày 12 giờ
Pull request đã merge (30 ngày)
3

Hướng dẫn đóng góp

Mở hướng dẫn đóng góp

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của modelcontextprotocol/typescript-sdk

Tất cả issue của modelcontextprotocol/typescript-sdk

Issue tương tự

Thêm issue về TypeScript

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.