mcpcompat/server: refuse dispatch of a request whose per-request context is gone

Đang mở
#414 0 bình luận 0 reaction 0 người được giao Xem trên GitHub

Maintainer thường phản hồi trong vòng 1 ngày

@jhrozek đang làm issue này rồi.

Từ ngày 26/10/2026.

  • #415 của @jhrozek — đang mở

Đánh giá

Độ khó
4/5
Thời gian dự kiến
3-5 ngày
Mức phù hợp với người mới
54/100
Loại issue
Lỗi
Độ rõ ràng
Đặc tả rõ ràng
Mức độ hoạt động
Sôi nổi
Công nghệ
go
Lĩnh vực
backend

Hướng nghiên cứu

Start in mcpcompat/server/server.go at sessionDispatchMiddleware and trace how it gets the request nonce and identifies notifications. Read the pending context handling in mcpcompat/server/transports.go, then use the existing context bridge test and rehydration_test.go to build coverage for queued, abandoned requests and notifications. Done means missing-context requests are refused before hooks on local and rehydrated sessions, while notifications and messages without a nonce retain their current behavior.

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

Mô tả

needs-triage

Summary

When a client disconnects after its POST is queued but before the message is dispatched, mcpcompat/server still dispatches it, on the session's base context instead of the request's own context. Refuse that dispatch instead.

This issue is written so it can be picked up as a self-contained task. Line numbers are against main at 79572a9.

Current behaviour

  1. StreamableHTTPServer.ServeHTTP stores the POST's request context under a random nonce and stamps it on the request (mcpcompat/server/transports.go:487-489), then clears it when ServeHTTP returns (transports.go:502, defer s.mcp.clearPendingRequestContext(nonce)).

  2. go-sdk (v1.8.0, mcp/streamable.go, servePOST) puts the message on the session's incoming queue and returns as soon as the client goes away; it does not drop queued messages when the request context is cancelled. go-sdk handles everything except initialize and notifications asynchronously (mcp/server.go around line 2000), so dispatch can run after ServeHTTP has returned.

  3. At dispatch, sessionDispatchMiddleware (mcpcompat/server/server.go:821) bridges the stored context only if the entry still exists (server.go:838-844). If it is gone, it silently keeps the session's base context:

    if re := req.GetExtra(); re != nil {
        if nonce := re.Header.Get(reqNonceHeader); nonce != "" {
            if reqCtx := s.pendingRequestContext(nonce); reqCtx != nil {
                ctx = &valueBridgeContext{Context: ctx, values: reqCtx}
            }
        }
    }
    
  4. The base context belongs to a different request: the initialize POST for a session created on this replica (go-sdk connectStreamable(req.Context(), ...)), or the first request that reached this replica for a rehydrated session (transports.go:773, srv.Connect(context.WithoutCancel(r.Context()), ...)). The before-hooks (fireBeforeHooks, server.go:850) and the handler then run with that other request's context values (caller identity, audit and telemetry attributes), for a call whose client is no longer there to receive the result.

The existing comment at transports.go:490-501 documents the notification case of this as a known limitation; it does not cover requests.

Proposed change

In sessionDispatchMiddleware, when a message carries X-MCP-Req-Nonce and pendingRequestContext(nonce) returns nil, and the message is a JSON-RPC request (not a notification), return an error instead of continuing. Do this before fireBeforeHooks, so neither hooks nor the handler run.

Details to get right:

  • Notifications keep today's behaviour. Notification-only POSTs are acknowledged with 202 before dispatch, so they routinely lose the entry (the limitation at transports.go:490-501). Refusing them would drop notifications/initialized and break every session. Decide request vs notification from the method/request type available in the middleware (for example the notifications/ method prefix, or go-sdk's request type), and add a test that notifications/initialized still works.
  • No nonce means no change. Messages without the header (anything not coming through StreamableHTTPServer.ServeHTTP, for example in-process or stdio use) must behave exactly as today.
  • The nonce cannot be spoofed. ServeHTTP always overwrites the header with crand.Text() (transports.go:487-489), so "nonce present, entry missing" reliably means the request was abandoned. Keep it that way.
  • Stateless mode. ServeHTTP short-circuits to s.handler for stateless servers after storing the nonce (transports.go:503-519). Check whether a stateless request can be dispatched after its ServeHTTP returns; if it can, the same refusal applies; if it cannot, note why in a comment.
  • Rehydrated sessions. Requests on a rehydrated session go through serveRehydrated with the same nonce bridge. The refusal must apply there too; see rehydration_test.go for the harness.
  • What to return. The client is gone, so the response is not delivered; the point is that the handler does not run. Return a JSON-RPC error (an internal error with a short message such as "request context no longer available") so go-sdk completes the request cleanly. Log at DEBUG, not WARN: ordinary client cancellations trigger this at request rate.
  • Spec note. This treats a disconnect before dispatch as cancellation, which differs from the MCP 2025-11-25 transport note that disconnection SHOULD NOT be interpreted as cancellation. State this deliberate deviation in a comment at the change.
  • Update the comments at transports.go:490-501 and server.go:829-837 so they describe the new behaviour for requests.

Tests

Add to mcpcompat/server (the bridge is already covered by TestPerRequestContext_NotClobberedConcurrently, server_test.go:~258, which is a good starting point for the harness):

  1. Abandoned request is refused. The ordering has to be forced, and a slow tool call will not do it: go-sdk handles calls asynchronously, so they do not hold the session queue. Only initialize and notifications are handled synchronously. A recipe that works (it is how the equivalent toolhive test does it):
    • Register an OnRegisterSession hook (hooks.go:29-30, WithHooks, server.go:409) that records the session ID and then blocks on a channel. The hook runs inside the initialize dispatch, so while it blocks, every later message on that session waits in the queue.
    • Send initialize in a goroutine and wait until the hook has recorded the session ID.
    • Send a tools/call with that Mcp-Session-Id by calling the server's ServeHTTP in-process (httptest.NewRecorder plus a request built with httptest.NewRequest(...).WithContext(ctx) where ctx is already cancelled). go-sdk queues the message and ServeHTTP returns at once, which clears the nonce entry.
    • Release the hook, let initialize finish, and assert the tool handler and the before-hooks never ran for that call (use channels or counters, with a bounded wait; no bare sleeps). Also assert a positive signal that the dispatch did happen and was refused, for example the new DEBUG log line captured with a test slog handler or a counter, so the test cannot pass just because the message was never dispatched.
  2. Session still works afterwards. A normal tools/call on the same session succeeds.
  3. Notifications unaffected. notifications/initialized (and a notification sent with an already-cancelled context) is still processed as today.
  4. Rehydrated session. The same refusal on a session served by serveRehydrated.
  5. Mutation check. Removing the new branch makes test 1 fail.

Run task test (race detector on) and task lint.

Acceptance criteria

  • A request whose stored per-request context is gone at dispatch is never handed to before-hooks or the handler, on local and rehydrated sessions.
  • Notifications and messages without a nonce behave as before.
  • Tests above pass under -race; comments updated; spec deviation stated in code.
  • After release, bump github.com/stacklok/toolhive-core in stacklok/toolhive (go.mod) and run its vMCP tests (task test, LABEL_FILTER=vmcp task test-e2e).

Out of scope

Anything beyond refusing abandoned request dispatches.

Ngôn ngữ chính
Go
Star
2
Fork
5
Merge trung bình
23 giờ 44 phút
Pull request đã merge (30 ngày)
133

Chuẩn bị môi trường

Dự án này không cung cấp dev container, Dockerfile hay hướng dẫn đóng góp, nên bạn cần tự thiết lập môi trường: hãy bắt đầu từ README và xem hướng dẫn đóng góp lần đầu của chúng tôi để biết các bước chung.

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 stacklok/toolhive-core

Tất cả issue của stacklok/toolhive-core

Issue tương tự

Thêm issue về Go

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.