mcp: Implement stateless server (SEP-2275) (#965)
## Description
This PR lays the foundational server-side groundwork for the `>=
2026-06-30` sessionless and stateless feature introduced by
[SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575)
and SEP-2567, tracked in
[design/stateless.md](614460a225/design/stateless.md).
### SEP-2575: Stateless MCP
* **Per-request protocol detection in `ServerSession.handle()`**
Unmarshal `_meta` from the raw JSON-RPC params determines whether each
request follows the new protocol.
* **Per-request typed accessors on `ServerRequest[P]`**
```go
func (r *ServerRequest[P]) ProtocolVersion() string
func (r *ServerRequest[P]) ClientInfo() *Implementation
func (r *ServerRequest[P]) ClientCapabilities() *ClientCapabilities
```
* **Reject client->server `initialize`, `initialized` and `ping` for
new-protocol requests**
* **Per-request `_meta` field name constants**
Three constants for the wire-protocol field names
(`MetaKeyProtocolVersion`, `MetaKeyClientInfo`,
`MetaKeyClientCapabilities`)
* **Stop synthesizing fake `InitializeParams` for new-protocol
requests**
Fixes: #966
This commit is contained in:
parent
c044cdb734
commit
4cbdd6a514
1 changed files with 1113 additions and 62 deletions
|
|
@ -8,6 +8,7 @@ import (
|
|||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
|
|
@ -19,6 +20,7 @@ import (
|
|||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/jsonschema-go/jsonschema"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2"
|
||||
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
)
|
||||
|
||||
type testItem struct {
|
||||
|
|
@ -1093,3 +1095,167 @@ func TestServerCapabilitiesOverWire(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// SEP-2575 removes the initialization handshake. An `initialize` request
|
||||
// that opts into the new protocol via `_meta.protocolVersion` must be
|
||||
// rejected with `Method not found` (-32601).
|
||||
func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
params any
|
||||
wantReject bool
|
||||
}{
|
||||
{
|
||||
name: "initialize with new-protocol _meta is rejected",
|
||||
params: map[string]any{
|
||||
"_meta": map[string]any{
|
||||
MetaKeyProtocolVersion: protocolVersion20260630,
|
||||
MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"},
|
||||
MetaKeyClientCapabilities: map[string]any{},
|
||||
},
|
||||
"protocolVersion": protocolVersion20260630,
|
||||
},
|
||||
wantReject: true,
|
||||
},
|
||||
{
|
||||
name: "initialize without _meta is allowed (old protocol)",
|
||||
params: map[string]any{
|
||||
"protocolVersion": protocolVersion20251125,
|
||||
},
|
||||
wantReject: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ss := &ServerSession{server: NewServer(testImpl, nil)}
|
||||
id, err := jsonrpc.MakeID("test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := &jsonrpc.Request{
|
||||
ID: id,
|
||||
Method: methodInitialize,
|
||||
Params: mustMarshal(tc.params),
|
||||
}
|
||||
_, err = ss.handle(context.Background(), req)
|
||||
if tc.wantReject {
|
||||
if err == nil {
|
||||
t.Fatal("expected error rejecting initialize, got nil")
|
||||
}
|
||||
var jerr *jsonrpc.Error
|
||||
if !errors.As(err, &jerr) {
|
||||
t.Fatalf("error type = %T, want *jsonrpc.Error so the wire returns the right code", err)
|
||||
}
|
||||
if jerr.Code != jsonrpc.CodeMethodNotFound {
|
||||
t.Errorf("error code = %d, want %d (CodeMethodNotFound = -32601)", jerr.Code, jsonrpc.CodeMethodNotFound)
|
||||
}
|
||||
if !strings.Contains(jerr.Message, "initialize") {
|
||||
t.Errorf("error message %q does not mention %q", jerr.Message, "initialize")
|
||||
}
|
||||
} else {
|
||||
// Old-protocol initialize should be dispatched normally; any
|
||||
// CodeMethodNotFound here would mean the rejection branch
|
||||
// fired incorrectly.
|
||||
var jerr *jsonrpc.Error
|
||||
if errors.As(err, &jerr) && jerr.Code == jsonrpc.CodeMethodNotFound {
|
||||
t.Errorf("old-protocol initialize was incorrectly rejected: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("rejection error encodes to wire as code -32601", func(t *testing.T) {
|
||||
// Belt-and-braces check that the error type produced by handle()
|
||||
// actually serializes to JSON-RPC code -32601, not a bare 0.
|
||||
ss := &ServerSession{server: NewServer(testImpl, nil)}
|
||||
id, err := jsonrpc.MakeID("test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := &jsonrpc.Request{
|
||||
ID: id,
|
||||
Method: methodInitialize,
|
||||
Params: mustMarshal(map[string]any{
|
||||
"_meta": map[string]any{
|
||||
MetaKeyProtocolVersion: protocolVersion20260630,
|
||||
MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"},
|
||||
MetaKeyClientCapabilities: map[string]any{},
|
||||
},
|
||||
"protocolVersion": protocolVersion20260630,
|
||||
}),
|
||||
}
|
||||
_, handleErr := ss.handle(context.Background(), req)
|
||||
if handleErr == nil {
|
||||
t.Fatal("expected rejection error, got nil")
|
||||
}
|
||||
data, encErr := jsonrpc.EncodeMessage(&jsonrpc.Response{ID: id, Error: handleErr.(*jsonrpc.Error)})
|
||||
if encErr != nil {
|
||||
t.Fatal(encErr)
|
||||
}
|
||||
var wire struct {
|
||||
Error struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &wire); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wire.Error.Code != jsonrpc.CodeMethodNotFound {
|
||||
t.Errorf("wire error code = %d, want %d; full response = %s", wire.Error.Code, jsonrpc.CodeMethodNotFound, data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestServerSessionHandle_RejectsRemovedMethodsOnNewProtocol verifies that
|
||||
// the methods removed by SEP-2575 (`initialize`, `notifications/initialized`,
|
||||
// `ping`) all return Method not found when the request opts into the new
|
||||
// protocol via `_meta.protocolVersion`.
|
||||
func TestServerSessionHandle_RejectsRemovedMethodsOnNewProtocol(t *testing.T) {
|
||||
newProtoMeta := map[string]any{
|
||||
"_meta": map[string]any{
|
||||
MetaKeyProtocolVersion: protocolVersion20260630,
|
||||
MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"},
|
||||
MetaKeyClientCapabilities: map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
}{
|
||||
{"initialize", methodInitialize},
|
||||
{"ping", methodPing},
|
||||
{"notifications/initialized", notificationInitialized},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name+" rejected on new protocol", func(t *testing.T) {
|
||||
ss := &ServerSession{server: NewServer(testImpl, nil)}
|
||||
id, err := jsonrpc.MakeID("test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := &jsonrpc.Request{
|
||||
ID: id,
|
||||
Method: tc.method,
|
||||
Params: mustMarshal(newProtoMeta),
|
||||
}
|
||||
_, err = ss.handle(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Fatalf("method %q on new protocol: got nil error, want CodeMethodNotFound", tc.method)
|
||||
}
|
||||
var jerr *jsonrpc.Error
|
||||
if !errors.As(err, &jerr) {
|
||||
t.Fatalf("error type = %T, want *jsonrpc.Error", err)
|
||||
}
|
||||
if jerr.Code != jsonrpc.CodeMethodNotFound {
|
||||
t.Errorf("method %q: code = %d, want %d", tc.method, jerr.Code, jsonrpc.CodeMethodNotFound)
|
||||
}
|
||||
if !strings.Contains(jerr.Message, tc.method) {
|
||||
t.Errorf("method %q: message %q does not mention method name", tc.method, jerr.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue