Skip to content

Commit 96074c9

Browse files
fix: prevent duplicate in-flight request IDs (#1011)
Fixes #1004
1 parent 6a6ade9 commit 96074c9

2 files changed

Lines changed: 88 additions & 14 deletions

File tree

mcp/streamable.go

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1553,6 +1553,30 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
15531553
done := make(chan struct{})
15541554
stream.done = done
15551555
stream.protocolVersion = effectiveVersion
1556+
1557+
// Reject any call whose ID is already in flight on this session,
1558+
// atomically and without partial registration.
1559+
c.mu.Lock()
1560+
for reqID := range calls {
1561+
if _, ok := c.requestStreams[reqID]; ok {
1562+
c.mu.Unlock()
1563+
writeJSONRPCError(w, http.StatusBadRequest, reqID, &jsonrpc.Error{
1564+
Code: jsonrpc.CodeInvalidRequest,
1565+
Message: fmt.Sprintf("duplicate in-flight request ID %v", reqID.Raw()),
1566+
})
1567+
return
1568+
}
1569+
}
1570+
c.streams[stream.id] = stream
1571+
for reqID := range calls {
1572+
c.requestStreams[reqID] = stream.id
1573+
}
1574+
c.mu.Unlock()
1575+
1576+
// TODO(rfindley): if we have no event store, we should really cancel all
1577+
// remaining requests here, since the client will never get the results.
1578+
defer stream.release()
1579+
15561580
if c.jsonResponse {
15571581
// JSON mode: collect messages in pendingJSONMessages until done.
15581582
// Set pendingJSONMessages to a non-nil value to signal that this is an
@@ -1581,20 +1605,6 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
15811605
}
15821606
}
15831607

1584-
// TODO(rfindley): if we have no event store, we should really cancel all
1585-
// remaining requests here, since the client will never get the results.
1586-
defer stream.release()
1587-
1588-
// The stream is now set up to deliver messages.
1589-
//
1590-
// Register it before publishing incoming messages.
1591-
c.mu.Lock()
1592-
c.streams[stream.id] = stream
1593-
for reqID := range calls {
1594-
c.requestStreams[reqID] = stream.id
1595-
}
1596-
c.mu.Unlock()
1597-
15981608
// Publish incoming messages.
15991609
for _, msg := range incoming {
16001610
select {

mcp/streamable_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3716,3 +3716,67 @@ func TestStreamableHTTP_E2E_DiscoverSuccess(t *testing.T) {
37163716
t.Errorf("CallTool result[0] = %+v, want TextContent{Text:\"hello\"}", res.Content[0])
37173717
}
37183718
}
3719+
3720+
// A POST whose call ID is already in flight on the same session must be rejected
3721+
// with a JSON-RPC error, must not overwrite the existing request-to-stream
3722+
// mapping, and must not publish the duplicate message to the session's incoming
3723+
// channel.
3724+
func TestStreamableServerRejectsDuplicateInFlightRequestID(t *testing.T) {
3725+
id := jsonrpc2.Int64ID(1)
3726+
conn := &streamableServerConn{
3727+
logger: ensureLogger(nil),
3728+
incoming: make(chan jsonrpc.Message, 1),
3729+
done: make(chan struct{}),
3730+
streams: map[string]*stream{
3731+
"existing": {
3732+
id: "existing",
3733+
logger: ensureLogger(nil),
3734+
requests: map[jsonrpc.ID]struct{}{id: {}},
3735+
},
3736+
},
3737+
requestStreams: map[jsonrpc.ID]string{id: "existing"},
3738+
}
3739+
3740+
data, err := jsonrpc2.EncodeMessage(req(1, methodPing, &PingParams{}))
3741+
if err != nil {
3742+
t.Fatalf("EncodeMessage() error = %v", err)
3743+
}
3744+
ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond)
3745+
defer cancel()
3746+
httpReq := httptest.NewRequestWithContext(ctx, http.MethodPost, "/", bytes.NewReader(data))
3747+
httpReq.Header.Set("Content-Type", "application/json")
3748+
httpReq.Header.Set("Accept", "application/json, text/event-stream")
3749+
3750+
rec := httptest.NewRecorder()
3751+
conn.servePOST(rec, httpReq)
3752+
3753+
if rec.Code != http.StatusBadRequest {
3754+
t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String())
3755+
}
3756+
msg, err := jsonrpc2.DecodeMessage(rec.Body.Bytes())
3757+
if err != nil {
3758+
t.Fatalf("DecodeMessage() error = %v; body = %s", err, rec.Body.String())
3759+
}
3760+
resp, ok := msg.(*jsonrpc.Response)
3761+
if !ok {
3762+
t.Fatalf("response type = %T, want *jsonrpc.Response", msg)
3763+
}
3764+
if got := resp.ID.Raw(); got != int64(1) {
3765+
t.Fatalf("response ID = %v, want 1", got)
3766+
}
3767+
var jerr *jsonrpc.Error
3768+
if !errors.As(resp.Error, &jerr) {
3769+
t.Fatalf("response error = %v, want *jsonrpc.Error", resp.Error)
3770+
}
3771+
if jerr.Code != jsonrpc.CodeInvalidRequest {
3772+
t.Fatalf("error code = %d, want %d", jerr.Code, jsonrpc.CodeInvalidRequest)
3773+
}
3774+
if got := conn.requestStreams[id]; got != "existing" {
3775+
t.Fatalf("requestStreams[%v] = %q, want %q", id, got, "existing")
3776+
}
3777+
select {
3778+
case msg := <-conn.incoming:
3779+
t.Fatalf("duplicate request was published to incoming: %v", msg)
3780+
default:
3781+
}
3782+
}

0 commit comments

Comments
 (0)