mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
The client sent MCP-Protocol-Version on the initialize request itself. The spec requires the header on requests after initialization, and the offered version already travels in the initialize body. A server that validates the header rejected the handshake unless the client happened to offer a version it supports, so connecting depended on matching the error body and only worked for the two versions the client offers. - Send the header only after initialize succeeds, with the version the server negotiated - Keep the fallback loop, newest-first order and negotiated-version persistence for servers that still reject the initialize body version - Rewrite both version tests around this, including a 2025-03-26-only server, and assert that initialize carries no version header Tags: #mcp Co-Authored-By: htjulia <htjulia1@gmail.com>
236 lines
7.9 KiB
Go
236 lines
7.9 KiB
Go
package mcphttp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// A Streamable HTTP server whose slow_call tool takes 3s, so the client's
|
|
// per-request timeout can be observed: 1s cuts the call off, 10s lets it finish.
|
|
func TestStreamableClientRequestTimeout(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var req rpcRequest
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch req.Method {
|
|
case "initialize":
|
|
fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"result":{"protocolVersion":"2025-06-18","capabilities":{},"serverInfo":{"name":"t","version":"0"}}}`, req.ID)
|
|
case "notifications/initialized":
|
|
w.WriteHeader(http.StatusAccepted)
|
|
case "tools/list":
|
|
fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"result":{"tools":[{"name":"slow_call","description":"slow","inputSchema":{"type":"object"}}]}}`, req.ID)
|
|
case "tools/call":
|
|
time.Sleep(3 * time.Second)
|
|
fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"result":{"content":[{"type":"text","text":"done"}]}}`, req.ID)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
// timeout_seconds=1: the slow call is cut off well before the server answers.
|
|
c, err := New(ctx, "t", srv.URL, nil, false, 1)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
if _, err := c.Tools(ctx); err != nil {
|
|
t.Fatalf("tools/list: %v", err)
|
|
}
|
|
start := time.Now()
|
|
if _, err := c.Call(ctx, "slow_call", nil); err == nil {
|
|
t.Fatal("expected the 1s-timeout call to fail")
|
|
} else if elapsed := time.Since(start); elapsed > 2*time.Second {
|
|
t.Fatalf("call not cut off by the timeout: %v", elapsed)
|
|
}
|
|
_ = c.Close()
|
|
|
|
// timeout_seconds=10: the same slow call completes.
|
|
c2, err := New(ctx, "t", srv.URL, nil, false, 10)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
got, err := c2.Call(ctx, "slow_call", nil)
|
|
if err != nil || got != "done" {
|
|
t.Fatalf("Call() = %q, %v", got, err)
|
|
}
|
|
_ = c2.Close()
|
|
|
|
// timeout_seconds=0: falls back to the 120s default (call completes).
|
|
c3, err := New(ctx, "t", srv.URL, nil, false, 0)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
if got, err := c3.Call(ctx, "slow_call", nil); err != nil || got != "done" {
|
|
t.Fatalf("default-timeout Call() = %q, %v", got, err)
|
|
}
|
|
_ = c3.Close()
|
|
}
|
|
|
|
// initRequest captures what the client sent for one MCP request.
|
|
type initRequest struct {
|
|
method, header, bodyVersion string
|
|
}
|
|
|
|
func recordRequest(r *http.Request) (rpcRequest, initRequest) {
|
|
var req rpcRequest
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
rec := initRequest{method: req.Method, header: r.Header.Get("MCP-Protocol-Version")}
|
|
if p, ok := req.Params.(map[string]any); ok {
|
|
rec.bodyVersion, _ = p["protocolVersion"].(string)
|
|
}
|
|
return req, rec
|
|
}
|
|
|
|
// TestStreamableClientProtocolVersions uses spec-shaped servers that each support
|
|
// one version: they answer initialize with their own version, accept a request
|
|
// without MCP-Protocol-Version, and reject any other header value. The client
|
|
// must send no version header on initialize and the negotiated one afterwards.
|
|
func TestStreamableClientProtocolVersions(t *testing.T) {
|
|
for _, supported := range []string{"2025-11-25", "2025-06-18", "2025-03-26"} {
|
|
t.Run(supported, func(t *testing.T) {
|
|
var seen []initRequest
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
req, rec := recordRequest(r)
|
|
seen = append(seen, rec)
|
|
if rec.header != "" && rec.header != supported {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
fmt.Fprint(w, `{"code":"UNSUPPORTED_PROTOCOL_VERSION"}`)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch req.Method {
|
|
case "initialize":
|
|
fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"result":{"protocolVersion":%q,"capabilities":{},"serverInfo":{"name":"t","version":"0"}}}`, req.ID, supported)
|
|
case "notifications/initialized":
|
|
w.WriteHeader(http.StatusAccepted)
|
|
case "tools/list":
|
|
fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"result":{"tools":[]}}`, req.ID)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c, err := New(context.Background(), "t", srv.URL, nil, false, 0)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
defer c.Close()
|
|
if _, err := c.Tools(context.Background()); err != nil {
|
|
t.Fatalf("tools/list: %v", err)
|
|
}
|
|
if c.protocol != supported {
|
|
t.Fatalf("negotiated protocol = %q", c.protocol)
|
|
}
|
|
var inits int
|
|
for _, rec := range seen {
|
|
if rec.method == "initialize" {
|
|
inits++
|
|
if rec.header != "" {
|
|
t.Fatalf("initialize carried MCP-Protocol-Version %q", rec.header)
|
|
}
|
|
if rec.bodyVersion != protocolVersion {
|
|
t.Fatalf("initialize offered %q, want %q", rec.bodyVersion, protocolVersion)
|
|
}
|
|
continue
|
|
}
|
|
if rec.header != supported {
|
|
t.Fatalf("%s carried MCP-Protocol-Version %q, want %q", rec.method, rec.header, supported)
|
|
}
|
|
}
|
|
if inits != 1 {
|
|
t.Fatalf("initialize attempts = %d, want 1", inits)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestStreamableClientVersionRejectionForms keeps the fallback loop honest for
|
|
// servers that reject the initialize body version instead of negotiating: an
|
|
// HTTP 400 "Unsupported protocol version", the JSON-RPC -32602 error from the
|
|
// spec's example, and an unrelated failure that must not trigger a retry.
|
|
func TestStreamableClientVersionRejectionForms(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
reject func(w http.ResponseWriter, id int)
|
|
fallback bool
|
|
}{
|
|
{"http 400", func(w http.ResponseWriter, _ int) {
|
|
http.Error(w, "Bad Request: Unsupported protocol version", http.StatusBadRequest)
|
|
}, true},
|
|
{"json-rpc -32602", func(w http.ResponseWriter, id int) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"error":{"code":-32602,"message":"Unsupported protocol version","data":{"supported":["2025-06-18"],"requested":"2025-11-25"}}}`, id)
|
|
}, true},
|
|
{"unrelated 401", func(w http.ResponseWriter, _ int) {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
}, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var inits []initRequest
|
|
var after []initRequest
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
req, rec := recordRequest(r)
|
|
if req.Method == "initialize" {
|
|
inits = append(inits, rec)
|
|
if rec.bodyVersion != "2025-06-18" {
|
|
tc.reject(w, req.ID)
|
|
return
|
|
}
|
|
} else {
|
|
after = append(after, rec)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch req.Method {
|
|
case "initialize":
|
|
fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"result":{"protocolVersion":"2025-06-18","capabilities":{},"serverInfo":{"name":"t","version":"0"}}}`, req.ID)
|
|
case "notifications/initialized":
|
|
w.WriteHeader(http.StatusAccepted)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c, err := New(context.Background(), "t", srv.URL, nil, false, 0)
|
|
for _, rec := range inits {
|
|
if rec.header != "" {
|
|
t.Fatalf("initialize carried MCP-Protocol-Version %q", rec.header)
|
|
}
|
|
}
|
|
if !tc.fallback {
|
|
if err == nil {
|
|
_ = c.Close()
|
|
t.Fatal("expected connect error")
|
|
}
|
|
if len(inits) != 1 {
|
|
t.Fatalf("unrelated error retried: %d initialize attempts", len(inits))
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
defer c.Close()
|
|
if len(inits) != 2 || inits[0].bodyVersion != protocolVersion || inits[1].bodyVersion != "2025-06-18" {
|
|
t.Fatalf("fallback attempts = %+v", inits)
|
|
}
|
|
if c.protocol != "2025-06-18" {
|
|
t.Fatalf("negotiated protocol = %q", c.protocol)
|
|
}
|
|
if len(after) == 0 || after[0].method != "notifications/initialized" || after[0].header != "2025-06-18" {
|
|
t.Fatalf("requests after initialize = %+v", after)
|
|
}
|
|
})
|
|
}
|
|
}
|