parent
59185e6969
commit
2fdabde139
4 changed files with 133 additions and 2 deletions
|
|
@ -1962,7 +1962,7 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
if !initialized && !validatedMeta.usesNewProtocol {
|
if !initialized && !validatedMeta.usesNewProtocol && req.IsCall() {
|
||||||
ss.server.opts.Logger.Error("method invalid during initialization", "method", req.Method)
|
ss.server.opts.Logger.Error("method invalid during initialization", "method", req.Method)
|
||||||
return nil, fmt.Errorf("method %q is invalid during session initialization", req.Method)
|
return nil, fmt.Errorf("method %q is invalid during session initialization", req.Method)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -560,7 +560,15 @@ type validatedMeta struct {
|
||||||
// a non-nil error. clientInfo is optional; if present but invalid, an error is
|
// a non-nil error. clientInfo is optional; if present but invalid, an error is
|
||||||
// returned. Otherwise, it returns usesNewProtocol set to true and the populated
|
// returned. Otherwise, it returns usesNewProtocol set to true and the populated
|
||||||
// initializeParams.
|
// initializeParams.
|
||||||
|
// Notifications always report usesNewProtocol false and never error.
|
||||||
func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) {
|
func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) {
|
||||||
|
// SEP-2575 defines the `_meta` triple for calls only: NotificationParams
|
||||||
|
// declares `_meta` optional with no protocolVersion, so a notification
|
||||||
|
// neither selects the new protocol nor is discarded for carrying an
|
||||||
|
// incomplete triple.
|
||||||
|
if !req.IsCall() {
|
||||||
|
return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil
|
||||||
|
}
|
||||||
meta := extractRequestMeta(req.Params)
|
meta := extractRequestMeta(req.Params)
|
||||||
if meta == nil {
|
if meta == nil {
|
||||||
return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil
|
return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil
|
||||||
|
|
|
||||||
|
|
@ -1530,12 +1530,15 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
|
||||||
// The new (>= 2026-07-28) protocol is supported on the HTTP transport
|
// The new (>= 2026-07-28) protocol is supported on the HTTP transport
|
||||||
// only when [StreamableHTTPOptions.Stateless] is true.
|
// only when [StreamableHTTPOptions.Stateless] is true.
|
||||||
//
|
//
|
||||||
|
// The `_meta` triple is defined for calls only, notifications are
|
||||||
|
// excluded.
|
||||||
|
//
|
||||||
// TODO: this validation can be moved within validateMcpHeaders.
|
// TODO: this validation can be moved within validateMcpHeaders.
|
||||||
var metaVersion string
|
var metaVersion string
|
||||||
if meta := extractRequestMeta(jreq.Params); meta != nil {
|
if meta := extractRequestMeta(jreq.Params); meta != nil {
|
||||||
metaVersion, _ = meta[MetaKeyProtocolVersion].(string)
|
metaVersion, _ = meta[MetaKeyProtocolVersion].(string)
|
||||||
}
|
}
|
||||||
if protocolVersion >= protocolVersion20260728 || metaVersion != "" {
|
if (jreq.IsCall() && protocolVersion >= protocolVersion20260728) || metaVersion != "" {
|
||||||
// Extract again the protocol version from the context to see what the client
|
// Extract again the protocol version from the context to see what the client
|
||||||
// is advertising in the Mcp-Protocol-Version HTTP header.
|
// is advertising in the Mcp-Protocol-Version HTTP header.
|
||||||
headerVersion := protocolVersionFromContext(req.Context())
|
headerVersion := protocolVersionFromContext(req.Context())
|
||||||
|
|
|
||||||
|
|
@ -3712,6 +3712,126 @@ func TestStreamableStateless_AcceptsNewProtocol(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStreamableStateless_NotificationMetaValidation checks that the SEP-2575
|
||||||
|
// per-request `_meta` triple is required of calls only. NotificationParams
|
||||||
|
// declares `_meta` optional with no protocolVersion, so a notification that
|
||||||
|
// omits it is well-formed and must be accepted with 202.
|
||||||
|
func TestStreamableStateless_NotificationMetaValidation(t *testing.T) {
|
||||||
|
newProtocolMeta := map[string]any{
|
||||||
|
MetaKeyProtocolVersion: protocolVersion20260728,
|
||||||
|
MetaKeyClientInfo: map[string]any{"name": "new-proto-client", "version": "9.9"},
|
||||||
|
MetaKeyClientCapabilities: map[string]any{},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
message map[string]any
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "cancelled without meta",
|
||||||
|
message: map[string]any{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": notificationCancelled,
|
||||||
|
"params": map[string]any{"requestID": 1, "reason": "context canceled"},
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusAccepted,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "progress without meta",
|
||||||
|
message: map[string]any{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": notificationProgress,
|
||||||
|
"params": map[string]any{"progressToken": "t", "progress": 1},
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusAccepted,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "notification with matching meta",
|
||||||
|
message: map[string]any{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": notificationCancelled,
|
||||||
|
"params": map[string]any{
|
||||||
|
"_meta": newProtocolMeta,
|
||||||
|
"requestID": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusAccepted,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A notification that volunteers a version is still held to the
|
||||||
|
// header-match rule, so relaxing the requirement does not open a
|
||||||
|
// hole for inconsistent messages.
|
||||||
|
name: "notification with mismatched meta",
|
||||||
|
message: map[string]any{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": notificationCancelled,
|
||||||
|
"params": map[string]any{
|
||||||
|
"_meta": map[string]any{
|
||||||
|
MetaKeyProtocolVersion: "2025-06-18",
|
||||||
|
MetaKeyClientCapabilities: map[string]any{},
|
||||||
|
},
|
||||||
|
"requestID": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Calls remain subject to the requirement.
|
||||||
|
name: "call without meta",
|
||||||
|
message: map[string]any{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "tools/list",
|
||||||
|
"params": map[string]any{},
|
||||||
|
},
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
server := NewServer(testImpl, nil)
|
||||||
|
AddTool(server, &Tool{Name: "noop"},
|
||||||
|
func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) {
|
||||||
|
return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil
|
||||||
|
})
|
||||||
|
handler := NewStreamableHTTPHandler(
|
||||||
|
func(*http.Request) *Server { return server },
|
||||||
|
&StreamableHTTPOptions{Stateless: true},
|
||||||
|
)
|
||||||
|
httpServer := httptest.NewServer(handler)
|
||||||
|
defer httpServer.Close()
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
body, err := json.Marshal(test.message)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||||
|
req.Header.Set(protocolVersionHeader, protocolVersion20260728)
|
||||||
|
req.Header.Set(methodHeader, test.message["method"].(string))
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != test.wantStatus {
|
||||||
|
t.Fatalf("status = %d, want %d; body = %s", resp.StatusCode, test.wantStatus, respBody)
|
||||||
|
}
|
||||||
|
if test.wantStatus == http.StatusAccepted && len(respBody) > 0 {
|
||||||
|
t.Errorf("accepted notification returned body %q, want empty", respBody)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestStreamableClientUnsupportedVersionFallback exercises the full
|
// TestStreamableClientUnsupportedVersionFallback exercises the full
|
||||||
// SEP-2575 fallback. The client requests protocolVersion20260728, which the
|
// SEP-2575 fallback. The client requests protocolVersion20260728, which the
|
||||||
// server is configured to NOT advertise in supportedProtocolVersions for the
|
// server is configured to NOT advertise in supportedProtocolVersions for the
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue