parent
826e653c27
commit
b58aa8d8d8
2 changed files with 105 additions and 1 deletions
|
|
@ -734,7 +734,7 @@ func (t *ioConn) Read(ctx context.Context) (jsonrpc.Message, error) {
|
||||||
if batch {
|
if batch {
|
||||||
var respBatch *msgBatch // track incoming requests in the batch
|
var respBatch *msgBatch // track incoming requests in the batch
|
||||||
for _, msg := range msgs {
|
for _, msg := range msgs {
|
||||||
if req, ok := msg.(*jsonrpc.Request); ok {
|
if req, ok := msg.(*jsonrpc.Request); ok && req.IsCall() {
|
||||||
if respBatch == nil {
|
if respBatch == nil {
|
||||||
respBatch = &msgBatch{
|
respBatch = &msgBatch{
|
||||||
unresolved: make(map[jsonrpc2.ID]int),
|
unresolved: make(map[jsonrpc2.ID]int),
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
package mcp
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -171,6 +172,109 @@ func TestIOConnRead_EmptyMethod(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestIOConnReadBatchNotifications is a regression test for go-sdk#1256:
|
||||||
|
// notifications carried inside a JSON-RPC batch must not be tracked as awaiting
|
||||||
|
// a response. Only calls belong in a batch's response array, so a batch's
|
||||||
|
// notifications must neither keep it from completing nor pollute the
|
||||||
|
// per-connection duplicate-ID tracking.
|
||||||
|
func TestIOConnReadBatchNotifications(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
// input is one or more newline-separated batch payloads.
|
||||||
|
input string
|
||||||
|
// wantCalls records, for each message Read returns in order, whether it
|
||||||
|
// is a call (true) or a notification (false). Reading every message
|
||||||
|
// must succeed.
|
||||||
|
wantCalls []bool
|
||||||
|
// wantTrackedCalls is the number of in-flight call requests tracked across
|
||||||
|
// batches awaiting a response once every message has been read, before any
|
||||||
|
// response is written.
|
||||||
|
wantTrackedCalls int
|
||||||
|
// respondIDs are the call IDs to respond to, in order.
|
||||||
|
respondIDs []int64
|
||||||
|
// wantWrites are the batch response payloads expected on the wire, each
|
||||||
|
// matched as a substring.
|
||||||
|
wantWrites []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "call and notification is answered",
|
||||||
|
input: `[{"jsonrpc":"2.0","id":2,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":99}}]`,
|
||||||
|
wantCalls: []bool{true, false},
|
||||||
|
wantTrackedCalls: 1,
|
||||||
|
respondIDs: []int64{2},
|
||||||
|
wantWrites: []string{`[{"jsonrpc":"2.0","id":2,"result":{}}]`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only notifications keeps the connection open",
|
||||||
|
input: `[{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":98}},{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":99}}]`,
|
||||||
|
wantCalls: []bool{false, false},
|
||||||
|
wantTrackedCalls: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "repeated batches holding a notification",
|
||||||
|
input: `[{"jsonrpc":"2.0","id":2,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":99}}]` + "\n" +
|
||||||
|
`[{"jsonrpc":"2.0","id":3,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":99}}]`,
|
||||||
|
wantCalls: []bool{true, false, true, false},
|
||||||
|
wantTrackedCalls: 2,
|
||||||
|
respondIDs: []int64{2, 3},
|
||||||
|
wantWrites: []string{
|
||||||
|
`[{"jsonrpc":"2.0","id":2,"result":{}}]`,
|
||||||
|
`[{"jsonrpc":"2.0","id":3,"result":{}}]`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
sink := new(bytes.Buffer)
|
||||||
|
tr := newIOConn(rwc{
|
||||||
|
rc: io.NopCloser(strings.NewReader(tt.input)),
|
||||||
|
wc: nopCloserWriter{sink},
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { tr.Close() })
|
||||||
|
|
||||||
|
for i, wantCall := range tt.wantCalls {
|
||||||
|
msg, err := tr.Read(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Read() #%d: unexpected error: %v", i, err)
|
||||||
|
}
|
||||||
|
req, ok := msg.(*jsonrpc.Request)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Read() #%d = %T, want *jsonrpc.Request", i, msg)
|
||||||
|
}
|
||||||
|
if req.IsCall() != wantCall {
|
||||||
|
t.Fatalf("Read() #%d IsCall() = %t, want %t", i, req.IsCall(), wantCall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only calls are tracked as awaiting a response; notifications must
|
||||||
|
// not linger in the batch bookkeeping.
|
||||||
|
tr.batchMu.Lock()
|
||||||
|
gotTrackedCalls := len(tr.batches)
|
||||||
|
tr.batchMu.Unlock()
|
||||||
|
if gotTrackedCalls != tt.wantTrackedCalls {
|
||||||
|
t.Errorf("tracked calls = %d, want %d", gotTrackedCalls, tt.wantTrackedCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range tt.respondIDs {
|
||||||
|
resp := &jsonrpc.Response{ID: jsonrpc2.Int64ID(id), Result: []byte("{}")}
|
||||||
|
if err := tr.Write(ctx, resp); err != nil {
|
||||||
|
t.Fatalf("Write(response id=%d): %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
got := sink.String()
|
||||||
|
for _, want := range tt.wantWrites {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("batch responses = %q, missing %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestIOConnFrameCap verifies single inbound frame cap enforcement.
|
// TestIOConnFrameCap verifies single inbound frame cap enforcement.
|
||||||
func TestIOConnFrameCap(t *testing.T) {
|
func TestIOConnFrameCap(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue