mcpcompat/server: refuse dispatch of a request whose per-request context is gone
Maintainer thường phản hồi trong vòng 1 ngày
Đá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
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ả
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
-
StreamableHTTPServer.ServeHTTPstores the POST's request context under a random nonce and stamps it on the request (mcpcompat/server/transports.go:487-489), then clears it whenServeHTTPreturns (transports.go:502,defer s.mcp.clearPendingRequestContext(nonce)). -
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 exceptinitializeand notifications asynchronously (mcp/server.goaround line 2000), so dispatch can run afterServeHTTPhas returned. -
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} } } } -
The base context belongs to a different request: the
initializePOST for a session created on this replica (go-sdkconnectStreamable(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 dropnotifications/initializedand break every session. Decide request vs notification from the method/request type available in the middleware (for example thenotifications/method prefix, or go-sdk's request type), and add a test thatnotifications/initializedstill 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.
ServeHTTPalways overwrites the header withcrand.Text()(transports.go:487-489), so "nonce present, entry missing" reliably means the request was abandoned. Keep it that way. - Stateless mode.
ServeHTTPshort-circuits tos.handlerfor stateless servers after storing the nonce (transports.go:503-519). Check whether a stateless request can be dispatched after itsServeHTTPreturns; if it can, the same refusal applies; if it cannot, note why in a comment. - Rehydrated sessions. Requests on a rehydrated session go through
serveRehydratedwith the same nonce bridge. The refusal must apply there too; seerehydration_test.gofor 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-501andserver.go:829-837so 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):
- 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
initializeand notifications are handled synchronously. A recipe that works (it is how the equivalent toolhive test does it):- Register an
OnRegisterSessionhook (hooks.go:29-30,WithHooks,server.go:409) that records the session ID and then blocks on a channel. The hook runs inside theinitializedispatch, so while it blocks, every later message on that session waits in the queue. - Send
initializein a goroutine and wait until the hook has recorded the session ID. - Send a
tools/callwith thatMcp-Session-Idby calling the server'sServeHTTPin-process (httptest.NewRecorderplus a request built withhttptest.NewRequest(...).WithContext(ctx)wherectxis already cancelled). go-sdk queues the message andServeHTTPreturns at once, which clears the nonce entry. - Release the hook, let
initializefinish, 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 testsloghandler or a counter, so the test cannot pass just because the message was never dispatched.
- Register an
- Session still works afterwards. A normal
tools/callon the same session succeeds. - Notifications unaffected.
notifications/initialized(and a notification sent with an already-cancelled context) is still processed as today. - Rehydrated session. The same refusal on a session served by
serveRehydrated. - 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-coreinstacklok/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
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- 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.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của stacklok/toolhive-core
-
needs-triage
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 54/100
stacklok/toolhive-core#466 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
needs-triage
Độ khó 3/5 1-2 ngày Mức phù hợp với người mới 66/100
stacklok/toolhive-core#426 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
needs-triage
Độ khó 5/5 Hơn một tuần Mức phù hợp với người mới 8/100
stacklok/toolhive-core#343 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
needs-triage
Độ khó 5/5 Hơn một tuần Mức phù hợp với người mới 12/100
stacklok/toolhive-core#342 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
needs-triage
Độ khó 5/5 Hơn một tuần Mức phù hợp với người mới 15/100
stacklok/toolhive-core#341 ·
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của stacklok/toolhive-core
Issue tương tự
-
agent-butler-finding bug
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 82/100
jordansmall/spindrift#6414 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 68/100
Icinga/icinga-kubernetes#294 ·
Maintainer thường phản hồi trong vòng 4 ngày
-
Cluster details: the Control Plane version field has a different border color than its icon boxĐang mởkind/design sig/ui
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 92/100
kubermatic/dashboard#8449 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 65/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
filecoin-project/curio#1632 ·
Maintainer thường phản hồi trong vòng 1 ngày