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:
Guglielmo Colombo 2026-05-26 16:25:51 +02:00 • committed by GitHub
commit 4cbdd6a514
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -4,6 +4,247 @@
package mcp
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
)
func TestValidateRequestMeta(t *testing.T) {
tests := []struct {
name string
method string
isNotification bool
params any
wantUsesNew bool
wantErrContains string
}{
{
name: "no params: old protocol",
method: methodListTools,
params: nil,
wantUsesNew: false,
},
{
name: "no _meta: old protocol",
method: methodCallTool,
params: map[string]any{"name": "x"},
wantUsesNew: false,
},
{
name: "_meta without protocolVersion: old protocol",
method: methodCallTool,
params: map[string]any{
"_meta": map[string]any{"otherKey": "v"},
"name": "x",
},
wantUsesNew: false,
},
{
name: "new protocol with all required fields",
method: methodCallTool,
params: map[string]any{
"_meta": map[string]any{
MetaKeyProtocolVersion: protocolVersion20260630,
MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"},
MetaKeyClientCapabilities: map[string]any{},
},
"name": "x",
},
wantUsesNew: true,
},
{
name: "new protocol missing clientInfo",
method: methodCallTool,
params: map[string]any{
"_meta": map[string]any{
MetaKeyProtocolVersion: protocolVersion20260630,
MetaKeyClientCapabilities: map[string]any{},
},
"name": "x",
},
wantUsesNew: false,
wantErrContains: MetaKeyClientInfo,
},
{
name: "new protocol missing clientCapabilities",
method: methodCallTool,
params: map[string]any{
"_meta": map[string]any{
MetaKeyProtocolVersion: protocolVersion20260630,
MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"},
},
"name": "x",
},
wantUsesNew: false,
wantErrContains: MetaKeyClientCapabilities,
},
{
name: "notifications exempt from required fields",
method: notificationCancelled,
isNotification: true,
params: map[string]any{
"_meta": map[string]any{
MetaKeyProtocolVersion: protocolVersion20260630,
},
"requestId": "r1",
},
wantUsesNew: true,
},
{
name: "malformed _meta is ignored",
method: methodCallTool,
params: json.RawMessage(`{"_meta": "not an object", "name": "x"}`),
wantUsesNew: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var raw json.RawMessage
switch p := tc.params.(type) {
case json.RawMessage:
raw = p
default:
raw = mustMarshal(tc.params)
}
req := &jsonrpc.Request{Method: tc.method, Params: raw}
if !tc.isNotification {
req.ID = jsonrpc.ID{}
// Give the request an ID by parsing one.
id, err := jsonrpc.MakeID("test")
if err != nil {
t.Fatal(err)
}
req.ID = id
}
vmeta, err := validateRequestMeta(req)
usesNew := vmeta != nil && vmeta.usesNewProtocol
if usesNew != tc.wantUsesNew {
t.Errorf("usesNewProtocol = %v, want %v", usesNew, tc.wantUsesNew)
}
if tc.wantErrContains == "" {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
return
}
if err == nil {
t.Fatalf("expected error containing %q, got nil", tc.wantErrContains)
}
var jerr *jsonrpc.Error
if !errors.As(err, &jerr) {
t.Fatalf("expected *jsonrpc.Error, got %T: %v", err, err)
}
if jerr.Code != jsonrpc.CodeInvalidParams {
t.Errorf("error code = %d, want %d", jerr.Code, jsonrpc.CodeInvalidParams)
}
if !strings.Contains(jerr.Message, tc.wantErrContains) {
t.Errorf("error message %q does not contain %q", jerr.Message, tc.wantErrContains)
}
})
}
}
func TestServerRequest_PerRequestAccessors(t *testing.T) {
// A request carrying the new-protocol _meta fields populates the
// accessors with values from _meta.
caps := &ClientCapabilities{Sampling: &SamplingCapabilities{}}
info := &Implementation{Name: "c", Version: "1"}
params := &CallToolParamsRaw{
Meta: Meta{
MetaKeyProtocolVersion: protocolVersion20260630,
MetaKeyClientInfo: info,
MetaKeyClientCapabilities: caps,
},
Name: "x",
}
req := &ServerRequest[*CallToolParamsRaw]{Params: params}
if got := req.ProtocolVersion(); got != protocolVersion20260630 {
t.Errorf("ProtocolVersion = %q, want %q", got, protocolVersion20260630)
}
if got := req.ClientInfo(); got == nil || got.Name != "c" {
t.Errorf("ClientInfo = %+v, want Name=c", got)
}
if got := req.ClientCapabilities(); got == nil || got.Sampling == nil {
t.Errorf("ClientCapabilities = %+v, want non-nil Sampling", got)
}
}
func TestServerRequest_PerRequestAccessors_FromJSON(t *testing.T) {
// Values arriving over the wire are JSON maps; the accessors should
// re-decode them into typed Go values.
raw := json.RawMessage(`{
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-06-30",
"io.modelcontextprotocol/clientInfo": {"name": "wire-client", "version": "9"},
"io.modelcontextprotocol/clientCapabilities": {"sampling": {}}
},
"name": "tool"
}`)
var params CallToolParamsRaw
if err := json.Unmarshal(raw, &params); err != nil {
t.Fatal(err)
}
req := &ServerRequest[*CallToolParamsRaw]{Params: &params}
if got, want := req.ProtocolVersion(), protocolVersion20260630; got != want {
t.Errorf("ProtocolVersion = %q, want %q", got, want)
}
gotInfo := req.ClientInfo()
wantInfo := &Implementation{Name: "wire-client", Version: "9"}
if diff := cmp.Diff(wantInfo, gotInfo); diff != "" {
t.Errorf("ClientInfo mismatch (-want +got):\n%s", diff)
}
gotCaps := req.ClientCapabilities()
if gotCaps == nil || gotCaps.Sampling == nil {
t.Errorf("ClientCapabilities = %+v, want non-nil Sampling", gotCaps)
}
}
func TestServerRequest_PerRequestAccessors_FallbackToInitializeParams(t *testing.T) {
// With no _meta on the request, accessors must fall back to the
// session's InitializeParams (the old-protocol path).
ss := &ServerSession{}
ss.state.InitializeParams = &InitializeParams{
ProtocolVersion: protocolVersion20251125,
ClientInfo: &Implementation{Name: "old", Version: "0"},
Capabilities: &ClientCapabilities{Elicitation: &ElicitationCapabilities{}},
}
req := &ServerRequest[*CallToolParamsRaw]{
Session: ss,
Params: &CallToolParamsRaw{Name: "x"},
}
if got, want := req.ProtocolVersion(), protocolVersion20251125; got != want {
t.Errorf("ProtocolVersion fallback = %q, want %q", got, want)
}
if got := req.ClientInfo(); got == nil || got.Name != "old" {
t.Errorf("ClientInfo fallback = %+v, want Name=old", got)
}
if got := req.ClientCapabilities(); got == nil || got.Elicitation == nil {
t.Errorf("ClientCapabilities fallback = %+v, want non-nil Elicitation", got)
}
}
func TestServerRequest_PerRequestAccessors_Empty(t *testing.T) {
// With no _meta and no session, accessors return zero values.
req := &ServerRequest[*CallToolParamsRaw]{
Params: &CallToolParamsRaw{Name: "x"},
}
if got := req.ProtocolVersion(); got != "" {
t.Errorf("ProtocolVersion = %q, want empty", got)
}
if got := req.ClientInfo(); got != nil {
t.Errorf("ClientInfo = %+v, want nil", got)
}
if got := req.ClientCapabilities(); got != nil {
t.Errorf("ClientCapabilities = %+v, want nil", got)
}
}
// TODO(v0.3.0): rewrite this test.
// func TestToolValidate(t *testing.T) {
// // Check that the tool returned from NewServerTool properly validates its input schema.