From 7bfde44981f838739645a1a4df900c70c831be98 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Sat, 30 Aug 2025 08:16:06 -0400 Subject: [PATCH 001/418] mcp: statically type the server-side tool params (#378) Introduce CallToolParamsRaw on the server side, so that tool handlers see in the type system that a tool's arguments are a json.RawMessage. Fixes #377. --- mcp/mcp_test.go | 5 ++--- mcp/protocol.go | 26 ++++++++++++-------------- mcp/requests.go | 2 +- mcp/server.go | 6 ++---- 4 files changed, 17 insertions(+), 22 deletions(-) diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 8529be5..a16ac83 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -494,7 +494,6 @@ func TestEndToEnd(t *testing.T) { if result.Action != "accept" { t.Errorf("got action %q, want %q", result.Action, "accept") } - }) // Disconnect. @@ -1638,7 +1637,7 @@ func TestPointerArgEquivalence(t *testing.T) { // // We handle a few different types of results, to assert they behave the // same in all cases. - AddTool(s, &Tool{Name: "pointer"}, func(_ context.Context, req *ServerRequest[*CallToolParams], in *input) (*CallToolResult, *output, error) { + AddTool(s, &Tool{Name: "pointer"}, func(_ context.Context, req *CallToolRequest, in *input) (*CallToolResult, *output, error) { switch in.In { case "": return nil, nil, fmt.Errorf("must provide input") @@ -1652,7 +1651,7 @@ func TestPointerArgEquivalence(t *testing.T) { panic("unreachable") } }) - AddTool(s, &Tool{Name: "nonpointer"}, func(_ context.Context, req *ServerRequest[*CallToolParams], in input) (*CallToolResult, output, error) { + AddTool(s, &Tool{Name: "nonpointer"}, func(_ context.Context, req *CallToolRequest, in input) (*CallToolResult, output, error) { switch in.In { case "": return nil, output{}, fmt.Errorf("must provide input") diff --git a/mcp/protocol.go b/mcp/protocol.go index 382f745..2786065 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -40,6 +40,7 @@ type Annotations struct { Priority float64 `json:"priority,omitempty"` } +// CallToolParams is used by clients to call a tool. type CallToolParams struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. @@ -48,20 +49,13 @@ type CallToolParams struct { Arguments any `json:"arguments,omitempty"` } -// When unmarshalling CallToolParams on the server side, we need to delay unmarshaling of the arguments. -func (c *CallToolParams) UnmarshalJSON(data []byte) error { - var raw struct { - Meta `json:"_meta,omitempty"` - Name string `json:"name"` - RawArguments json.RawMessage `json:"arguments,omitempty"` - } - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - c.Meta = raw.Meta - c.Name = raw.Name - c.Arguments = raw.RawArguments - return nil +// CallToolParamsRaw is passed to tool handlers on the server. +type CallToolParamsRaw struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments,omitempty"` } // The server's response to a tool call. @@ -115,6 +109,10 @@ func (x *CallToolParams) isParams() {} func (x *CallToolParams) GetProgressToken() any { return getProgressToken(x) } func (x *CallToolParams) SetProgressToken(t any) { setProgressToken(x, t) } +func (x *CallToolParamsRaw) isParams() {} +func (x *CallToolParamsRaw) GetProgressToken() any { return getProgressToken(x) } +func (x *CallToolParamsRaw) SetProgressToken(t any) { setProgressToken(x, t) } + type CancelledParams struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. diff --git a/mcp/requests.go b/mcp/requests.go index 3afaac5..82b700f 100644 --- a/mcp/requests.go +++ b/mcp/requests.go @@ -7,7 +7,7 @@ package mcp type ( - CallToolRequest = ServerRequest[*CallToolParams] + CallToolRequest = ServerRequest[*CallToolParamsRaw] CompleteRequest = ServerRequest[*CompleteParams] GetPromptRequest = ServerRequest[*GetPromptParams] InitializedRequest = ServerRequest[*InitializedParams] diff --git a/mcp/server.go b/mcp/server.go index dd5d807..c496b33 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -9,7 +9,6 @@ import ( "context" "encoding/base64" "encoding/gob" - "encoding/json" "fmt" "iter" "maps" @@ -234,10 +233,9 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHan th := func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { // Unmarshal and validate args. - rawArgs := req.Params.Arguments.(json.RawMessage) var in In - if rawArgs != nil { - if err := unmarshalSchema(rawArgs, inputResolved, &in); err != nil { + if req.Params.Arguments != nil { + if err := unmarshalSchema(req.Params.Arguments, inputResolved, &in); err != nil { return nil, err } } From a617dcea31a776b8f9c54791ee96e1ed48c39e39 Mon Sep 17 00:00:00 2001 From: Adam Koszek Date: Sat, 30 Aug 2025 11:30:48 -0700 Subject: [PATCH 002/418] mcp/examples: HTTP server example with a simple client built-in (#168) I'm adding a practical example of an MCP with HTTP streaming: both client and server. Those are useful for testing real-world applications. --- examples/http/README.md | 66 +++++++++ examples/http/logging_middleware.go | 51 +++++++ examples/http/main.go | 203 ++++++++++++++++++++++++++++ 3 files changed, 320 insertions(+) create mode 100644 examples/http/README.md create mode 100644 examples/http/logging_middleware.go create mode 100644 examples/http/main.go diff --git a/examples/http/README.md b/examples/http/README.md new file mode 100644 index 0000000..16a4801 --- /dev/null +++ b/examples/http/README.md @@ -0,0 +1,66 @@ +# MCP HTTP Example + +This example demonstrates how to use the Model Context Protocol (MCP) over HTTP using the streamable transport. It includes both a server and client implementation. + +## Overview + +The example implements: +- A server that provides a `cityTime` tool +- A client that connects to the server, lists available tools, and calls the `cityTime` tool + +## Usage + +Start the Server + +```bash +go run main.go server +``` +This starts an MCP server on `http://localhost:8080` (default) that provides a `cityTime` tool. + +To run a client in another terminal: + +```bash +go run main.go client +``` + +The client will: +1. Connect to the server +2. List available tools +3. Call the `cityTime` tool for NYC, San Francisco, and Boston +4. Display the results + +At any given time you can pass a custom URL to the program to run it on a custom host/port: + +``` +go run main.go -host 0.0.0.0 -port 9000 server +``` + +## Testing with real-world MCP Clients + +Once the server is started, assuming it's the default +localhost:8080, you can try to add it to a popular MCP client: + + claude mcp add -t http timezone http://localhost:8080 + +Once added, Claude Code will be able to discover and use the `cityTime` tool provided by this server. + +In Claude Code: + + > what's the timezone + + ⏺ I'll get the current time in a major US city for you. + + ⏺ timezone - cityTime (MCP)(city: "nyc") + ⎿ The current time in New York City is 7:30:16 PM EDT on Wedn + esday, July 23, 2025 + + + ⏺ The current timezone is EDT (Eastern Daylight Time), and it's + 7:30 PM on Wednesday, July 23, 2025. + + > what timezones do you support? + + ⏺ The timezone tool supports three US cities: + - NYC (Eastern Time) + - SF (Pacific Time) + - Boston (Eastern Time) diff --git a/examples/http/logging_middleware.go b/examples/http/logging_middleware.go new file mode 100644 index 0000000..4266012 --- /dev/null +++ b/examples/http/logging_middleware.go @@ -0,0 +1,51 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package main + +import ( + "log" + "net/http" + "time" +) + +// responseWriter wraps http.ResponseWriter to capture the status code. +type responseWriter struct { + http.ResponseWriter + statusCode int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.statusCode = code + rw.ResponseWriter.WriteHeader(code) +} + +func loggingHandler(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + // Create a response writer wrapper to capture status code. + wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} + + // Log request details. + log.Printf("[REQUEST] %s | %s | %s %s", + start.Format(time.RFC3339), + r.RemoteAddr, + r.Method, + r.URL.Path) + + // Call the actual handler. + handler.ServeHTTP(wrapped, r) + + // Log response details. + duration := time.Since(start) + log.Printf("[RESPONSE] %s | %s | %s %s | Status: %d | Duration: %v", + time.Now().Format(time.RFC3339), + r.RemoteAddr, + r.Method, + r.URL.Path, + wrapped.statusCode, + duration) + }) +} diff --git a/examples/http/main.go b/examples/http/main.go new file mode 100644 index 0000000..682dc8d --- /dev/null +++ b/examples/http/main.go @@ -0,0 +1,203 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package main + +import ( + "context" + "flag" + "fmt" + "log" + "net/http" + "os" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var ( + host = flag.String("host", "localhost", "host to connect to/listen on") + port = flag.Int("port", 8000, "port number to connect to/listen on") + proto = flag.String("proto", "http", "if set, use as proto:// part of URL (ignored for server)") +) + +func main() { + out := flag.CommandLine.Output() + flag.Usage = func() { + fmt.Fprintf(out, "Usage: %s [-proto ] [-port ]\n\n", os.Args[0]) + fmt.Fprintf(out, "This program demonstrates MCP over HTTP using the streamable transport.\n") + fmt.Fprintf(out, "It can run as either a server or client.\n\n") + fmt.Fprintf(out, "Options:\n") + flag.PrintDefaults() + fmt.Fprintf(out, "\nExamples:\n") + fmt.Fprintf(out, " Run as server: %s server\n", os.Args[0]) + fmt.Fprintf(out, " Run as client: %s client\n", os.Args[0]) + fmt.Fprintf(out, " Custom host/port: %s -port 9000 -host 0.0.0.0 server\n", os.Args[0]) + os.Exit(1) + } + flag.Parse() + + if flag.NArg() != 1 { + fmt.Fprintf(out, "Error: Must specify 'client' or 'server' as first argument\n") + flag.Usage() + } + mode := flag.Arg(0) + + switch mode { + case "server": + if *proto != "http" { + log.Fatalf("Server only works with 'http' (you passed proto=%s)", *proto) + } + runServer(fmt.Sprintf("%s:%d", *host, *port)) + case "client": + runClient(fmt.Sprintf("%s://%s:%d", *proto, *host, *port)) + default: + fmt.Fprintf(os.Stderr, "Error: Invalid mode '%s'. Must be 'client' or 'server'\n\n", mode) + flag.Usage() + } +} + +// GetTimeParams defines the parameters for the cityTime tool. +type GetTimeParams struct { + City string `json:"city" jsonschema:"City to get time for (nyc, sf, or boston)"` +} + +// getTime implements the tool that returns the current time for a given city. +func getTime(ctx context.Context, ss *mcp.ServerSession, params *mcp.CallToolParamsFor[GetTimeParams]) (*mcp.CallToolResultFor[any], error) { + // Define time zones for each city + locations := map[string]string{ + "nyc": "America/New_York", + "sf": "America/Los_Angeles", + "boston": "America/New_York", + } + + city := params.Arguments.City + if city == "" { + city = "nyc" // Default to NYC + } + + // Get the timezone. + tzName, ok := locations[city] + if !ok { + return nil, fmt.Errorf("unknown city: %s", city) + } + + // Load the location. + loc, err := time.LoadLocation(tzName) + if err != nil { + return nil, fmt.Errorf("failed to load timezone: %w", err) + } + + // Get current time in that location. + now := time.Now().In(loc) + + // Format the response. + cityNames := map[string]string{ + "nyc": "New York City", + "sf": "San Francisco", + "boston": "Boston", + } + + response := fmt.Sprintf("The current time in %s is %s", + cityNames[city], + now.Format(time.RFC3339)) + + return &mcp.CallToolResultFor[any]{ + Content: []mcp.Content{ + &mcp.TextContent{Text: response}, + }, + }, nil +} + +func runServer(url string) { + // Create an MCP server. + server := mcp.NewServer(&mcp.Implementation{ + Name: "time-server", + Version: "1.0.0", + }, nil) + + // Add the cityTime tool. + mcp.AddTool(server, &mcp.Tool{ + Name: "cityTime", + Description: "Get the current time in NYC, San Francisco, or Boston", + }, getTime) + + // Create the streamable HTTP handler. + handler := mcp.NewStreamableHTTPHandler(func(req *http.Request) *mcp.Server { + return server + }, nil) + + handlerWithLogging := loggingHandler(handler) + + log.Printf("MCP server listening on %s", url) + log.Printf("Available tool: cityTime (cities: nyc, sf, boston)") + + // Start the HTTP server with logging handler. + if err := http.ListenAndServe(url, handlerWithLogging); err != nil { + log.Fatalf("Server failed: %v", err) + } +} + +func runClient(url string) { + ctx := context.Background() + + // Create the URL for the server. + log.Printf("Connecting to MCP server at %s", url) + + // Create a streamable client transport. + transport := mcp.NewStreamableClientTransport(url, nil) + + // Create an MCP client. + client := mcp.NewClient(&mcp.Implementation{ + Name: "time-client", + Version: "1.0.0", + }, nil) + + // Connect to the server. + session, err := client.Connect(ctx, transport) + if err != nil { + log.Fatalf("Failed to connect: %v", err) + } + defer session.Close() + + log.Printf("Connected to server (session ID: %s)", session.ID()) + + // First, list available tools. + log.Println("Listing available tools...") + toolsResult, err := session.ListTools(ctx, nil) + if err != nil { + log.Fatalf("Failed to list tools: %v", err) + } + + for _, tool := range toolsResult.Tools { + log.Printf(" - %s: %s\n", tool.Name, tool.Description) + } + + // Call the cityTime tool for each city. + cities := []string{"nyc", "sf", "boston"} + + log.Println("Getting time for each city...") + for _, city := range cities { + // Call the tool. + result, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "cityTime", + Arguments: map[string]any{ + "city": city, + }, + }) + if err != nil { + log.Printf("Failed to get time for %s: %v\n", city, err) + continue + } + + // Print the result. + for _, content := range result.Content { + if textContent, ok := content.(*mcp.TextContent); ok { + log.Printf(" %s", textContent.Text) + } + } + } + + log.Println("Client completed successfully") +} From be0a00cd0a57c30afdb9aac035b495744cc0c9f3 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Mon, 1 Sep 2025 09:24:30 -0400 Subject: [PATCH 003/418] examples/http: update to v0.3.0 Fix build breakage. Fixes #383. --- examples/http/main.go | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/examples/http/main.go b/examples/http/main.go index 682dc8d..188674a 100644 --- a/examples/http/main.go +++ b/examples/http/main.go @@ -64,7 +64,7 @@ type GetTimeParams struct { } // getTime implements the tool that returns the current time for a given city. -func getTime(ctx context.Context, ss *mcp.ServerSession, params *mcp.CallToolParamsFor[GetTimeParams]) (*mcp.CallToolResultFor[any], error) { +func getTime(ctx context.Context, req *mcp.CallToolRequest, params *GetTimeParams) (*mcp.CallToolResult, any, error) { // Define time zones for each city locations := map[string]string{ "nyc": "America/New_York", @@ -72,7 +72,7 @@ func getTime(ctx context.Context, ss *mcp.ServerSession, params *mcp.CallToolPar "boston": "America/New_York", } - city := params.Arguments.City + city := params.City if city == "" { city = "nyc" // Default to NYC } @@ -80,13 +80,13 @@ func getTime(ctx context.Context, ss *mcp.ServerSession, params *mcp.CallToolPar // Get the timezone. tzName, ok := locations[city] if !ok { - return nil, fmt.Errorf("unknown city: %s", city) + return nil, nil, fmt.Errorf("unknown city: %s", city) } // Load the location. loc, err := time.LoadLocation(tzName) if err != nil { - return nil, fmt.Errorf("failed to load timezone: %w", err) + return nil, nil, fmt.Errorf("failed to load timezone: %w", err) } // Get current time in that location. @@ -103,11 +103,11 @@ func getTime(ctx context.Context, ss *mcp.ServerSession, params *mcp.CallToolPar cityNames[city], now.Format(time.RFC3339)) - return &mcp.CallToolResultFor[any]{ + return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{Text: response}, }, - }, nil + }, nil, nil } func runServer(url string) { @@ -145,9 +145,6 @@ func runClient(url string) { // Create the URL for the server. log.Printf("Connecting to MCP server at %s", url) - // Create a streamable client transport. - transport := mcp.NewStreamableClientTransport(url, nil) - // Create an MCP client. client := mcp.NewClient(&mcp.Implementation{ Name: "time-client", @@ -155,7 +152,7 @@ func runClient(url string) { }, nil) // Connect to the server. - session, err := client.Connect(ctx, transport) + session, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: url}, nil) if err != nil { log.Fatalf("Failed to connect: %v", err) } From a3935c6be225392d4e9ba42a067090e325b0c685 Mon Sep 17 00:00:00 2001 From: Shusaku Yasoda <136243871+yasomaru@users.noreply.github.com> Date: Wed, 3 Sep 2025 00:49:46 +0900 Subject: [PATCH 004/418] examples/server: add example MCP server with JWT and API key (#339) This commit introduces a new example demonstrating the integration of authentication middleware with an MCP server. The server supports both JWT token and API key authentication, along with scope-based access control for various MCP tools. Key features include token generation endpoints, in-memory API key storage, and a health check endpoint. New files added: - `main.go`: Implements the MCP server and authentication logic. - `go.mod` and `go.sum`: Manage dependencies for the project. - `README.md`: Provides setup instructions, available endpoints, and example usage. This example serves as a reference for implementing secure access to MCP tools. Fixes #330 --- examples/server/auth-middleware/README.md | 247 ++++++++++++++ examples/server/auth-middleware/go.mod | 15 + examples/server/auth-middleware/go.sum | 10 + examples/server/auth-middleware/main.go | 392 ++++++++++++++++++++++ 4 files changed, 664 insertions(+) create mode 100644 examples/server/auth-middleware/README.md create mode 100644 examples/server/auth-middleware/go.mod create mode 100644 examples/server/auth-middleware/go.sum create mode 100644 examples/server/auth-middleware/main.go diff --git a/examples/server/auth-middleware/README.md b/examples/server/auth-middleware/README.md new file mode 100644 index 0000000..913022f --- /dev/null +++ b/examples/server/auth-middleware/README.md @@ -0,0 +1,247 @@ +# MCP Server with Auth Middleware + +This example demonstrates how to integrate the Go MCP SDK's `auth.RequireBearerToken` middleware with an MCP server to provide authenticated access to MCP tools and resources. + +## Features + +The server provides authentication and authorization capabilities for MCP tools: + +### 1. Authentication Methods + +- **JWT Token Authentication**: JSON Web Token-based authentication +- **API Key Authentication**: API key-based authentication +- **Scope-based Access Control**: Permission-based access to MCP tools + +### 2. MCP Integration + +- **Authenticated MCP Tools**: Tools that require authentication and check permissions +- **Token Generation**: Utility endpoints for generating test tokens +- **Middleware Integration**: Seamless integration with MCP server handlers + +## Setup + +```bash +cd examples/server/auth-middleware +go mod tidy +go run main.go +``` + +## Testing + +```bash +# Run all tests +go test -v + +# Run benchmark tests +go test -bench=. + +# Generate coverage report +go test -cover +``` + +## Endpoints + +### Public Endpoints (No Authentication Required) + +- `GET /health` - Health check + +### MCP Endpoints (Authentication Required) + +- `POST /mcp/jwt` - MCP server with JWT authentication +- `POST /mcp/apikey` - MCP server with API key authentication + +### Utility Endpoints + +- `GET /generate-token` - Generate JWT token +- `POST /generate-api-key` - Generate API key + +## Available MCP Tools + +The server provides three authenticated MCP tools: + +### 1. Say Hi (`say_hi`) + +A simple greeting tool that requires authentication. + +**Parameters:** +- None required + +**Required Scopes:** +- Any authenticated user + +### 2. Get User Info (`get_user_info`) + +Retrieves user information based on the provided user ID. + +**Parameters:** +- `user_id` (string): The user ID to get information for + +**Required Scopes:** +- `read` permission + +### 3. Create Resource (`create_resource`) + +Creates a new resource with the provided details. + +**Parameters:** +- `name` (string): The name of the resource +- `description` (string): The description of the resource +- `content` (string): The content of the resource + +**Required Scopes:** +- `write` permission + +## Example Usage + +### 1. Generating JWT Token and Using MCP Tools + +```bash +# Generate a token +curl 'http://localhost:8080/generate-token?user_id=alice&scopes=read,write' + +# Use MCP tool with JWT authentication +curl -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"say_hi","arguments":{}}}' \ + http://localhost:8080/mcp/jwt +``` + +### 2. Generating API Key and Using MCP Tools + +```bash +# Generate an API key +curl -X POST 'http://localhost:8080/generate-api-key?user_id=bob&scopes=read' + +# Use MCP tool with API key authentication +curl -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_user_info","arguments":{"user_id":"test"}}}' \ + http://localhost:8080/mcp/apikey +``` + +### 3. Testing Scope Restrictions + +```bash +# Access MCP tool requiring write scope +curl -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_resource","arguments":{"name":"test","description":"test resource","content":"test content"}}}' \ + http://localhost:8080/mcp/jwt +``` + +## Core Concepts + +### Authentication Integration + +This example demonstrates how to integrate `auth.RequireBearerToken` middleware with an MCP server to provide authenticated access. The MCP server operates as an HTTP handler protected by authentication middleware. + +### Key Features + +1. **MCP Server Integration**: Create MCP server using `mcp.NewServer` +2. **Authentication Middleware**: Protect MCP handlers with `auth.RequireBearerToken` +3. **Token Verification**: Validate tokens using provided `TokenVerifier` functions +4. **Scope Checking**: Verify required permissions (scopes) are present +5. **Expiration Validation**: Check that tokens haven't expired +6. **Context Injection**: Add verified token information to request context +7. **Authenticated MCP Tools**: Tools that operate based on authentication information +8. **Error Handling**: Return appropriate HTTP status codes and error messages on authentication failure + +### Implementation + +```go +// Create MCP server +server := mcp.NewServer(&mcp.Implementation{Name: "authenticated-mcp-server"}, nil) + +// Create authentication middleware +authMiddleware := auth.RequireBearerToken(verifier, &auth.RequireBearerTokenOptions{ + Scopes: []string{"read", "write"}, +}) + +// Create MCP handler +handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { + return server +}, nil) + +// Apply authentication middleware to MCP handler +authenticatedHandler := authMiddleware(customMiddleware(handler)) +``` + +### Parameters + +- **verifier**: Function to verify tokens (`TokenVerifier` type) +- **opts**: Authentication options + - `Scopes`: List of required permissions + - `ResourceMetadataURL`: OAuth 2.0 resource metadata URL + +### Error Responses + +- **401 Unauthorized**: Token is invalid, expired, or missing +- **403 Forbidden**: Required scopes are insufficient +- **WWW-Authenticate Header**: Included when resource metadata URL is configured + +## Implementation Details + +### 1. TokenVerifier Implementation + +```go +func jwtVerifier(ctx context.Context, tokenString string) (*auth.TokenInfo, error) { + // JWT token verification logic + // On success: Return TokenInfo + // On failure: Return auth.ErrInvalidToken +} +``` + +### 2. Using Authentication Information in MCP Tools + +```go +// Get authentication information in MCP tool +func MyTool(ctx context.Context, req *mcp.CallToolRequest, args MyArgs) (*mcp.CallToolResult, any, error) { + // Extract authentication info from request + userInfo := req.Extra.TokenInfo + + // Check scopes + if !slices.Contains(userInfo.Scopes, "read") { + return nil, nil, fmt.Errorf("insufficient permissions: read scope required") + } + + // Execute tool logic + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "Tool executed successfully"}, + }, + }, nil, nil +} +``` + +### 3. Middleware Composition + +```go +// Combine authentication middleware with custom middleware +authenticatedHandler := authMiddleware(customMiddleware(mcpHandler)) +``` + +## Security Best Practices + +1. **Environment Variables**: Use environment variables for JWT secrets in production +2. **Database Storage**: Store API keys in a database +3. **HTTPS Usage**: Always use HTTPS in production environments +4. **Token Expiration**: Set appropriate token expiration times +5. **Principle of Least Privilege**: Grant only the minimum required scopes + +## Use Cases + +**Ideal for:** + +- MCP servers requiring authentication and authorization +- Applications needing scope-based access control +- Systems requiring both JWT and API key authentication +- Projects needing secure MCP tool access +- Scenarios requiring audit trails and permission management + +**Examples:** + +- Enterprise MCP servers with user management +- Multi-tenant MCP applications +- Secure API gateways with MCP integration +- Development environments with authentication requirements +- Production systems requiring fine-grained access control diff --git a/examples/server/auth-middleware/go.mod b/examples/server/auth-middleware/go.mod new file mode 100644 index 0000000..402c0aa --- /dev/null +++ b/examples/server/auth-middleware/go.mod @@ -0,0 +1,15 @@ +module auth-middleware-example + +go 1.23.0 + +require ( + github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/modelcontextprotocol/go-sdk v0.3.0 +) + +require ( + github.com/google/jsonschema-go v0.2.1-0.20250825175020-748c325cec76 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect +) + +replace github.com/modelcontextprotocol/go-sdk => ../../../ diff --git a/examples/server/auth-middleware/go.sum b/examples/server/auth-middleware/go.sum new file mode 100644 index 0000000..eea5bdb --- /dev/null +++ b/examples/server/auth-middleware/go.sum @@ -0,0 +1,10 @@ +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.2.1-0.20250825175020-748c325cec76 h1:mBlBwtDebdDYr+zdop8N62a44g+Nbv7o2KjWyS1deR4= +github.com/google/jsonschema-go v0.2.1-0.20250825175020-748c325cec76/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= diff --git a/examples/server/auth-middleware/main.go b/examples/server/auth-middleware/main.go new file mode 100644 index 0000000..f472b76 --- /dev/null +++ b/examples/server/auth-middleware/main.go @@ -0,0 +1,392 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "slices" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// This example demonstrates how to integrate auth.RequireBearerToken middleware +// with an MCP server to provide authenticated access to MCP tools and resources. + +var httpAddr = flag.String("http", ":8080", "HTTP address to listen on") + +// JWTClaims represents the claims in our JWT tokens. +// In a real application, you would include additional claims like issuer, audience, etc. +type JWTClaims struct { + UserID string `json:"user_id"` // User identifier + Scopes []string `json:"scopes"` // Permissions/roles for the user + jwt.RegisteredClaims +} + +// APIKey represents an API key with associated scopes. +// In production, this would be stored in a database with additional metadata. +type APIKey struct { + Key string `json:"key"` // The actual API key value + UserID string `json:"user_id"` // User identifier + Scopes []string `json:"scopes"` // Permissions/roles for this key +} + +// In-memory storage for API keys (in production, use a database). +// This is for demonstration purposes only. +var apiKeys = map[string]*APIKey{ + "sk-1234567890abcdef": { + Key: "sk-1234567890abcdef", + UserID: "user1", + Scopes: []string{"read", "write"}, + }, + "sk-abcdef1234567890": { + Key: "sk-abcdef1234567890", + UserID: "user2", + Scopes: []string{"read"}, + }, +} + +// JWT secret (in production, use environment variables). +// This should be a strong, randomly generated secret in real applications. +var jwtSecret = []byte("your-secret-key") + +// generateToken creates a JWT token for testing purposes. +// In a real application, this would be handled by your authentication service. +func generateToken(userID string, scopes []string, expiresIn time.Duration) (string, error) { + // Create JWT claims with user information and scopes. + claims := JWTClaims{ + UserID: userID, + Scopes: scopes, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiresIn)), // Token expiration + IssuedAt: jwt.NewNumericDate(time.Now()), // Token issuance time + NotBefore: jwt.NewNumericDate(time.Now()), // Token validity start time + }, + } + + // Create and sign the JWT token. + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(jwtSecret) +} + +// verifyJWT verifies JWT tokens and returns TokenInfo for the auth middleware. +// This function implements the TokenVerifier interface required by auth.RequireBearerToken. +func verifyJWT(ctx context.Context, tokenString string) (*auth.TokenInfo, error) { + // Parse and validate the JWT token. + token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (any, error) { + // Verify the signing method is HMAC. + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return jwtSecret, nil + }) + + if err != nil { + // Return standard error for invalid tokens. + return nil, fmt.Errorf("%w: %v", auth.ErrInvalidToken, err) + } + + // Extract claims and verify token validity. + if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid { + return &auth.TokenInfo{ + Scopes: claims.Scopes, // User permissions + Expiration: claims.ExpiresAt.Time, // Token expiration time + }, nil + } + + return nil, fmt.Errorf("%w: invalid token claims", auth.ErrInvalidToken) +} + +// verifyAPIKey verifies API keys and returns TokenInfo for the auth middleware. +// This function implements the TokenVerifier interface required by auth.RequireBearerToken. +func verifyAPIKey(ctx context.Context, apiKey string) (*auth.TokenInfo, error) { + // Look up the API key in our storage. + key, exists := apiKeys[apiKey] + if !exists { + return nil, fmt.Errorf("%w: API key not found", auth.ErrInvalidToken) + } + + // API keys don't expire in this example, but you could add expiration logic here. + // For demonstration, we set a 24-hour expiration. + return &auth.TokenInfo{ + Scopes: key.Scopes, // User permissions + Expiration: time.Now().Add(24 * time.Hour), // 24 hour expiration + }, nil +} + +// MCP Tool Arguments +type getUserInfoArgs struct { + UserID string `json:"user_id" jsonschema:"the user ID to get information for"` +} + +type createResourceArgs struct { + Name string `json:"name" jsonschema:"the name of the resource"` + Description string `json:"description" jsonschema:"the description of the resource"` + Content string `json:"content" jsonschema:"the content of the resource"` +} + +// SayHi is a simple MCP tool that requires authentication +func SayHi(ctx context.Context, req *mcp.CallToolRequest, args struct{}) (*mcp.CallToolResult, any, error) { + // Extract user information from request (v0.3.0+) + userInfo := req.Extra.TokenInfo + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Hello! You have scopes: %v", userInfo.Scopes)}, + }, + }, nil, nil +} + +// GetUserInfo is an MCP tool that requires read scope +func GetUserInfo(ctx context.Context, req *mcp.CallToolRequest, args getUserInfoArgs) (*mcp.CallToolResult, any, error) { + // Extract user information from request (v0.3.0+) + userInfo := req.Extra.TokenInfo + + // Check if user has read scope. + if !slices.Contains(userInfo.Scopes, "read") { + return nil, nil, fmt.Errorf("insufficient permissions: read scope required") + } + + userData := map[string]any{ + "requested_user_id": args.UserID, + "your_scopes": userInfo.Scopes, + "message": "User information retrieved successfully", + } + + userDataJSON, err := json.Marshal(userData) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal user data: %w", err) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(userDataJSON)}, + }, + }, nil, nil +} + +// CreateResource is an MCP tool that requires write scope +func CreateResource(ctx context.Context, req *mcp.CallToolRequest, args createResourceArgs) (*mcp.CallToolResult, any, error) { + // Extract user information from request (v0.3.0+) + userInfo := req.Extra.TokenInfo + + // Check if user has write scope. + if !slices.Contains(userInfo.Scopes, "write") { + return nil, nil, fmt.Errorf("insufficient permissions: write scope required") + } + + resourceInfo := map[string]any{ + "name": args.Name, + "description": args.Description, + "content": args.Content, + "created_by": "authenticated_user", + "created_at": time.Now().Format(time.RFC3339), + } + + resourceInfoJSON, err := json.Marshal(resourceInfo) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal resource info: %w", err) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Resource created successfully: %s", string(resourceInfoJSON))}, + }, + }, nil, nil +} + +// authMiddleware extracts token information and adds it to the context +func authMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // In a real application, you would extract token info from the auth middleware's context + // For this example, we simulate the token info that would be available + ctx := context.WithValue(r.Context(), "user_info", &auth.TokenInfo{ + Scopes: []string{"read", "write"}, + Expiration: time.Now().Add(time.Hour), + }) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// createMCPServer creates an MCP server with authentication-aware tools +func createMCPServer() *mcp.Server { + server := mcp.NewServer(&mcp.Implementation{Name: "authenticated-mcp-server"}, nil) + + // Add tools that require authentication. + mcp.AddTool(server, &mcp.Tool{ + Name: "say_hi", + Description: "A simple greeting tool that requires authentication", + }, SayHi) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_user_info", + Description: "Get user information (requires read scope)", + }, GetUserInfo) + + mcp.AddTool(server, &mcp.Tool{ + Name: "create_resource", + Description: "Create a new resource (requires write scope)", + }, CreateResource) + + return server +} + +func main() { + flag.Parse() + + // Create the MCP server. + server := createMCPServer() + + // Create authentication middleware. + jwtAuth := auth.RequireBearerToken(verifyJWT, &auth.RequireBearerTokenOptions{ + Scopes: []string{"read"}, // Require "read" permission + }) + + apiKeyAuth := auth.RequireBearerToken(verifyAPIKey, &auth.RequireBearerTokenOptions{ + Scopes: []string{"read"}, // Require "read" permission + }) + + // Create HTTP handler with authentication. + handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { + return server + }, nil) + + // Apply authentication middleware to the MCP handler. + authenticatedHandler := jwtAuth(authMiddleware(handler)) + apiKeyHandler := apiKeyAuth(authMiddleware(handler)) + + // Create router for different authentication methods. + http.HandleFunc("/mcp/jwt", authenticatedHandler.ServeHTTP) + http.HandleFunc("/mcp/apikey", apiKeyHandler.ServeHTTP) + + // Add utility endpoints for token generation. + http.HandleFunc("/generate-token", func(w http.ResponseWriter, r *http.Request) { + // Get user ID from query parameters (default: "test-user"). + userID := r.URL.Query().Get("user_id") + if userID == "" { + userID = "test-user" + } + + // Get scopes from query parameters (default: ["read", "write"]). + scopes := strings.Split(r.URL.Query().Get("scopes"), ",") + if len(scopes) == 1 && scopes[0] == "" { + scopes = []string{"read", "write"} + } + + // Get expiration time from query parameters (default: 1 hour). + expiresIn := 1 * time.Hour + if expStr := r.URL.Query().Get("expires_in"); expStr != "" { + if exp, err := time.ParseDuration(expStr); err == nil { + expiresIn = exp + } + } + + // Generate the JWT token. + token, err := generateToken(userID, scopes, expiresIn) + if err != nil { + http.Error(w, "Failed to generate token", http.StatusInternalServerError) + return + } + + // Return the generated token. + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "token": token, + "type": "Bearer", + }) + }) + + http.HandleFunc("/generate-api-key", func(w http.ResponseWriter, r *http.Request) { + // Generate a random API key using cryptographically secure random bytes. + bytes := make([]byte, 16) + if _, err := rand.Read(bytes); err != nil { + http.Error(w, "Failed to generate random bytes", http.StatusInternalServerError) + return + } + apiKey := "sk-" + base64.URLEncoding.EncodeToString(bytes) + + // Get user ID from query parameters (default: "test-user"). + userID := r.URL.Query().Get("user_id") + if userID == "" { + userID = "test-user" + } + + // Get scopes from query parameters (default: ["read"]). + scopes := strings.Split(r.URL.Query().Get("scopes"), ",") + if len(scopes) == 1 && scopes[0] == "" { + scopes = []string{"read"} + } + + // Store the new API key in our in-memory storage. + // In production, this would be stored in a database. + apiKeys[apiKey] = &APIKey{ + Key: apiKey, + UserID: userID, + Scopes: scopes, + } + + // Return the generated API key. + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "api_key": apiKey, + "type": "Bearer", + }) + }) + + // Health check endpoint. + http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "healthy", + "time": time.Now().Format(time.RFC3339), + }) + }) + + // Start the HTTP server. + log.Println("Authenticated MCP Server") + log.Println("========================") + log.Println("Server starting on", *httpAddr) + log.Println() + log.Println("Available endpoints:") + log.Println(" GET /health - Health check (no auth)") + log.Println(" GET /generate-token - Generate JWT token") + log.Println(" POST /generate-api-key - Generate API key") + log.Println(" POST /mcp/jwt - MCP endpoint (JWT auth)") + log.Println(" POST /mcp/apikey - MCP endpoint (API key auth)") + log.Println() + log.Println("Available MCP Tools:") + log.Println(" - say_hi - Simple greeting (any auth)") + log.Println(" - get_user_info - Get user info (read scope)") + log.Println(" - create_resource - Create resource (write scope)") + log.Println() + log.Println("Example usage:") + log.Println(" # Generate a token") + log.Println(" curl 'http://localhost:8080/generate-token?user_id=alice&scopes=read,write'") + log.Println() + log.Println(" # Use MCP with JWT authentication") + log.Println(" curl -H 'Authorization: Bearer ' -H 'Content-Type: application/json' \\") + log.Println(" -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"say_hi\",\"arguments\":{}}}' \\") + log.Println(" http://localhost:8080/mcp/jwt") + log.Println() + log.Println(" # Generate an API key") + log.Println(" curl -X POST 'http://localhost:8080/generate-api-key?user_id=bob&scopes=read'") + log.Println() + log.Println(" # Use MCP with API key authentication") + log.Println(" curl -H 'Authorization: Bearer ' -H 'Content-Type: application/json' \\") + log.Println(" -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"get_user_info\",\"arguments\":{\"user_id\":\"test\"}}}' \\") + log.Println(" http://localhost:8080/mcp/apikey") + + log.Fatal(http.ListenAndServe(*httpAddr, nil)) +} From 24fc13eb88446dbda49c3cbb1b990a50fd096ffd Mon Sep 17 00:00:00 2001 From: Brad Hoekstra Date: Tue, 2 Sep 2025 14:19:53 -0400 Subject: [PATCH 005/418] cleanup: remove reference to deleted jsonschema package Signed-off-by: Brad Hoekstra --- README.md | 6 +----- internal/readme/README.src.md | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index bffd48a..126aa52 100644 --- a/README.md +++ b/README.md @@ -33,16 +33,12 @@ open-ended discussion. See [CONTRIBUTING.md](/CONTRIBUTING.md) for details. ## Package documentation -The SDK consists of three importable packages: +The SDK consists of two importable packages: - The [`github.com/modelcontextprotocol/go-sdk/mcp`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp) package defines the primary APIs for constructing and using MCP clients and servers. -- The - [`github.com/modelcontextprotocol/go-sdk/jsonschema`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/jsonschema) - package provides an implementation of [JSON - Schema](https://json-schema.org/), used for MCP tool input and output schema. - The [`github.com/modelcontextprotocol/go-sdk/jsonrpc`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/jsonrpc) package is for users implementing their own transports. diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md index bafcbc7..40e0aa1 100644 --- a/internal/readme/README.src.md +++ b/internal/readme/README.src.md @@ -32,16 +32,12 @@ open-ended discussion. See [CONTRIBUTING.md](/CONTRIBUTING.md) for details. ## Package documentation -The SDK consists of three importable packages: +The SDK consists of two importable packages: - The [`github.com/modelcontextprotocol/go-sdk/mcp`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp) package defines the primary APIs for constructing and using MCP clients and servers. -- The - [`github.com/modelcontextprotocol/go-sdk/jsonschema`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/jsonschema) - package provides an implementation of [JSON - Schema](https://json-schema.org/), used for MCP tool input and output schema. - The [`github.com/modelcontextprotocol/go-sdk/jsonrpc`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/jsonrpc) package is for users implementing their own transports. From 07b65d76a7fdb710e757f26e2c03418609bc1b4b Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Tue, 2 Sep 2025 14:32:12 -0400 Subject: [PATCH 006/418] examples/server/distributed: a stateless distributed server Add an example of a server that proxies HTTP requests to multiple stateless streamable MCP backends. For #148 --- examples/client/loadtest/main.go | 12 +- examples/server/distributed/main.go | 166 ++++++++++++++++++++++++++++ examples/server/everything/main.go | 28 ++++- 3 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 examples/server/distributed/main.go diff --git a/examples/client/loadtest/main.go b/examples/client/loadtest/main.go index 2c6a5c0..d5a04c2 100644 --- a/examples/client/loadtest/main.go +++ b/examples/client/loadtest/main.go @@ -33,7 +33,7 @@ var ( workers = flag.Int("workers", 10, "number of concurrent workers") timeout = flag.Duration("timeout", 1*time.Second, "request timeout") qps = flag.Int("qps", 100, "tool calls per second, per worker") - v = flag.Bool("v", false, "if set, enable verbose logging of results") + verbose = flag.Bool("v", false, "if set, enable verbose logging") ) func main() { @@ -56,8 +56,8 @@ func main() { parentCtx, cancel := context.WithTimeout(context.Background(), *duration) defer cancel() - parentCtx, restoreSignal := signal.NotifyContext(parentCtx, os.Interrupt) - defer restoreSignal() + parentCtx, stop := signal.NotifyContext(parentCtx, os.Interrupt) + defer stop() var ( start = time.Now() @@ -91,12 +91,12 @@ func main() { return // test ended } failure.Add(1) - if *v { + if *verbose { log.Printf("FAILURE: %v", err) } } else { success.Add(1) - if *v { + if *verbose { data, err := json.Marshal(res) if err != nil { log.Fatalf("marshalling result: %v", err) @@ -108,7 +108,7 @@ func main() { }() } wg.Wait() - restoreSignal() // call restore signal (redundantly) here to allow ctrl-c to work again + stop() // restore the interrupt signal // Print stats. var ( diff --git a/examples/server/distributed/main.go b/examples/server/distributed/main.go new file mode 100644 index 0000000..43d959e --- /dev/null +++ b/examples/server/distributed/main.go @@ -0,0 +1,166 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// The distributed command is an example of a distributed MCP server. +// +// It forks multiple child processes (according to the -child_ports flag), each +// of which is a streamable HTTP MCP server with the 'inc' tool, and proxies +// incoming http requests to them. +// +// Distributed MCP servers must be stateless, because there's no guarantee that +// subsequent requests for a session land on the same backend. However, they +// may still have logical session IDs, as can be seen with verbose logging +// (-v). +// +// Example: +// +// ./distributed -http=localhost:8080 -child_ports=8081,8082 +package main + +import ( + "context" + "flag" + "fmt" + "log" + "net/http" + "net/http/httputil" + "net/url" + "os" + "os/exec" + "os/signal" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const childPortVar = "MCP_CHILD_PORT" + +var ( + httpAddr = flag.String("http", "", "if set, use streamable HTTP at this address, instead of stdin/stdout") + childPorts = flag.String("child_ports", "", "comma-separated child ports to distribute to") + verbose = flag.Bool("v", false, "if set, enable verbose logging") +) + +func main() { + // This command runs as either a parent or a child, depending on whether + // childPortVar is set (a.k.a. the fork-and-exec trick). + // + // Each child is a streamable HTTP server, and the parent is a reverse proxy. + flag.Parse() + if v := os.Getenv(childPortVar); v != "" { + child(v) + } else { + parent() + } +} + +func parent() { + exe, err := os.Executable() + if err != nil { + log.Fatal(err) + } + + if *httpAddr == "" { + log.Fatal("must provide -http") + } + if *childPorts == "" { + log.Fatal("must provide -child_ports") + } + + // Ensure that children are cleaned up on CTRL-C + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + // Start the child processes. + ports := strings.Split(*childPorts, ",") + var wg sync.WaitGroup + childURLs := make([]*url.URL, len(ports)) + for i, port := range ports { + wg.Add(1) + childURL := fmt.Sprintf("http://localhost:%s", port) + childURLs[i], err = url.Parse(childURL) + if err != nil { + log.Fatal(err) + } + cmd := exec.CommandContext(ctx, exe, os.Args[1:]...) + cmd.Env = append(os.Environ(), fmt.Sprintf("%s=%s", childPortVar, port)) + cmd.Stderr = os.Stderr + go func() { + defer wg.Done() + log.Printf("starting child %d at %s", i, childURL) + if err := cmd.Run(); err != nil && ctx.Err() == nil { + log.Printf("child %d failed: %v", i, err) + } else { + log.Printf("child %d exited gracefully", i) + } + }() + } + + // Start a reverse proxy that round-robin's requests to each backend. + var nextBackend atomic.Int64 + server := http.Server{ + Addr: *httpAddr, + Handler: &httputil.ReverseProxy{ + Rewrite: func(r *httputil.ProxyRequest) { + child := int(nextBackend.Add(1)) % len(childURLs) + if *verbose { + log.Printf("dispatching to localhost:%s", ports[child]) + } + r.SetURL(childURLs[child]) + }, + }, + } + + wg.Add(1) + go func() { + defer wg.Done() + if err := server.ListenAndServe(); err != nil && ctx.Err() == nil { + log.Printf("Server failed: %v", err) + } + }() + + log.Printf("Serving at %s (CTRL-C to cancel)", *httpAddr) + + <-ctx.Done() + stop() // restore the interrupt signal + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Attempt the graceful shutdown. + if err := server.Shutdown(shutdownCtx); err != nil { + log.Fatalf("Server shutdown failed: %v", err) + } + + // Wait for the subprocesses and http server to stop. + wg.Wait() + + log.Println("Server shutdown gracefully.") +} + +func child(port string) { + // Create a server with a single tool that increments a counter. + server := mcp.NewServer(&mcp.Implementation{Name: "counter"}, nil) + + var count atomic.Int64 + inc := func(ctx context.Context, req *mcp.CallToolRequest, args struct{}) (*mcp.CallToolResult, struct{ Count int64 }, error) { + n := count.Add(1) + if *verbose { + log.Printf("request %d (session %s)", n, req.Session.ID()) + } + return nil, struct{ Count int64 }{n}, nil + } + mcp.AddTool(server, &mcp.Tool{Name: "inc"}, inc) + + handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { + return server + }, &mcp.StreamableHTTPOptions{ + Stateless: true, + }) + log.Printf("child listening on localhost:%s", port) + log.Fatal(http.ListenAndServe(fmt.Sprintf("localhost:%s", port), handler)) +} diff --git a/examples/server/everything/main.go b/examples/server/everything/main.go index d2b7b33..0b81919 100644 --- a/examples/server/everything/main.go +++ b/examples/server/everything/main.go @@ -11,19 +11,40 @@ import ( "fmt" "log" "net/http" + _ "net/http/pprof" "net/url" "os" + "runtime" "strings" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) -var httpAddr = flag.String("http", "", "if set, use streamable HTTP at this address, instead of stdin/stdout") +var ( + httpAddr = flag.String("http", "", "if set, use streamable HTTP at this address, instead of stdin/stdout") + pprofAddr = flag.String("pprof", "", "if set, host the pprof debugging server at this address") +) func main() { flag.Parse() + if *pprofAddr != "" { + // For debugging memory leaks, add an endpoint to trigger a few garbage + // collection cycles and ensure the /debug/pprof/heap endpoint only reports + // reachable memory. + http.DefaultServeMux.Handle("/gc", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + for range 3 { + runtime.GC() + } + fmt.Fprintln(w, "GC'ed") + })) + go func() { + // DefaultServeMux was mutated by the /debug/pprof import. + http.ListenAndServe(*pprofAddr, http.DefaultServeMux) + }() + } + opts := &mcp.ServerOptions{ Instructions: "Use this server!", CompletionHandler: complete, // support completions by setting this handler @@ -56,7 +77,10 @@ func main() { return server }, nil) log.Printf("MCP handler listening at %s", *httpAddr) - http.ListenAndServe(*httpAddr, handler) + if *pprofAddr != "" { + log.Printf("pprof listening at http://%s/debug/pprof", *pprofAddr) + } + log.Fatal(http.ListenAndServe(*httpAddr, handler)) } else { t := &mcp.LoggingTransport{Transport: &mcp.StdioTransport{}, Writer: os.Stderr} if err := server.Run(context.Background(), t); err != nil { From 7769b2a959e8a793dabd9974994ba4ba139e1b84 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Wed, 3 Sep 2025 10:37:27 -0400 Subject: [PATCH 007/418] mcp: set content to marshaled output (#398) The ToolHandler constructed by ToolFor sets the result's Content to the marshaled output, following the spec's suggestion. See https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content. Also, set StructuredContent to the marshaled RawMessage to avoid a double marshal. Fixes #391. --- mcp/server.go | 30 ++++++++++++++++++++---------- mcp/streamable_test.go | 10 ++++++++-- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index c496b33..2e7fbcc 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -9,6 +9,7 @@ import ( "context" "encoding/base64" "encoding/gob" + "encoding/json" "fmt" "iter" "maps" @@ -261,26 +262,35 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHan // TODO(v0.3.0): Validate out. _ = outputResolved - // TODO: return the serialized JSON in a TextContent block, as per spec? - // https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content - // But people may use res.Content for other things. if res == nil { res = &CallToolResult{} } - if res.Content == nil { - res.Content = []Content{} // avoid returning 'null' - } - res.StructuredContent = out + // Marshal the output and put the RawMessage in the StructuredContent field. + var outval any = out if elemZero != nil { // Avoid typed nil, which will serialize as JSON null. - // Instead, use the zero value of the non-zero + // Instead, use the zero value of the unpointered type. var z Out if any(out) == any(z) { // zero is only non-nil if Out is a pointer type - res.StructuredContent = elemZero + outval = elemZero } } + outbytes, err := json.Marshal(outval) + if err != nil { + return nil, fmt.Errorf("marshaling output: %w", err) + } + res.StructuredContent = json.RawMessage(outbytes) // avoid a second marshal over the wire + + // If the Content field isn't being used, return the serialized JSON in a + // TextContent block, as the spec suggests: + // https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content. + if res.Content == nil { + res.Content = []Content{&TextContent{ + Text: string(outbytes), + }} + } return res, nil - } + } // end of handler return &tt, th, nil } diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index c99ca78..0cb7f95 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1175,7 +1175,10 @@ func TestStreamableStateless(t *testing.T) { req(2, "tools/call", &CallToolParams{Name: "greet", Arguments: hiParams{Name: "World"}}), }, wantMessages: []jsonrpc.Message{ - resp(2, &CallToolResult{Content: []Content{&TextContent{Text: "hi World"}}}, nil), + resp(2, &CallToolResult{ + Content: []Content{&TextContent{Text: "hi World"}}, + StructuredContent: json.RawMessage("null"), + }, nil), }, wantSessionID: false, }, @@ -1186,7 +1189,10 @@ func TestStreamableStateless(t *testing.T) { req(2, "tools/call", &CallToolParams{Name: "greet", Arguments: hiParams{Name: "foo"}}), }, wantMessages: []jsonrpc.Message{ - resp(2, &CallToolResult{Content: []Content{&TextContent{Text: "hi foo"}}}, nil), + resp(2, &CallToolResult{ + Content: []Content{&TextContent{Text: "hi foo"}}, + StructuredContent: json.RawMessage("null"), + }, nil), }, wantSessionID: false, }, From 063fb121491cf576c3a425445b813af668c4bab4 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Wed, 3 Sep 2025 11:33:40 -0400 Subject: [PATCH 008/418] mcp: unexport ToolFor (#402) The rationale is provided on the issue. Also, document the low-level nature of Server.AddTool. (drive-by change) Fixes #401. --- mcp/server.go | 42 ++++++++++++++++-------------------------- 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index 2e7fbcc..114489a 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -154,10 +154,18 @@ func (s *Server) RemovePrompts(names ...string) { // If present, the output schema must also have type "object". // // When the handler is invoked as part of a CallTool request, req.Params.Arguments -// will be a json.RawMessage. Unmarshaling the arguments and validating them against the -// input schema are the handler author's responsibility. +// will be a json.RawMessage. // -// Most users should use the top-level function [AddTool]. +// Unmarshaling the arguments and validating them against the input schema are the +// caller's responsibility. +// +// Validating the result against the output schema, if any, is the caller's responsibility. +// +// Setting the result's Content, StructuredContent and IsError fields are the caller's +// responsibility. +// +// Most users should use the top-level function [AddTool], which handles all these +// responsibilities. func (s *Server) AddTool(t *Tool, h ToolHandler) { if t.InputSchema == nil { // This prevents the tool author from forgetting to write a schema where @@ -181,26 +189,6 @@ func (s *Server) AddTool(t *Tool, h ToolHandler) { func() bool { s.tools.add(st); return true }) } -// ToolFor returns a shallow copy of t and a [ToolHandler] that wraps h. -// -// If the tool's input schema is nil, it is set to the schema inferred from the In -// type parameter, using [jsonschema.For]. The In type parameter must be a map -// or a struct, so that its inferred JSON Schema has type "object". -// -// For tools that don't return structured output, Out should be 'any'. -// Otherwise, if the tool's output schema is nil the output schema is set to -// the schema inferred from Out, which must be a map or a struct. -// -// Most users will call [AddTool]. Use [ToolFor] if you wish to modify the -// tool's schemas or wrap the ToolHandler before calling [Server.AddTool]. -func ToolFor[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHandler) { - tt, hh, err := toolForErr(t, h) - if err != nil { - panic(fmt.Sprintf("ToolFor: tool %q: %v", t.Name, err)) - } - return tt, hh -} - // TODO(v0.3.0): test func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHandler, error) { tt := *t @@ -335,10 +323,12 @@ func setSchema[T any](sfield **jsonschema.Schema, rfield **jsonschema.Resolved) // For tools that don't return structured output, Out should be 'any'. // Otherwise, if the tool's output schema is nil the output schema is set to // the schema inferred from Out, which must be a map or a struct. -// -// It is a convenience for s.AddTool(ToolFor(t, h)). func AddTool[In, Out any](s *Server, t *Tool, h ToolHandlerFor[In, Out]) { - s.AddTool(ToolFor(t, h)) + tt, hh, err := toolForErr(t, h) + if err != nil { + panic(fmt.Sprintf("AddTool: tool %q: %v", t.Name, err)) + } + s.AddTool(tt, hh) } // RemoveTools removes the tools with the given names. From 0bb1a42b53c08869cdc65281504081706c3627b8 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Wed, 3 Sep 2025 11:34:02 -0400 Subject: [PATCH 009/418] examples/server/middleware: log tool result (#400) Log the result of a tool call in the middleware. This example now fully demonstrates that receiving middleware can effectively wrap a ToolHandler. That means that one reason for ToolFor is moot: you don't need to get your hands on the returned ToolHandler in order to wrap it. --- examples/server/middleware/main.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/examples/server/middleware/main.go b/examples/server/middleware/main.go index 224c8c6..4e37471 100644 --- a/examples/server/middleware/main.go +++ b/examples/server/middleware/main.go @@ -64,6 +64,12 @@ func main() { "duration_ms", duration.Milliseconds(), "has_result", result != nil, ) + // Log more for tool results. + if ctr, ok := result.(*mcp.CallToolResult); ok { + logger.Info("tool result", + "isError", ctr.IsError, + "structuredContent", ctr.StructuredContent) + } } return result, err } @@ -103,7 +109,7 @@ func main() { Content: []mcp.Content{ &mcp.TextContent{Text: message}, }, - }, nil, nil + }, message, nil }, ) From c76d991f0533dac9d3668c8d3406f65d29d47d9a Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Wed, 3 Sep 2025 11:34:39 -0400 Subject: [PATCH 010/418] examples/server/distributed: address comment in #380 I failed to push my branch before merging #380. Address one minor comment from that PR. --- examples/server/distributed/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/server/distributed/main.go b/examples/server/distributed/main.go index 43d959e..0433f1f 100644 --- a/examples/server/distributed/main.go +++ b/examples/server/distributed/main.go @@ -80,7 +80,6 @@ func parent() { var wg sync.WaitGroup childURLs := make([]*url.URL, len(ports)) for i, port := range ports { - wg.Add(1) childURL := fmt.Sprintf("http://localhost:%s", port) childURLs[i], err = url.Parse(childURL) if err != nil { @@ -89,6 +88,8 @@ func parent() { cmd := exec.CommandContext(ctx, exe, os.Args[1:]...) cmd.Env = append(os.Environ(), fmt.Sprintf("%s=%s", childPortVar, port)) cmd.Stderr = os.Stderr + + wg.Add(1) go func() { defer wg.Done() log.Printf("starting child %d at %s", i, childURL) From 1c20560beb05b1b1c84431eb9dd96d7d17266731 Mon Sep 17 00:00:00 2001 From: ccpro <92025731+CCpro10@users.noreply.github.com> Date: Wed, 3 Sep 2025 23:35:19 +0800 Subject: [PATCH 011/418] mcp: export jsonResponse field Exporting StreamableHTTPOptions.JSONResponse is helpful for some users behind proxies that don't work well with text/event-stream. For #211 Co-authored-by: ccpro10 --- mcp/streamable.go | 8 ++++---- mcp/streamable_test.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index f56eabd..f1a8ca1 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -72,8 +72,8 @@ type StreamableHTTPOptions struct { // TODO: support session retention (?) - // jsonResponse is forwarded to StreamableServerTransport.jsonResponse. - jsonResponse bool + // JSONResponse is forwarded to StreamableServerTransport.jsonResponse. + JSONResponse bool } // NewStreamableHTTPHandler returns a new [StreamableHTTPHandler]. @@ -233,7 +233,7 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque transport = &StreamableServerTransport{ SessionID: sessionID, Stateless: h.opts.Stateless, - jsonResponse: h.opts.jsonResponse, + jsonResponse: h.opts.JSONResponse, } // To support stateless mode, we initialize the session with a default @@ -487,7 +487,7 @@ type stream struct { // jsonResponse records whether this stream should respond with application/json // instead of text/event-stream. // - // See [StreamableServerTransportOptions.jsonResponse]. + // See [StreamableServerTransportOptions.JSONResponse]. jsonResponse bool // signal is a 1-buffered channel, owned by an incoming HTTP request, that signals diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 0cb7f95..49c2e87 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -84,7 +84,7 @@ func TestStreamableTransports(t *testing.T) { // Start an httptest.Server with the StreamableHTTPHandler, wrapped in a // cookie-checking middleware. handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{ - jsonResponse: useJSON, + JSONResponse: useJSON, }) var ( From 1250a31b5101e473f727a766c167f8d79bf64c33 Mon Sep 17 00:00:00 2001 From: Shusaku Yasoda <136243871+yasomaru@users.noreply.github.com> Date: Thu, 4 Sep 2025 02:05:07 +0900 Subject: [PATCH 012/418] auth: add OAuth error handling in TokenVerifier and tests (#399) Add ErrOAuth error type and handling to match TypeScript SDK behavior. OAuth protocol errors now return HTTP 400 instead of 500, providing better error classification for authentication issues. Changes - Add ErrOAuth variable for OAuth-specific protocol errors - Update verify function to return 400 for OAuth errors - Add test case for OAuth error handling Fixes compatibility with TypeScript SDK error handling patterns. --- auth/auth.go | 7 ++++++- auth/auth_test.go | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/auth/auth.go b/auth/auth.go index 14ad28c..ce908f6 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -24,6 +24,9 @@ type TokenInfo struct { // The error that a TokenVerifier should return if the token cannot be verified. var ErrInvalidToken = errors.New("invalid token") +// The error that a TokenVerifier should return for OAuth-specific protocol errors. +var ErrOAuth = errors.New("oauth error") + // A TokenVerifier checks the validity of a bearer token, and extracts information // from it. If verification fails, it should return an error that unwraps to ErrInvalidToken. type TokenVerifier func(ctx context.Context, token string) (*TokenInfo, error) @@ -88,7 +91,9 @@ func verify(ctx context.Context, verifier TokenVerifier, opts *RequireBearerToke if errors.Is(err, ErrInvalidToken) { return nil, err.Error(), http.StatusUnauthorized } - // TODO: the TS SDK distinguishes another error, OAuthError, and returns a 400. + if errors.Is(err, ErrOAuth) { + return nil, err.Error(), http.StatusBadRequest + } // Investigate how that works. // See typescript-sdk/src/server/auth/middleware/bearerAuth.ts. return nil, err.Error(), http.StatusInternalServerError diff --git a/auth/auth_test.go b/auth/auth_test.go index 715b9bb..8da41ec 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -19,6 +19,8 @@ func TestVerify(t *testing.T) { return &TokenInfo{Expiration: time.Now().Add(time.Hour)}, nil case "invalid": return nil, ErrInvalidToken + case "oauth": + return nil, ErrOAuth case "noexp": return &TokenInfo{}, nil case "expired": @@ -47,6 +49,10 @@ func TestVerify(t *testing.T) { "invalid", nil, "bearer invalid", "invalid token", 401, }, + { + "oauth error", nil, "Bearer oauth", + "oauth error", 400, + }, { "no expiration", nil, "Bearer noexp", "token missing expiration", 401, From 29c1650e2af89513c70a5b26cb78e83f67f84441 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Wed, 3 Sep 2025 14:20:53 -0400 Subject: [PATCH 013/418] internal/oauthex: OAuth extensions (#125) Add a package for the extensions to OAuth 2.0 required by MCP. This first PR adds Protected Resource Metadata. --- internal/oauthex/oauth2.go | 6 + internal/oauthex/oauth2_test.go | 270 +++++++++++++++++++++ internal/oauthex/resource_meta.go | 382 ++++++++++++++++++++++++++++++ 3 files changed, 658 insertions(+) create mode 100644 internal/oauthex/oauth2.go create mode 100644 internal/oauthex/oauth2_test.go create mode 100644 internal/oauthex/resource_meta.go diff --git a/internal/oauthex/oauth2.go b/internal/oauthex/oauth2.go new file mode 100644 index 0000000..d1166fe --- /dev/null +++ b/internal/oauthex/oauth2.go @@ -0,0 +1,6 @@ +// Copyright 2025 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package oauthex implements extensions to OAuth2. +package oauthex diff --git a/internal/oauthex/oauth2_test.go b/internal/oauthex/oauth2_test.go new file mode 100644 index 0000000..92017f8 --- /dev/null +++ b/internal/oauthex/oauth2_test.go @@ -0,0 +1,270 @@ +// Copyright 2025 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package oauthex + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "testing" +) + +func TestSplitChallenges(t *testing.T) { + tests := []struct { + name string + input string + want []string + }{ + { + name: "single challenge no params", + input: `Basic`, + want: []string{`Basic`}, + }, + { + name: "single challenge with params", + input: `Bearer realm="example.com", error="invalid_token"`, + want: []string{`Bearer realm="example.com", error="invalid_token"`}, + }, + { + name: "single challenge with comma in quoted string", + input: `Bearer realm="example, with comma"`, + want: []string{`Bearer realm="example, with comma"`}, + }, + { + name: "two challenges", + input: `Basic, Bearer realm="example"`, + want: []string{`Basic`, ` Bearer realm="example"`}, + }, + { + name: "multiple challenges complex", + input: `Newauth realm="apps", Basic, Bearer realm="example.com", error="invalid_token"`, + want: []string{`Newauth realm="apps"`, ` Basic`, ` Bearer realm="example.com", error="invalid_token"`}, + }, + { + name: "challenge with escaped quote", + input: `Bearer realm="example \"quoted\""`, + want: []string{`Bearer realm="example \"quoted\""`}, + }, + { + name: "empty input", + input: "", + want: []string{""}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := splitChallenges(tt.input) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("splitChallenges() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSplitChallengesError(t *testing.T) { + if _, err := splitChallenges(`"Bearer"`); err == nil { + t.Fatal("got nil, want error") + } +} + +func TestParseSingleChallenge(t *testing.T) { + tests := []struct { + name string + input string + want challenge + wantErr bool + }{ + { + name: "scheme only", + input: "Basic", + want: challenge{ + Scheme: "basic", + }, + wantErr: false, + }, + { + name: "scheme with one quoted param", + input: `Bearer realm="example.com"`, + want: challenge{ + Scheme: "bearer", + Params: map[string]string{"realm": "example.com"}, + }, + wantErr: false, + }, + { + name: "scheme with one unquoted param", + input: `Bearer realm=example.com`, + want: challenge{ + Scheme: "bearer", + Params: map[string]string{"realm": "example.com"}, + }, + wantErr: false, + }, + { + name: "scheme with multiple params", + input: `Bearer realm="example", error="invalid_token", error_description="The token expired"`, + want: challenge{ + Scheme: "bearer", + Params: map[string]string{ + "realm": "example", + "error": "invalid_token", + "error_description": "The token expired", + }, + }, + wantErr: false, + }, + { + name: "scheme with multiple unquoted params", + input: `Bearer realm=example, error=invalid_token, error_description=The token expired`, + want: challenge{ + Scheme: "bearer", + Params: map[string]string{ + "realm": "example", + "error": "invalid_token", + "error_description": "The token expired", + }, + }, + wantErr: false, + }, + { + name: "case-insensitive scheme and keys", + input: `BEARER ReAlM="example"`, + want: challenge{ + Scheme: "bearer", + Params: map[string]string{"realm": "example"}, + }, + wantErr: false, + }, + { + name: "param with escaped quote", + input: `Bearer realm="example \"foo\" bar"`, + want: challenge{ + Scheme: "bearer", + Params: map[string]string{"realm": `example "foo" bar`}, + }, + wantErr: false, + }, + { + name: "param without quotes (token)", + input: "Bearer realm=example.com", + want: challenge{ + Scheme: "bearer", + Params: map[string]string{"realm": "example.com"}, + }, + wantErr: false, + }, + { + name: "malformed param - no value", + input: "Bearer realm=", + wantErr: true, + }, + { + name: "malformed param - unterminated quote", + input: `Bearer realm="example`, + wantErr: true, + }, + { + name: "malformed param - missing comma", + input: `Bearer realm="a" error="b"`, + wantErr: true, + }, + { + name: "malformed param - initial equal", + input: `Bearer ="a"`, + wantErr: true, + }, + { + name: "empty input", + input: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseSingleChallenge(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("parseSingleChallenge() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseSingleChallenge() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetProtectedResourceMetadata(t *testing.T) { + ctx := context.Background() + t.Run("FromHeader", func(t *testing.T) { + h := &fakeResourceHandler{serveWWWAuthenticate: true} + server := httptest.NewTLSServer(h) + h.installHandlers(server.URL) + client := server.Client() + res, err := client.Get(server.URL + "/resource") + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusUnauthorized { + t.Fatal("want unauth") + } + prm, err := GetProtectedResourceMetadataFromHeader(ctx, res.Header, client) + if err != nil { + t.Fatal(err) + } + if prm == nil { + t.Fatal("nil prm") + } + }) + t.Run("FromID", func(t *testing.T) { + h := &fakeResourceHandler{serveWWWAuthenticate: false} + server := httptest.NewTLSServer(h) + h.installHandlers(server.URL) + client := server.Client() + prm, err := GetProtectedResourceMetadataFromID(ctx, server.URL, client) + if err != nil { + t.Fatal(err) + } + if prm == nil { + t.Fatal("nil prm") + } + }) +} + +type fakeResourceHandler struct { + http.ServeMux + serveWWWAuthenticate bool +} + +func (h *fakeResourceHandler) installHandlers(serverURL string) { + path := "/.well-known/oauth-protected-resource" + url := serverURL + path + h.Handle("GET /resource", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if h.serveWWWAuthenticate { + w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer resource_metadata="%s"`, url)) + } + w.WriteHeader(http.StatusUnauthorized) + })) + h.Handle("GET "+path, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + // If there is a WWW-Authenticate header, the resource field is the value of that header. + // If not, it's the server URL without the "/.well-known/..." part. + resource := serverURL + if h.serveWWWAuthenticate { + resource = url + } + prm := &ProtectedResourceMetadata{Resource: resource} + if err := json.NewEncoder(w).Encode(prm); err != nil { + panic(err) + } + })) +} diff --git a/internal/oauthex/resource_meta.go b/internal/oauthex/resource_meta.go new file mode 100644 index 0000000..eb981d2 --- /dev/null +++ b/internal/oauthex/resource_meta.go @@ -0,0 +1,382 @@ +// Copyright 2025 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This file implements Protected Resource Metadata. +// See https://www.rfc-editor.org/rfc/rfc9728.html. + +package oauthex + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "path" + "strings" + "unicode" + + "github.com/modelcontextprotocol/go-sdk/internal/util" +) + +const defaultProtectedResourceMetadataURI = "/.well-known/oauth-protected-resource" + +// ProtectedResourceMetadata is the metadata for an OAuth 2.0 protected resource, +// as defined in section 2 of https://www.rfc-editor.org/rfc/rfc9728.html. +// +// The following features are not supported: +// - additional keys (§2, last sentence) +// - human-readable metadata (§2.1) +// - signed metadata (§2.2) +type ProtectedResourceMetadata struct { + // GENERATED BY GEMINI 2.5. + + // Resource (resource) is the protected resource's resource identifier. + // Required. + Resource string `json:"resource"` + + // AuthorizationServers (authorization_servers) is an optional slice containing a list of + // OAuth authorization server issuer identifiers (as defined in RFC 8414) that can be + // used with this protected resource. + AuthorizationServers []string `json:"authorization_servers,omitempty"` + + // JWKSURI (jwks_uri) is an optional URL of the protected resource's JSON Web Key (JWK) Set + // document. This contains public keys belonging to the protected resource, such as + // signing key(s) that the resource server uses to sign resource responses. + JWKSURI string `json:"jwks_uri,omitempty"` + + // ScopesSupported (scopes_supported) is a recommended slice containing a list of scope + // values (as defined in RFC 6749) used in authorization requests to request access + // to this protected resource. + ScopesSupported []string `json:"scopes_supported,omitempty"` + + // BearerMethodsSupported (bearer_methods_supported) is an optional slice containing + // a list of the supported methods of sending an OAuth 2.0 bearer token to the + // protected resource. Defined values are "header", "body", and "query". + BearerMethodsSupported []string `json:"bearer_methods_supported,omitempty"` + + // ResourceSigningAlgValuesSupported (resource_signing_alg_values_supported) is an optional + // slice of JWS signing algorithms (alg values) supported by the protected + // resource for signing resource responses. + ResourceSigningAlgValuesSupported []string `json:"resource_signing_alg_values_supported,omitempty"` + + // ResourceName (resource_name) is a human-readable name of the protected resource + // intended for display to the end user. It is RECOMMENDED that this field be included. + // This value may be internationalized. + ResourceName string `json:"resource_name,omitempty"` + + // ResourceDocumentation (resource_documentation) is an optional URL of a page containing + // human-readable information for developers using the protected resource. + // This value may be internationalized. + ResourceDocumentation string `json:"resource_documentation,omitempty"` + + // ResourcePolicyURI (resource_policy_uri) is an optional URL of a page containing + // human-readable policy information on how a client can use the data provided. + // This value may be internationalized. + ResourcePolicyURI string `json:"resource_policy_uri,omitempty"` + + // ResourceTOSURI (resource_tos_uri) is an optional URL of a page containing the protected + // resource's human-readable terms of service. This value may be internationalized. + ResourceTOSURI string `json:"resource_tos_uri,omitempty"` + + // TLSClientCertificateBoundAccessTokens (tls_client_certificate_bound_access_tokens) is an + // optional boolean indicating support for mutual-TLS client certificate-bound + // access tokens (RFC 8705). Defaults to false if omitted. + TLSClientCertificateBoundAccessTokens bool `json:"tls_client_certificate_bound_access_tokens,omitempty"` + + // AuthorizationDetailsTypesSupported (authorization_details_types_supported) is an optional + // slice of 'type' values supported by the resource server for the + // 'authorization_details' parameter (RFC 9396). + AuthorizationDetailsTypesSupported []string `json:"authorization_details_types_supported,omitempty"` + + // DPOPSigningAlgValuesSupported (dpop_signing_alg_values_supported) is an optional + // slice of JWS signing algorithms supported by the resource server for validating + // DPoP proof JWTs (RFC 9449). + DPOPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"` + + // DPOPBoundAccessTokensRequired (dpop_bound_access_tokens_required) is an optional boolean + // specifying whether the protected resource always requires the use of DPoP-bound + // access tokens (RFC 9449). Defaults to false if omitted. + DPOPBoundAccessTokensRequired bool `json:"dpop_bound_access_tokens_required,omitempty"` + + // SignedMetadata (signed_metadata) is an optional JWT containing metadata parameters + // about the protected resource as claims. If present, these values take precedence + // over values conveyed in plain JSON. + // TODO:implement. + // Note that §2.2 says it's okay to ignore this. + // SignedMetadata string `json:"signed_metadata,omitempty"` +} + +// GetProtectedResourceMetadataFromID issues a GET request to retrieve protected resource +// metadata from a resource server by its ID. +// The resource ID is an HTTPS URL, typically with a host:port and possibly a path. +// For example: +// +// https://example.com/server +// +// This function, following the spec (§3), inserts the default well-known path into the +// URL. In our example, the result would be +// +// https://example.com/.well-known/oauth-protected-resource/server +// +// It then retrieves the metadata at that location using the given client (or the +// default client if nil) and validates its resource field against resourceID. +func GetProtectedResourceMetadataFromID(ctx context.Context, resourceID string, c *http.Client) (_ *ProtectedResourceMetadata, err error) { + defer util.Wrapf(&err, "GetProtectedResourceMetadataFromID(%q)", resourceID) + + u, err := url.Parse(resourceID) + if err != nil { + return nil, err + } + // Insert well-known URI into URL. + u.Path = path.Join(defaultProtectedResourceMetadataURI, u.Path) + return getPRM(ctx, u.String(), c, resourceID) +} + +// GetProtectedResourceMetadataFromHeader retrieves protected resource metadata +// using information in the given header, using the given client (or the default +// client if nil). +// It issues a GET request to a URL discovered by parsing the WWW-Authenticate headers in the given request, +// It then validates the resource field of the resulting metadata against the given URL. +// If there is no URL in the request, it returns nil, nil. +func GetProtectedResourceMetadataFromHeader(ctx context.Context, header http.Header, c *http.Client) (_ *ProtectedResourceMetadata, err error) { + defer util.Wrapf(&err, "GetProtectedResourceMetadataFromHeader") + headers := header[http.CanonicalHeaderKey("WWW-Authenticate")] + if len(headers) == 0 { + return nil, nil + } + cs, err := parseWWWAuthenticate(headers) + if err != nil { + return nil, err + } + url := resourceMetadataURL(cs) + if url == "" { + return nil, nil + } + return getPRM(ctx, url, c, url) +} + +// getPRM makes a GET request to the given URL, and validates the response. +// As part of the validation, it compares the returned resource field to wantResource. +func getPRM(ctx context.Context, url string, c *http.Client, wantResource string) (*ProtectedResourceMetadata, error) { + if !strings.HasPrefix(strings.ToUpper(url), "HTTPS://") { + return nil, fmt.Errorf("resource URL %q does not use HTTPS", url) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + if c == nil { + c = http.DefaultClient + } + res, err := c.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + // Spec §3.2 requires a 200. + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("bad status %s", res.Status) + } + // Spec §3.2 requires application/json. + if ct := res.Header.Get("Content-Type"); ct != "application/json" { + return nil, fmt.Errorf("bad content type %q", ct) + } + + var prm ProtectedResourceMetadata + dec := json.NewDecoder(res.Body) + if err := dec.Decode(&prm); err != nil { + return nil, err + } + // Validate the Resource field to thwart impersonation attacks (section 3.3). + if prm.Resource != wantResource { + return nil, fmt.Errorf("got metadata resource %q, want %q", prm.Resource, wantResource) + } + return &prm, nil +} + +// challenge represents a single authentication challenge from a WWW-Authenticate header. +// As per RFC 9110, Section 11.6.1, a challenge consists of a scheme and optional parameters. +type challenge struct { + // GENERATED BY GEMINI 2.5. + // + // Scheme is the authentication scheme (e.g., "Bearer", "Basic"). + // It is case-insensitive. A parsed value will always be lower-case. + Scheme string + // Params is a map of authentication parameters. + // Keys are case-insensitive. Parsed keys are always lower-case. + Params map[string]string +} + +// resourceMetadataURL returns a resource metadata URL from the given challenges, +// or the empty string if there is none. +func resourceMetadataURL(cs []challenge) string { + for _, c := range cs { + if u := c.Params["resource_metadata"]; u != "" { + return u + } + } + return "" +} + +// parseWWWAuthenticate parses a WWW-Authenticate header string. +// The header format is defined in RFC 9110, Section 11.6.1, and can contain +// one or more challenges, separated by commas. +// It returns a slice of challenges or an error if one of the headers is malformed. +func parseWWWAuthenticate(headers []string) ([]challenge, error) { + // GENERATED BY GEMINI 2.5 (human-tweaked) + var challenges []challenge + for _, h := range headers { + challengeStrings, err := splitChallenges(h) + if err != nil { + return nil, err + } + for _, cs := range challengeStrings { + if strings.TrimSpace(cs) == "" { + continue + } + challenge, err := parseSingleChallenge(cs) + if err != nil { + return nil, fmt.Errorf("failed to parse challenge %q: %w", cs, err) + } + challenges = append(challenges, challenge) + } + } + return challenges, nil +} + +// splitChallenges splits a header value containing one or more challenges. +// It correctly handles commas within quoted strings and distinguishes between +// commas separating auth-params and commas separating challenges. +func splitChallenges(header string) ([]string, error) { + // GENERATED BY GEMINI 2.5. + var challenges []string + inQuotes := false + start := 0 + for i, r := range header { + if r == '"' { + if i > 0 && header[i-1] != '\\' { + inQuotes = !inQuotes + } else if i == 0 { + // A challenge begins with an auth-scheme, which is a token, which cannot contain + // a quote. + return nil, errors.New(`challenge begins with '"'`) + } + } else if r == ',' && !inQuotes { + // This is a potential challenge separator. + // A new challenge does not start with `key=value`. + // We check if the part after the comma looks like a parameter. + lookahead := strings.TrimSpace(header[i+1:]) + eqPos := strings.Index(lookahead, "=") + + isParam := false + if eqPos > 0 { + // Check if the part before '=' is a single token (no spaces). + token := lookahead[:eqPos] + if strings.IndexFunc(token, unicode.IsSpace) == -1 { + isParam = true + } + } + + if !isParam { + // The part after the comma does not look like a parameter, + // so this comma separates challenges. + challenges = append(challenges, header[start:i]) + start = i + 1 + } + } + } + // Add the last (or only) challenge to the list. + challenges = append(challenges, header[start:]) + return challenges, nil +} + +// parseSingleChallenge parses a string containing exactly one challenge. +// challenge = auth-scheme [ 1*SP ( token68 / #auth-param ) ] +func parseSingleChallenge(s string) (challenge, error) { + // GENERATED BY GEMINI 2.5, human-tweaked. + s = strings.TrimSpace(s) + if s == "" { + return challenge{}, errors.New("empty challenge string") + } + + scheme, paramsStr, found := strings.Cut(s, " ") + c := challenge{Scheme: strings.ToLower(scheme)} + if !found { + return c, nil + } + + params := make(map[string]string) + + // Parse the key-value parameters. + for paramsStr != "" { + // Find the end of the parameter key. + keyEnd := strings.Index(paramsStr, "=") + if keyEnd <= 0 { + return challenge{}, fmt.Errorf("malformed auth parameter: expected key=value, but got %q", paramsStr) + } + key := strings.TrimSpace(paramsStr[:keyEnd]) + + // Move the string past the key and the '='. + paramsStr = strings.TrimSpace(paramsStr[keyEnd+1:]) + + var value string + if strings.HasPrefix(paramsStr, "\"") { + // The value is a quoted string. + paramsStr = paramsStr[1:] // Consume the opening quote. + var valBuilder strings.Builder + i := 0 + for ; i < len(paramsStr); i++ { + // Handle escaped characters. + if paramsStr[i] == '\\' && i+1 < len(paramsStr) { + valBuilder.WriteByte(paramsStr[i+1]) + i++ // We've consumed two characters. + } else if paramsStr[i] == '"' { + // End of the quoted string. + break + } else { + valBuilder.WriteByte(paramsStr[i]) + } + } + + // A quoted string must be terminated. + if i == len(paramsStr) { + return challenge{}, fmt.Errorf("unterminated quoted string in auth parameter") + } + + value = valBuilder.String() + // Move the string past the value and the closing quote. + paramsStr = strings.TrimSpace(paramsStr[i+1:]) + } else { + // The value is a token. It ends at the next comma or the end of the string. + commaPos := strings.Index(paramsStr, ",") + if commaPos == -1 { + value = paramsStr + paramsStr = "" + } else { + value = strings.TrimSpace(paramsStr[:commaPos]) + paramsStr = strings.TrimSpace(paramsStr[commaPos:]) // Keep comma for next check + } + } + if value == "" { + return challenge{}, fmt.Errorf("no value for auth param %q", key) + } + + // Per RFC 9110, parameter keys are case-insensitive. + params[strings.ToLower(key)] = value + + // If there is a comma, consume it and continue to the next parameter. + if strings.HasPrefix(paramsStr, ",") { + paramsStr = strings.TrimSpace(paramsStr[1:]) + } else if paramsStr != "" { + // If there's content but it's not a new parameter, the format is wrong. + return challenge{}, fmt.Errorf("malformed auth parameter: expected comma after value, but got %q", paramsStr) + } + } + + // Per RFC 9110, the scheme is case-insensitive. + return challenge{Scheme: strings.ToLower(scheme), Params: params}, nil +} From d0c5943f25c91bf12887ec70746ea99f8056e8b0 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Wed, 3 Sep 2025 16:23:42 -0400 Subject: [PATCH 014/418] add HTTP Request to TokenVerifier (#404) The request might be needed to verify the token. Fixes #403. --- auth/auth.go | 14 ++++++++------ auth/auth_test.go | 8 +++++--- examples/server/auth-middleware/main.go | 5 ++--- mcp/streamable_test.go | 2 +- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/auth/auth.go b/auth/auth.go index ce908f6..7cc0074 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -29,7 +29,8 @@ var ErrOAuth = errors.New("oauth error") // A TokenVerifier checks the validity of a bearer token, and extracts information // from it. If verification fails, it should return an error that unwraps to ErrInvalidToken. -type TokenVerifier func(ctx context.Context, token string) (*TokenInfo, error) +// The HTTP request is provided in case verifying the token involves checking it. +type TokenVerifier func(ctx context.Context, token string, req *http.Request) (*TokenInfo, error) // RequireBearerTokenOptions are options for [RequireBearerToken]. type RequireBearerTokenOptions struct { @@ -55,6 +56,8 @@ func TokenInfoFromContext(ctx context.Context) *TokenInfo { // If verification succeeds, the [TokenInfo] is added to the request's context and the request proceeds. // If verification fails, the request fails with a 401 Unauthenticated, and the WWW-Authenticate header // is populated to enable [protected resource metadata]. +// + // // [protected resource metadata]: https://datatracker.ietf.org/doc/rfc9728 func RequireBearerToken(verifier TokenVerifier, opts *RequireBearerTokenOptions) func(http.Handler) http.Handler { @@ -62,7 +65,7 @@ func RequireBearerToken(verifier TokenVerifier, opts *RequireBearerTokenOptions) return func(handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - tokenInfo, errmsg, code := verify(r.Context(), verifier, opts, r.Header.Get("Authorization")) + tokenInfo, errmsg, code := verify(r, verifier, opts) if code != 0 { if code == http.StatusUnauthorized || code == http.StatusForbidden { if opts != nil && opts.ResourceMetadataURL != "" { @@ -78,15 +81,16 @@ func RequireBearerToken(verifier TokenVerifier, opts *RequireBearerTokenOptions) } } -func verify(ctx context.Context, verifier TokenVerifier, opts *RequireBearerTokenOptions, authHeader string) (_ *TokenInfo, errmsg string, code int) { +func verify(req *http.Request, verifier TokenVerifier, opts *RequireBearerTokenOptions) (_ *TokenInfo, errmsg string, code int) { // Extract bearer token. + authHeader := req.Header.Get("Authorization") fields := strings.Fields(authHeader) if len(fields) != 2 || strings.ToLower(fields[0]) != "bearer" { return nil, "no bearer token", http.StatusUnauthorized } // Verify the token and get information from it. - tokenInfo, err := verifier(ctx, fields[1]) + tokenInfo, err := verifier(req.Context(), fields[1], req) if err != nil { if errors.Is(err, ErrInvalidToken) { return nil, err.Error(), http.StatusUnauthorized @@ -94,8 +98,6 @@ func verify(ctx context.Context, verifier TokenVerifier, opts *RequireBearerToke if errors.Is(err, ErrOAuth) { return nil, err.Error(), http.StatusBadRequest } - // Investigate how that works. - // See typescript-sdk/src/server/auth/middleware/bearerAuth.ts. return nil, err.Error(), http.StatusInternalServerError } diff --git a/auth/auth_test.go b/auth/auth_test.go index 8da41ec..ef8ea7b 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -7,13 +7,13 @@ package auth import ( "context" "errors" + "net/http" "testing" "time" ) func TestVerify(t *testing.T) { - ctx := context.Background() - verifier := func(_ context.Context, token string) (*TokenInfo, error) { + verifier := func(_ context.Context, token string, _ *http.Request) (*TokenInfo, error) { switch token { case "valid": return &TokenInfo{Expiration: time.Now().Add(time.Hour)}, nil @@ -67,7 +67,9 @@ func TestVerify(t *testing.T) { }, } { t.Run(tt.name, func(t *testing.T) { - _, gotMsg, gotCode := verify(ctx, verifier, tt.opts, tt.header) + _, gotMsg, gotCode := verify(&http.Request{ + Header: http.Header{"Authorization": {tt.header}}, + }, verifier, tt.opts) if gotMsg != tt.wantMsg || gotCode != tt.wantCode { t.Errorf("got (%q, %d), want (%q, %d)", gotMsg, gotCode, tt.wantMsg, tt.wantCode) } diff --git a/examples/server/auth-middleware/main.go b/examples/server/auth-middleware/main.go index f472b76..dd1271e 100644 --- a/examples/server/auth-middleware/main.go +++ b/examples/server/auth-middleware/main.go @@ -83,7 +83,7 @@ func generateToken(userID string, scopes []string, expiresIn time.Duration) (str // verifyJWT verifies JWT tokens and returns TokenInfo for the auth middleware. // This function implements the TokenVerifier interface required by auth.RequireBearerToken. -func verifyJWT(ctx context.Context, tokenString string) (*auth.TokenInfo, error) { +func verifyJWT(ctx context.Context, tokenString string, _ *http.Request) (*auth.TokenInfo, error) { // Parse and validate the JWT token. token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (any, error) { // Verify the signing method is HMAC. @@ -92,7 +92,6 @@ func verifyJWT(ctx context.Context, tokenString string) (*auth.TokenInfo, error) } return jwtSecret, nil }) - if err != nil { // Return standard error for invalid tokens. return nil, fmt.Errorf("%w: %v", auth.ErrInvalidToken, err) @@ -111,7 +110,7 @@ func verifyJWT(ctx context.Context, tokenString string) (*auth.TokenInfo, error) // verifyAPIKey verifies API keys and returns TokenInfo for the auth middleware. // This function implements the TokenVerifier interface required by auth.RequireBearerToken. -func verifyAPIKey(ctx context.Context, apiKey string) (*auth.TokenInfo, error) { +func verifyAPIKey(ctx context.Context, apiKey string, _ *http.Request) (*auth.TokenInfo, error) { // Look up the API key in our storage. key, exists := apiKeys[apiKey] if !exists { diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 49c2e87..2c90425 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1270,7 +1270,7 @@ func TestTokenInfo(t *testing.T) { AddTool(server, &Tool{Name: "tokenInfo", Description: "return token info"}, tokenInfo) streamHandler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) - verifier := func(context.Context, string) (*auth.TokenInfo, error) { + verifier := func(context.Context, string, *http.Request) (*auth.TokenInfo, error) { return &auth.TokenInfo{ Scopes: []string{"scope"}, // Expiration is far, far in the future. From 8314ec099b7145dc2a4891bb5fa00c184c10d367 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Wed, 3 Sep 2025 16:25:48 -0400 Subject: [PATCH 015/418] mcp: validate tool output (#352) Validate the handler's returned output against the output schema of a tool. Fixes #301. --- mcp/mcp_test.go | 20 ++++++++++++++++++++ mcp/server.go | 8 ++++++-- mcp/tool.go | 14 +++++++++----- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index a16ac83..28a8b97 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -257,6 +257,26 @@ func TestEndToEnd(t *testing.T) { t.Errorf("tools/call 'fail' mismatch (-want +got):\n%s", diff) } + // Check output schema validation. + badout := &Tool{ + Name: "badout", + OutputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "x": {Type: "string"}, + }, + }, + } + AddTool(s, badout, func(_ context.Context, _ *CallToolRequest, arg map[string]any) (*CallToolResult, map[string]any, error) { + return nil, map[string]any{"x": 1}, nil + }) + _, err = cs.CallTool(ctx, &CallToolParams{Name: "badout"}) + wantMsg := `has type "integer", want "string"` + if err == nil || !strings.Contains(err.Error(), wantMsg) { + t.Errorf("\ngot %q\nwant error message containing %q", err, wantMsg) + } + + // Check tools-changed notifications. s.AddTool(&Tool{Name: "T", InputSchema: &jsonschema.Schema{Type: "object"}}, nopHandler) waitForNotification(t, "tools") s.RemoveTools("T") diff --git a/mcp/server.go b/mcp/server.go index 114489a..0c1acc2 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -247,8 +247,12 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHan }, nil } - // TODO(v0.3.0): Validate out. - _ = outputResolved + // Validate output schema, if any. + // Skip if out is nil: we've removed "null" from the output schema, so nil won't validate. + if v := reflect.ValueOf(out); v.Kind() == reflect.Pointer && v.IsNil() { + } else if err := validateSchema(outputResolved, &out); err != nil { + return nil, fmt.Errorf("tool output: %w", err) + } if res == nil { res = &CallToolResult{} diff --git a/mcp/tool.go b/mcp/tool.go index bd10a07..53a3c7a 100644 --- a/mcp/tool.go +++ b/mcp/tool.go @@ -9,6 +9,7 @@ import ( "context" "encoding/json" "fmt" + // "log" "github.com/google/jsonschema-go/jsonschema" ) @@ -42,13 +43,16 @@ func unmarshalSchema(data json.RawMessage, resolved *jsonschema.Resolved, v any) if err := dec.Decode(v); err != nil { return fmt.Errorf("unmarshaling: %w", err) } - // TODO: test with nil args. + return validateSchema(resolved, v) +} + +func validateSchema(resolved *jsonschema.Resolved, value any) error { if resolved != nil { - if err := resolved.ApplyDefaults(v); err != nil { - return fmt.Errorf("applying defaults from \n\t%s\nto\n\t%s:\n%w", schemaJSON(resolved.Schema()), data, err) + if err := resolved.ApplyDefaults(value); err != nil { + return fmt.Errorf("applying defaults from \n\t%s\nto\n\t%v:\n%w", schemaJSON(resolved.Schema()), value, err) } - if err := resolved.Validate(v); err != nil { - return fmt.Errorf("validating\n\t%s\nagainst\n\t %s:\n %w", data, schemaJSON(resolved.Schema()), err) + if err := resolved.Validate(value); err != nil { + return fmt.Errorf("validating\n\t%v\nagainst\n\t %s:\n %w", value, schemaJSON(resolved.Schema()), err) } } return nil From 92e19c1be8be2c863af290b1bf78a19c7623b2c2 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Wed, 3 Sep 2025 17:27:38 -0400 Subject: [PATCH 016/418] examples/server/auth-middleware: remove custom middleware (#405) The additional middleware added the TokenInfo the context. RequireBearerToken already does that. --- examples/server/auth-middleware/main.go | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/examples/server/auth-middleware/main.go b/examples/server/auth-middleware/main.go index dd1271e..75a38b8 100644 --- a/examples/server/auth-middleware/main.go +++ b/examples/server/auth-middleware/main.go @@ -206,19 +206,6 @@ func CreateResource(ctx context.Context, req *mcp.CallToolRequest, args createRe }, nil, nil } -// authMiddleware extracts token information and adds it to the context -func authMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // In a real application, you would extract token info from the auth middleware's context - // For this example, we simulate the token info that would be available - ctx := context.WithValue(r.Context(), "user_info", &auth.TokenInfo{ - Scopes: []string{"read", "write"}, - Expiration: time.Now().Add(time.Hour), - }) - next.ServeHTTP(w, r.WithContext(ctx)) - }) -} - // createMCPServer creates an MCP server with authentication-aware tools func createMCPServer() *mcp.Server { server := mcp.NewServer(&mcp.Implementation{Name: "authenticated-mcp-server"}, nil) @@ -263,8 +250,8 @@ func main() { }, nil) // Apply authentication middleware to the MCP handler. - authenticatedHandler := jwtAuth(authMiddleware(handler)) - apiKeyHandler := apiKeyAuth(authMiddleware(handler)) + authenticatedHandler := jwtAuth(handler) + apiKeyHandler := apiKeyAuth(handler) // Create router for different authentication methods. http.HandleFunc("/mcp/jwt", authenticatedHandler.ServeHTTP) From a1eb4849b74ceaf7fdc99661b0533ab72bf73c1d Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Wed, 3 Sep 2025 20:09:19 +0000 Subject: [PATCH 017/418] mcp: correctly disallow GET requests on stateless servers The condition sessionID == "" was not quite right for disallowing GET requests. Since we decided to differentiate "stateless" vs "sessionless" servers, we need to disallow GET requests for both. For #393 --- mcp/streamable.go | 2 +- mcp/streamable_test.go | 36 ++++++++++++++++++++++-------------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index f1a8ca1..0469613 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -171,7 +171,7 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque switch req.Method { case http.MethodPost, http.MethodGet: - if req.Method == http.MethodGet && sessionID == "" { + if req.Method == http.MethodGet && (h.opts.Stateless || sessionID == "") { http.Error(w, "GET requires an active session", http.StatusMethodNotAllowed) return } diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 2c90425..6b69b21 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -799,7 +799,9 @@ func testStreamableHandler(t *testing.T, handler http.Handler, requests []stream out := make(chan jsonrpc.Message) // Cancel the step if we encounter a request that isn't going to be // handled. - ctx, cancel := context.WithCancel(context.Background()) + // + // Also, add a timeout (hopefully generous). + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) var wg sync.WaitGroup wg.Add(1) @@ -1168,6 +1170,11 @@ func TestStreamableStateless(t *testing.T) { wantBodyContaining: "greet", wantSessionID: false, }, + { + method: "GET", + wantStatusCode: http.StatusMethodNotAllowed, + wantSessionID: false, + }, { method: "POST", wantStatusCode: http.StatusOK, @@ -1215,33 +1222,34 @@ func TestStreamableStateless(t *testing.T) { } } - handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{ + sessionlessHandler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{ GetSessionID: func() string { return "" }, Stateless: true, }) - // Test the default stateless mode. - t.Run("stateless", func(t *testing.T) { - testStreamableHandler(t, handler, requests) - testClientCompatibility(t, handler) + // First, test the "sessionless" stateless mode, where there is no session ID. + t.Run("sessionless", func(t *testing.T) { + testStreamableHandler(t, sessionlessHandler, requests) + testClientCompatibility(t, sessionlessHandler) }) - // Test a "distributed" variant of stateless mode, where it has non-empty - // session IDs, but is otherwise stateless. + // Next, test the default stateless mode, where session IDs are permitted. // // This can be used by tools to look up application state preserved across // subsequent requests. for i, req := range requests { - // Now, we want a session for all requests. - req.wantSessionID = true + // Now, we want a session for all (valid) requests. + if req.wantStatusCode != http.StatusMethodNotAllowed { + req.wantSessionID = true + } requests[i] = req } - distributableHandler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{ + statelessHandler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{ Stateless: true, }) - t.Run("distributed", func(t *testing.T) { - testStreamableHandler(t, distributableHandler, requests) - testClientCompatibility(t, handler) + t.Run("stateless", func(t *testing.T) { + testStreamableHandler(t, statelessHandler, requests) + testClientCompatibility(t, sessionlessHandler) }) } From 4257528dde13d228f1fcfe3a9d60d9df4a0d4887 Mon Sep 17 00:00:00 2001 From: ln-12 <36760115+ln-12@users.noreply.github.com> Date: Thu, 4 Sep 2025 17:10:39 +0200 Subject: [PATCH 018/418] mcp: set default logging level to info (#411) During initialization, the default logging level is set to info allowing servers to emit logs immediately without waiting for a client to set a log level. The updated example shows how to send a notification in the context of the request. Fixes #387 --- examples/server/distributed/main.go | 5 ++++- mcp/streamable.go | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/server/distributed/main.go b/examples/server/distributed/main.go index 0433f1f..b144040 100644 --- a/examples/server/distributed/main.go +++ b/examples/server/distributed/main.go @@ -144,7 +144,7 @@ func parent() { } func child(port string) { - // Create a server with a single tool that increments a counter. + // Create a server with a single tool that increments a counter and sends a notification. server := mcp.NewServer(&mcp.Implementation{Name: "counter"}, nil) var count atomic.Int64 @@ -153,6 +153,9 @@ func child(port string) { if *verbose { log.Printf("request %d (session %s)", n, req.Session.ID()) } + // Send a notification in the context of the request + // Hint: in stateless mode, at least log level 'info' is required to send notifications + req.Session.Log(ctx, &mcp.LoggingMessageParams{Data: fmt.Sprintf("request %d (session %s)", n, req.Session.ID()), Level: "info"}) return nil, struct{ Count int64 }{n}, nil } mcp.AddTool(server, &mcp.Tool{Name: "inc"}, inc) diff --git a/mcp/streamable.go b/mcp/streamable.go index 0469613..0f6bfdc 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -282,6 +282,7 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque if !hasInitialized { state.InitializedParams = new(InitializedParams) } + state.LogLevel = "info" connectOpts = &ServerSessionOptions{ State: state, } From 203792dfdc04d75712bd6c469029a4622b939c42 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Thu, 4 Sep 2025 11:13:17 -0400 Subject: [PATCH 019/418] mcp: improve docs around tool handlers (#414) --- mcp/server.go | 10 +++++++++- mcp/tool.go | 6 ++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index 0c1acc2..bb10079 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -319,7 +319,6 @@ func setSchema[T any](sfield **jsonschema.Schema, rfield **jsonschema.Resolved) } // AddTool adds a tool and typed tool handler to the server. -// // If the tool's input schema is nil, it is set to the schema inferred from the // In type parameter, using [jsonschema.For]. The In type parameter must be a // map or a struct, so that its inferred JSON Schema has type "object". @@ -327,6 +326,15 @@ func setSchema[T any](sfield **jsonschema.Schema, rfield **jsonschema.Resolved) // For tools that don't return structured output, Out should be 'any'. // Otherwise, if the tool's output schema is nil the output schema is set to // the schema inferred from Out, which must be a map or a struct. +// +// The In argument to the handler will contain the unmarshaled arguments from +// CallToolRequest.Params.Arguments. Most users can ignore the [CallToolRequest] +// argument to the handler. +// +// The handler's Out return value will be used to populate [CallToolResult.StructuredContent]. +// If the handler returns a non-nil error, [CallToolResult.IsError] will be set to true, +// and [CallToolResult.Content] will be set to the text of the error. +// Most users can ignore the [CallToolResult] return value. func AddTool[In, Out any](s *Server, t *Tool, h ToolHandlerFor[In, Out]) { tt, hh, err := toolForErr(t, h) if err != nil { diff --git a/mcp/tool.go b/mcp/tool.go index 53a3c7a..1e8ec8b 100644 --- a/mcp/tool.go +++ b/mcp/tool.go @@ -15,11 +15,13 @@ import ( ) // A ToolHandler handles a call to tools/call. -// [CallToolParams.Arguments] will contain a map[string]any that has been validated -// against the input schema. +// This is a low-level API, for use with [Server.AddTool]. +// Most users will write a [ToolHandlerFor] and install it with [AddTool]. type ToolHandler func(context.Context, *CallToolRequest) (*CallToolResult, error) // A ToolHandlerFor handles a call to tools/call with typed arguments and results. +// Use [AddTool] to add a ToolHandlerFor to a server. +// Most users can ignore the [CallToolRequest] argument and [CallToolResult] return value. type ToolHandlerFor[In, Out any] func(context.Context, *CallToolRequest, In) (*CallToolResult, Out, error) // A serverTool is a tool definition that is bound to a tool handler. From 07b9cee8ccbe0afa649822fd42b5473233d2f15b Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Thu, 4 Sep 2025 14:00:15 +0000 Subject: [PATCH 020/418] mcp: flush headers immediately for the hanging GET Flush headers immediately for the persistent hanging GET of the streamable transport; otherwise, clients may time out. Fixes #410 --- mcp/streamable.go | 37 ++++++++++++--------- mcp/streamable_test.go | 74 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 15 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 0f6bfdc..e7777eb 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -743,17 +743,28 @@ func (c *streamableServerConn) respondJSON(stream *stream, w http.ResponseWriter // lastIndex is the index of the last seen event if resuming, else -1. func (c *streamableServerConn) respondSSE(stream *stream, w http.ResponseWriter, req *http.Request, lastIndex int, persistent bool) { - writes := 0 - - // Accept checked in [StreamableHTTPHandler] + // Accept was checked in [StreamableHTTPHandler] w.Header().Set("Cache-Control", "no-cache, no-transform") w.Header().Set("Content-Type", "text/event-stream") // Accept checked in [StreamableHTTPHandler] w.Header().Set("Connection", "keep-alive") if c.sessionID != "" { w.Header().Set(sessionIDHeader, c.sessionID) } + if persistent { + // Issue #410: the hanging GET is likely not to receive messages for a long + // time. Ensure that headers are flushed. + // + // For non-persistent requests, delay the writing of the header in case we + // may want to set an error status. + // (see the TODO: this probably isn't worth it). + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + } // write one event containing data. + writes := 0 write := func(data []byte) bool { lastIndex++ e := Event{ @@ -770,23 +781,19 @@ func (c *streamableServerConn) respondSSE(stream *stream, w http.ResponseWriter, return true } - errorf := func(code int, format string, args ...any) { - if writes == 0 { - http.Error(w, fmt.Sprintf(format, args...), code) - } else { - // TODO(#170): log when we add server-side logging - } - } - // Repeatedly collect pending outgoing events and send them. ctx := req.Context() for msg, err := range c.messages(ctx, stream, persistent, lastIndex) { if err != nil { - if ctx.Err() != nil && writes == 0 { - // This probably doesn't matter, but respond with NoContent if the client disconnected. - w.WriteHeader(http.StatusNoContent) + if ctx.Err() == nil && writes == 0 && !persistent { + // If we haven't yet written the header, we have an opportunity to + // promote an error to an HTTP error. + // + // TODO: This may not matter in practice, in which case we should + // simplify. + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } else { - errorf(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) + // TODO(#170): log when we add server-side logging } return } diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 6b69b21..0d171d8 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1312,3 +1312,77 @@ func TestTokenInfo(t *testing.T) { t.Errorf("got %q, want %q", g, w) } } + +func TestStreamableGET(t *testing.T) { + // This test checks the fix for problematic behavior described in #410: + // Hanging GET headers should be written immediately, even if there are no + // messages. + server := NewServer(testImpl, nil) + + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + newReq := func(method string, msg jsonrpc.Message) *http.Request { + var body io.Reader + if msg != nil { + data, err := jsonrpc2.EncodeMessage(msg) + if err != nil { + t.Fatal(err) + } + body = bytes.NewReader(data) + } + req, err := http.NewRequestWithContext(ctx, method, httpServer.URL, body) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Accept", "application/json, text/event-stream") + if msg != nil { + req.Header.Set("Content-Type", "application/json") + } + return req + } + + get1 := newReq(http.MethodGet, nil) + resp, err := http.DefaultClient.Do(get1) + if err != nil { + t.Fatal(err) + } + if got, want := resp.StatusCode, http.StatusMethodNotAllowed; got != want { + t.Errorf("initial GET: got status %d, want %d", got, want) + } + defer resp.Body.Close() + + post1 := newReq(http.MethodPost, req(1, methodInitialize, &InitializeParams{})) + resp, err = http.DefaultClient.Do(post1) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if got, want := resp.StatusCode, http.StatusOK; got != want { + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + t.Errorf("initialize POST: got status %d, want %d; body:\n%s", got, want, string(body)) + } + + sessionID := resp.Header.Get(sessionIDHeader) + if sessionID == "" { + t.Fatalf("initialized missing session ID") + } + + get2 := newReq("GET", nil) + get2.Header.Set(sessionIDHeader, sessionID) + resp, err = http.DefaultClient.Do(get2) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if got, want := resp.StatusCode, http.StatusOK; got != want { + t.Errorf("GET with session ID: got status %d, want %d", got, want) + } +} From 30261728f72dc9f85dc2be3572699fefe097864d Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Thu, 4 Sep 2025 14:04:27 -0400 Subject: [PATCH 021/418] mcp: validate user-provided output schemas (#408) toolForErr was ignoring the output schema if the output type was any. That neglected the case where the user provided their own output schema. Fixes #371. --- mcp/server.go | 4 ++-- mcp/server_test.go | 57 ++++++++++++++++++++++++++++++++++++++++++++++ mcp/tool.go | 1 - 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index bb10079..19d902f 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -212,7 +212,7 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHan elemZero any // only non-nil if Out is a pointer type outputResolved *jsonschema.Resolved ) - if reflect.TypeFor[Out]() != reflect.TypeFor[any]() { + if t.OutputSchema != nil || reflect.TypeFor[Out]() != reflect.TypeFor[any]() { var err error elemZero, err = setSchema[Out](&tt.OutputSchema, &outputResolved) if err != nil { @@ -302,8 +302,8 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHan // TODO(rfindley): we really shouldn't ever return 'null' results. Maybe we // should have a jsonschema.Zero(schema) helper? func setSchema[T any](sfield **jsonschema.Schema, rfield **jsonschema.Resolved) (zero any, err error) { - rt := reflect.TypeFor[T]() if *sfield == nil { + rt := reflect.TypeFor[T]() if rt.Kind() == reflect.Pointer { rt = rt.Elem() zero = reflect.Zero(rt).Interface() diff --git a/mcp/server_test.go b/mcp/server_test.go index 7db4073..e46be37 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -6,6 +6,7 @@ package mcp import ( "context" + "encoding/json" "log" "slices" "testing" @@ -487,3 +488,59 @@ func TestAddTool(t *testing.T) { t.Error("bad Out: expected panic") } } + +type schema = jsonschema.Schema + +func testToolForSchema[In, Out any](t *testing.T, tool *Tool, in string, out Out, wantIn, wantOut *schema, wantErr bool) { + t.Helper() + th := func(context.Context, *CallToolRequest, In) (*CallToolResult, Out, error) { + return nil, out, nil + } + gott, goth, err := toolForErr(tool, th) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(wantIn, gott.InputSchema); diff != "" { + t.Errorf("input: mismatch (-want, +got):\n%s", diff) + } + if diff := cmp.Diff(wantOut, gott.OutputSchema); diff != "" { + t.Errorf("output: mismatch (-want, +got):\n%s", diff) + } + ctr := &CallToolRequest{ + Params: &CallToolParamsRaw{ + Arguments: json.RawMessage(in), + }, + } + _, err = goth(context.Background(), ctr) + + if gotErr := err != nil; gotErr != wantErr { + t.Errorf("got error: %t, want error: %t", gotErr, wantErr) + } +} + +func TestToolForSchemas(t *testing.T) { + // Validate that ToolFor handles schemas properly. + + // Infer both schemas. + testToolForSchema[int](t, &Tool{}, "3", true, + &schema{Type: "integer"}, &schema{Type: "boolean"}, false) + // Validate the input schema: expect an error if it's wrong. + // We can't test that the output schema is validated, because it's typed. + testToolForSchema[int](t, &Tool{}, `"x"`, true, + &schema{Type: "integer"}, &schema{Type: "boolean"}, true) + + // Ignore type any for output. + testToolForSchema[int, any](t, &Tool{}, "3", 0, + &schema{Type: "integer"}, nil, false) + // Input is still validated. + testToolForSchema[int, any](t, &Tool{}, `"x"`, 0, + &schema{Type: "integer"}, nil, true) + + // Tool sets input schema: that is what's used. + testToolForSchema[int, any](t, &Tool{InputSchema: &schema{Type: "string"}}, "3", 0, + &schema{Type: "string"}, nil, true) // error: 3 is not a string + + // Tool sets output schema: that is what's used, and validation happens. + testToolForSchema[string, any](t, &Tool{OutputSchema: &schema{Type: "integer"}}, "3", "x", + &schema{Type: "string"}, &schema{Type: "integer"}, true) // error: "x" is not an integer +} diff --git a/mcp/tool.go b/mcp/tool.go index 1e8ec8b..0797700 100644 --- a/mcp/tool.go +++ b/mcp/tool.go @@ -9,7 +9,6 @@ import ( "context" "encoding/json" "fmt" - // "log" "github.com/google/jsonschema-go/jsonschema" ) From a4313f92bf5acf136777895ed34c16bdf8eb6468 Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Thu, 4 Sep 2025 15:34:39 +0000 Subject: [PATCH 022/418] mcp: refactor the streamable client test to be more flexible Use a fake streamable server to facilitate testing client behavior. For this commit, just update the existing test (moved to a new file for isolation). Subsequent CLs will add more tests. Improve one client error message that occurred while debuging tests. For #393 --- mcp/streamable.go | 9 +- mcp/streamable_client_test.go | 185 ++++++++++++++++++++++++++++++++++ mcp/streamable_test.go | 71 ------------- 3 files changed, 193 insertions(+), 72 deletions(-) create mode 100644 mcp/streamable_client_test.go diff --git a/mcp/streamable.go b/mcp/streamable.go index e7777eb..7a40753 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1234,7 +1234,14 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e default: resp.Body.Close() - return fmt.Errorf("unsupported content type %q", ct) + switch msg := msg.(type) { + case *jsonrpc.Request: + return fmt.Errorf("unsupported content type %q when sending %q (status: %d)", ct, msg.Method, resp.StatusCode) + case *jsonrpc.Response: + return fmt.Errorf("unsupported content type %q when sending jsonrpc response #%d (status: %d)", ct, msg.ID, resp.StatusCode) + default: + panic("unreachable") + } } return nil } diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go new file mode 100644 index 0000000..ee89df5 --- /dev/null +++ b/mcp/streamable_client_test.go @@ -0,0 +1,185 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" +) + +type streamableRequestKey struct { + httpMethod string // http method + sessionID string // session ID header + jsonrpcMethod string // jsonrpc method, or "" for non-requests +} + +type header map[string]string + +type streamableResponse struct { + header header + status int // or http.StatusOK + body string // or "" + optional bool // if set, request need not be sent + wantProtocolVersion string // if "", unchecked + callback func() // if set, called after the request is handled +} + +type fakeResponses map[streamableRequestKey]*streamableResponse + +type fakeStreamableServer struct { + t *testing.T + responses fakeResponses + + callMu sync.Mutex + calls map[streamableRequestKey]int +} + +func (s *fakeStreamableServer) missingRequests() []streamableRequestKey { + s.callMu.Lock() + defer s.callMu.Unlock() + + var unused []streamableRequestKey + for k, resp := range s.responses { + if s.calls[k] == 0 && !resp.optional { + unused = append(unused, k) + } + } + return unused +} + +func (s *fakeStreamableServer) ServeHTTP(w http.ResponseWriter, req *http.Request) { + key := streamableRequestKey{ + httpMethod: req.Method, + sessionID: req.Header.Get(sessionIDHeader), + } + if req.Method == http.MethodPost { + body, err := io.ReadAll(req.Body) + if err != nil { + s.t.Errorf("failed to read body: %v", err) + http.Error(w, "failed to read body", http.StatusInternalServerError) + return + } + msg, err := jsonrpc.DecodeMessage(body) + if err != nil { + s.t.Errorf("invalid body: %v", err) + http.Error(w, "invalid body", http.StatusInternalServerError) + return + } + if r, ok := msg.(*jsonrpc.Request); ok { + key.jsonrpcMethod = r.Method + } + } + + s.callMu.Lock() + if s.calls == nil { + s.calls = make(map[streamableRequestKey]int) + } + s.calls[key]++ + s.callMu.Unlock() + + resp, ok := s.responses[key] + if !ok { + s.t.Errorf("missing response for %v", key) + http.Error(w, "no response", http.StatusInternalServerError) + return + } + if resp.callback != nil { + defer resp.callback() + } + for k, v := range resp.header { + w.Header().Set(k, v) + } + status := resp.status + if status == 0 { + status = http.StatusOK + } + w.WriteHeader(status) + + if v := req.Header.Get(protocolVersionHeader); v != resp.wantProtocolVersion && resp.wantProtocolVersion != "" { + s.t.Errorf("%v: bad protocol version header: got %q, want %q", key, v, resp.wantProtocolVersion) + } + w.Write([]byte(resp.body)) +} + +var ( + initResult = &InitializeResult{ + Capabilities: &ServerCapabilities{ + Completions: &CompletionCapabilities{}, + Logging: &LoggingCapabilities{}, + Tools: &ToolCapabilities{ListChanged: true}, + }, + ProtocolVersion: latestProtocolVersion, + ServerInfo: &Implementation{Name: "testServer", Version: "v1.0.0"}, + } + initResp = resp(1, initResult, nil) +) + +func jsonBody(t *testing.T, msg jsonrpc2.Message) string { + data, err := jsonrpc2.EncodeMessage(msg) + if err != nil { + t.Fatalf("encoding failed: %v", err) + } + return string(data) +} + +func TestStreamableClientTransportLifecycle(t *testing.T) { + ctx := context.Background() + + // The lifecycle test verifies various behavior of the streamable client + // initialization: + // - check that it can handle application/json responses + // - check that it sends the negotiated protocol version + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodInitialize}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "123", + }, + body: jsonBody(t, initResp), + }, + {"POST", "123", notificationInitialized}: { + status: http.StatusAccepted, + wantProtocolVersion: latestProtocolVersion, + }, + {"GET", "123", ""}: { + header: header{ + "Content-Type": "text/event-stream", + }, + optional: true, + wantProtocolVersion: latestProtocolVersion, + }, + {"DELETE", "123", ""}: {}, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, nil) + if err != nil { + t.Fatalf("client.Connect() failed: %v", err) + } + if err := session.Close(); err != nil { + t.Errorf("closing session: %v", err) + } + if missing := fake.missingRequests(); len(missing) > 0 { + t.Errorf("did not receive expected requests: %v", missing) + } + if diff := cmp.Diff(initResult, session.state.InitializeResult); diff != "" { + t.Errorf("mismatch (-want, +got):\n%s", diff) + } +} diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 0d171d8..2963a04 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1035,77 +1035,6 @@ func mustMarshal(v any) json.RawMessage { return data } -func TestStreamableClientTransport(t *testing.T) { - // This test verifies various behavior of the streamable client transport: - // - check that it can handle application/json responses - // - check that it sends the negotiated protocol version - // - // TODO(rfindley): make this test more comprehensive, similar to - // [TestStreamableServerTransport]. - ctx := context.Background() - resp := func(id int64, result any, err error) *jsonrpc.Response { - return &jsonrpc.Response{ - ID: jsonrpc2.Int64ID(id), - Result: mustMarshal(result), - Error: err, - } - } - initResult := &InitializeResult{ - Capabilities: &ServerCapabilities{ - Completions: &CompletionCapabilities{}, - Logging: &LoggingCapabilities{}, - Tools: &ToolCapabilities{ListChanged: true}, - }, - ProtocolVersion: latestProtocolVersion, - ServerInfo: &Implementation{Name: "testServer", Version: "v1.0.0"}, - } - initResp := resp(1, initResult, nil) - - var reqN atomic.Int32 // request count - serverHandler := func(w http.ResponseWriter, r *http.Request) { - rN := reqN.Add(1) - - // TODO(rfindley): if the status code is NoContent or Accepted, we should - // probably be tolerant of when the content type is not application/json. - w.Header().Set("Content-Type", "application/json") - if rN == 1 { - data, err := jsonrpc2.EncodeMessage(initResp) - if err != nil { - t.Errorf("encoding failed: %v", err) - } - w.Header().Set("Mcp-Session-Id", "123") - w.Write(data) - } else { - if v := r.Header.Get(protocolVersionHeader); v != latestProtocolVersion { - t.Errorf("bad protocol version header: got %q, want %q", v, latestProtocolVersion) - } - } - } - - httpServer := httptest.NewServer(http.HandlerFunc(serverHandler)) - defer httpServer.Close() - - transport := &StreamableClientTransport{Endpoint: httpServer.URL} - client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, nil) - if err != nil { - t.Fatalf("client.Connect() failed: %v", err) - } - if err := session.Close(); err != nil { - t.Errorf("closing session: %v", err) - } - - if got, want := reqN.Load(), int32(3); got < want { - // Expect at least 3 requests: initialize, initialized, and DELETE. - // We may or may not observe the GET, depending on timing. - t.Errorf("unexpected number of requests: got %d, want at least %d", got, want) - } - - if diff := cmp.Diff(initResult, session.state.InitializeResult); diff != "" { - t.Errorf("mismatch (-want, +got):\n%s", diff) - } -} - func TestEventID(t *testing.T) { tests := []struct { sid StreamID From 2c40bdc4d783023cd4b62649b9819db07aaa26f0 Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Thu, 4 Sep 2025 17:16:14 +0000 Subject: [PATCH 023/418] mcp: systematically improve streamable client errors The streamable client connection can break for a variety of reasons, asynchronously to the client's request. Decorate these failures with additional context to clarify why they occurred. Add a test for the failure message of #393. Fixes #393 --- mcp/streamable.go | 43 +++++++++-------- mcp/streamable_client_test.go | 90 +++++++++++++++++++++++++++++++++++ mcp/transport.go | 2 +- 3 files changed, 114 insertions(+), 21 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 7a40753..25efe31 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1130,7 +1130,7 @@ func (c *streamableClientConn) sessionUpdated(state clientSessionState) { // § 2.5: A server using the Streamable HTTP transport MAY assign a session // ID at initialization time, by including it in an Mcp-Session-Id header // on the HTTP response containing the InitializeResult. - go c.handleSSE(nil, true, nil) + go c.handleSSE("hanging GET", nil, true, nil) } // fail handles an asynchronous error while reading. @@ -1224,24 +1224,27 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e return nil } + var requestSummary string + switch msg := msg.(type) { + case *jsonrpc.Request: + requestSummary = fmt.Sprintf("sending %q", msg.Method) + case *jsonrpc.Response: + requestSummary = fmt.Sprintf("sending jsonrpc response #%d", msg.ID) + default: + panic("unreachable") + } + switch ct := resp.Header.Get("Content-Type"); ct { case "application/json": - go c.handleJSON(resp) + go c.handleJSON(requestSummary, resp) case "text/event-stream": jsonReq, _ := msg.(*jsonrpc.Request) - go c.handleSSE(resp, false, jsonReq) + go c.handleSSE(requestSummary, resp, false, jsonReq) default: resp.Body.Close() - switch msg := msg.(type) { - case *jsonrpc.Request: - return fmt.Errorf("unsupported content type %q when sending %q (status: %d)", ct, msg.Method, resp.StatusCode) - case *jsonrpc.Response: - return fmt.Errorf("unsupported content type %q when sending jsonrpc response #%d (status: %d)", ct, msg.ID, resp.StatusCode) - default: - panic("unreachable") - } + return fmt.Errorf("%s: unsupported content type %q", requestSummary, ct) } return nil } @@ -1265,16 +1268,16 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request) { } } -func (c *streamableClientConn) handleJSON(resp *http.Response) { +func (c *streamableClientConn) handleJSON(requestSummary string, resp *http.Response) { body, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { - c.fail(err) + c.fail(fmt.Errorf("%s: failed to read body: %v", requestSummary, err)) return } msg, err := jsonrpc.DecodeMessage(body) if err != nil { - c.fail(fmt.Errorf("failed to decode response: %v", err)) + c.fail(fmt.Errorf("%s: failed to decode response: %v", requestSummary, err)) return } select { @@ -1289,12 +1292,12 @@ func (c *streamableClientConn) handleJSON(resp *http.Response) { // // If forReq is set, it is the request that initiated the stream, and the // stream is complete when we receive its response. -func (c *streamableClientConn) handleSSE(initialResp *http.Response, persistent bool, forReq *jsonrpc2.Request) { +func (c *streamableClientConn) handleSSE(requestSummary string, initialResp *http.Response, persistent bool, forReq *jsonrpc2.Request) { resp := initialResp var lastEventID string for { if resp != nil { - eventID, clientClosed := c.processStream(resp, forReq) + eventID, clientClosed := c.processStream(requestSummary, resp, forReq) lastEventID = eventID // If the connection was closed by the client, we're done. @@ -1312,7 +1315,7 @@ func (c *streamableClientConn) handleSSE(initialResp *http.Response, persistent newResp, err := c.reconnect(lastEventID) if err != nil { // All reconnection attempts failed: fail the connection. - c.fail(err) + c.fail(fmt.Errorf("%s: failed to reconnect: %v", requestSummary, err)) return } resp = newResp @@ -1323,7 +1326,7 @@ func (c *streamableClientConn) handleSSE(initialResp *http.Response, persistent } if resp.StatusCode < 200 || resp.StatusCode >= 300 { resp.Body.Close() - c.fail(fmt.Errorf("failed to reconnect: %v", http.StatusText(resp.StatusCode))) + c.fail(fmt.Errorf("%s: failed to reconnect: %v", requestSummary, http.StatusText(resp.StatusCode))) return } // Reconnection was successful. Continue the loop with the new response. @@ -1334,7 +1337,7 @@ func (c *streamableClientConn) handleSSE(initialResp *http.Response, persistent // incoming channel. It returns the ID of the last processed event and a flag // indicating if the connection was closed by the client. If resp is nil, it // returns "", false. -func (c *streamableClientConn) processStream(resp *http.Response, forReq *jsonrpc.Request) (lastEventID string, clientClosed bool) { +func (c *streamableClientConn) processStream(requestSummary string, resp *http.Response, forReq *jsonrpc.Request) (lastEventID string, clientClosed bool) { defer resp.Body.Close() for evt, err := range scanEvents(resp.Body) { if err != nil { @@ -1347,7 +1350,7 @@ func (c *streamableClientConn) processStream(resp *http.Response, forReq *jsonrp msg, err := jsonrpc.DecodeMessage(evt.Data) if err != nil { - c.fail(fmt.Errorf("failed to decode event: %v", err)) + c.fail(fmt.Errorf("%s: failed to decode event: %v", requestSummary, err)) return "", true } diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index ee89df5..fe87b21 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -6,11 +6,14 @@ package mcp import ( "context" + "fmt" "io" "net/http" "net/http/httptest" + "strings" "sync" "testing" + "time" "github.com/google/go-cmp/cmp" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" @@ -183,3 +186,90 @@ func TestStreamableClientTransportLifecycle(t *testing.T) { t.Errorf("mismatch (-want, +got):\n%s", diff) } } + +func TestStreamableClientGETHandling(t *testing.T) { + ctx := context.Background() + + tests := []struct { + status int + wantErrorContaining string + }{ + {http.StatusOK, ""}, + {http.StatusMethodNotAllowed, ""}, + {http.StatusBadRequest, "hanging GET"}, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("status=%d", test.status), func(t *testing.T) { + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodInitialize}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "123", + }, + body: jsonBody(t, initResp), + }, + {"POST", "123", notificationInitialized}: { + status: http.StatusAccepted, + wantProtocolVersion: latestProtocolVersion, + }, + {"GET", "123", ""}: { + header: header{ + "Content-Type": "text/event-stream", + }, + status: test.status, + wantProtocolVersion: latestProtocolVersion, + }, + {"POST", "123", methodListTools}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "123", + }, + body: jsonBody(t, resp(2, &ListToolsResult{Tools: []*Tool{}}, nil)), + optional: true, + }, + {"DELETE", "123", ""}: {optional: true}, + }, + } + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, nil) + if err != nil { + t.Fatalf("client.Connect() failed: %v", err) + } + + // wait for all required requests to be handled, with exponential + // backoff. + start := time.Now() + delay := 1 * time.Millisecond + for range 10 { + if len(fake.missingRequests()) == 0 { + break + } + time.Sleep(delay) + delay *= 2 + } + if missing := fake.missingRequests(); len(missing) > 0 { + t.Errorf("did not receive expected requests after %s: %v", time.Since(start), missing) + } + + _, err = session.ListTools(ctx, nil) + if (err != nil) != (test.wantErrorContaining != "") { + t.Errorf("After initialization, got error %v, want %v", err, test.wantErrorContaining) + } else if err != nil { + if !strings.Contains(err.Error(), test.wantErrorContaining) { + t.Errorf("After initialization, got error %s, want containing %q", err, test.wantErrorContaining) + } + } + + if err := session.Close(); err != nil { + t.Errorf("closing session: %v", err) + } + }) + } +} diff --git a/mcp/transport.go b/mcp/transport.go index fac640a..5c7ca13 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -194,7 +194,7 @@ func call(ctx context.Context, conn *jsonrpc2.Connection, method string, params err := call.Await(ctx, result) switch { case errors.Is(err, jsonrpc2.ErrClientClosing), errors.Is(err, jsonrpc2.ErrServerClosing): - return fmt.Errorf("calling %q: %w", method, ErrConnectionClosed) + return fmt.Errorf("%w: calling %q: %v", ErrConnectionClosed, method, err) case ctx.Err() != nil: // Notify the peer of cancellation. err := conn.Notify(xcontext.Detach(ctx), notificationCancelled, &CancelledParams{ From ee5141621c346d52cb2c61a357845b4a2d33b2fd Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Thu, 4 Sep 2025 17:39:05 +0000 Subject: [PATCH 024/418] mcp: be strict about returning the Mcp-Session-Id header Rather than returning the Mcp-Session-Id header for all responses, only return it from initialize, per the spec. Fixes #412 --- mcp/streamable.go | 19 +++++++++++----- mcp/streamable_test.go | 49 +++++++++++++++++++----------------------- 2 files changed, 36 insertions(+), 32 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 25efe31..1eef9a7 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -424,7 +424,7 @@ func (t *StreamableServerTransport) Connect(ctx context.Context) (Connection, er // It is always text/event-stream, since it must carry arbitrarily many // messages. var err error - t.connection.streams[""], err = t.connection.newStream(ctx, "", false) + t.connection.streams[""], err = t.connection.newStream(ctx, "", false, false) if err != nil { return nil, err } @@ -485,6 +485,10 @@ type stream struct { // an empty string is used for messages that don't correlate with an incoming request. id StreamID + // If isInitialize is set, the stream is in response to an initialize request, + // and therefore should include the session ID header. + isInitialize bool + // jsonResponse records whether this stream should respond with application/json // instead of text/event-stream. // @@ -513,12 +517,13 @@ type stream struct { requests map[jsonrpc.ID]struct{} } -func (c *streamableServerConn) newStream(ctx context.Context, id StreamID, jsonResponse bool) (*stream, error) { +func (c *streamableServerConn) newStream(ctx context.Context, id StreamID, isInitialize, jsonResponse bool) (*stream, error) { if err := c.eventStore.Open(ctx, c.sessionID, id); err != nil { return nil, err } return &stream{ id: id, + isInitialize: isInitialize, jsonResponse: jsonResponse, requests: make(map[jsonrpc.ID]struct{}), }, nil @@ -647,6 +652,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques } requests := make(map[jsonrpc.ID]struct{}) tokenInfo := auth.TokenInfoFromContext(req.Context()) + isInitialize := false for _, msg := range incoming { if jreq, ok := msg.(*jsonrpc.Request); ok { // Preemptively check that this is a valid request, so that we can fail @@ -656,6 +662,9 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques http.Error(w, err.Error(), http.StatusBadRequest) return } + if jreq.Method == methodInitialize { + isInitialize = true + } jreq.Extra = &RequestExtra{ TokenInfo: tokenInfo, Header: req.Header, @@ -672,7 +681,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // notifications or server->client requests made in the course of handling. // Update accounting for this incoming payload. if len(requests) > 0 { - stream, err = c.newStream(req.Context(), StreamID(randText()), c.jsonResponse) + stream, err = c.newStream(req.Context(), StreamID(randText()), isInitialize, c.jsonResponse) if err != nil { http.Error(w, fmt.Sprintf("storing stream: %v", err), http.StatusInternalServerError) return @@ -708,7 +717,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques func (c *streamableServerConn) respondJSON(stream *stream, w http.ResponseWriter, req *http.Request) { w.Header().Set("Cache-Control", "no-cache, no-transform") w.Header().Set("Content-Type", "application/json") - if c.sessionID != "" { + if c.sessionID != "" && stream.isInitialize { w.Header().Set(sessionIDHeader, c.sessionID) } @@ -747,7 +756,7 @@ func (c *streamableServerConn) respondSSE(stream *stream, w http.ResponseWriter, w.Header().Set("Cache-Control", "no-cache, no-transform") w.Header().Set("Content-Type", "text/event-stream") // Accept checked in [StreamableHTTPHandler] w.Header().Set("Connection", "keep-alive") - if c.sessionID != "" { + if c.sessionID != "" && stream.isInitialize { w.Header().Set(sessionIDHeader, c.sessionID) } if persistent { diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 2963a04..3f897ba 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -133,7 +133,7 @@ func TestStreamableTransports(t *testing.T) { defer session.Close() sid := session.ID() if sid == "" { - t.Error("empty session ID") + t.Fatalf("empty session ID") } if g, w := session.mcpConn.(*streamableClientConn).initializedResult.ProtocolVersion, latestProtocolVersion; g != w { t.Fatalf("got protocol version %q, want %q", g, w) @@ -475,6 +475,8 @@ func resp(id int64, result any, err error) *jsonrpc.Response { } } +var () + func TestStreamableServerTransport(t *testing.T) { // This test checks detailed behavior of the streamable server transport, by // faking the behavior of a streamable client using a sequence of HTTP @@ -502,7 +504,6 @@ func TestStreamableServerTransport(t *testing.T) { method: "POST", messages: []jsonrpc.Message{initializedMsg}, wantStatusCode: http.StatusAccepted, - wantSessionID: false, // TODO: should this be true? } tests := []struct { @@ -520,7 +521,6 @@ func TestStreamableServerTransport(t *testing.T) { messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{}, nil)}, - wantSessionID: true, }, }, }, @@ -535,14 +535,12 @@ func TestStreamableServerTransport(t *testing.T) { headers: http.Header{"Accept": {"text/plain", "application/*"}}, messages: []jsonrpc.Message{req(3, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusBadRequest, // missing text/event-stream - wantSessionID: false, }, { method: "POST", headers: http.Header{"Accept": {"text/event-stream"}}, messages: []jsonrpc.Message{req(3, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusBadRequest, // missing application/json - wantSessionID: false, }, { method: "POST", @@ -550,7 +548,6 @@ func TestStreamableServerTransport(t *testing.T) { messages: []jsonrpc.Message{req(4, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(4, &CallToolResult{}, nil)}, - wantSessionID: true, }, { method: "POST", @@ -558,7 +555,6 @@ func TestStreamableServerTransport(t *testing.T) { messages: []jsonrpc.Message{req(4, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(4, &CallToolResult{}, nil)}, - wantSessionID: true, }, }, }, @@ -598,7 +594,6 @@ func TestStreamableServerTransport(t *testing.T) { req(0, "notifications/progress", &ProgressNotificationParams{}), resp(2, &CallToolResult{}, nil), }, - wantSessionID: true, }, }, }, @@ -620,7 +615,6 @@ func TestStreamableServerTransport(t *testing.T) { resp(1, &ListRootsResult{}, nil), }, wantStatusCode: http.StatusAccepted, - wantSessionID: false, }, { method: "POST", @@ -632,7 +626,6 @@ func TestStreamableServerTransport(t *testing.T) { req(1, "roots/list", &ListRootsParams{}), resp(2, &CallToolResult{}, nil), }, - wantSessionID: true, }, }, }, @@ -663,7 +656,6 @@ func TestStreamableServerTransport(t *testing.T) { resp(1, &ListRootsResult{}, nil), }, wantStatusCode: http.StatusAccepted, - wantSessionID: false, }, { method: "GET", @@ -674,7 +666,6 @@ func TestStreamableServerTransport(t *testing.T) { req(0, "notifications/progress", &ProgressNotificationParams{}), req(1, "roots/list", &ListRootsParams{}), }, - wantSessionID: true, }, { method: "POST", @@ -685,7 +676,6 @@ func TestStreamableServerTransport(t *testing.T) { wantMessages: []jsonrpc.Message{ resp(2, &CallToolResult{}, nil), }, - wantSessionID: true, }, { method: "DELETE", @@ -724,7 +714,6 @@ func TestStreamableServerTransport(t *testing.T) { wantMessages: []jsonrpc.Message{resp(2, nil, &jsonrpc2.WireError{ Message: `method "tools/call" is invalid during session initialization`, })}, - wantSessionID: true, // TODO: this is probably wrong; we don't have a valid session }, }, }, @@ -951,7 +940,7 @@ func (s streamableRequest) do(ctx context.Context, serverURL, sessionID string, return "", 0, nil, fmt.Errorf("creating request: %w", err) } if sessionID != "" { - req.Header.Set("Mcp-Session-Id", sessionID) + req.Header.Set(sessionIDHeader, sessionID) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") @@ -963,7 +952,7 @@ func (s streamableRequest) do(ctx context.Context, serverURL, sessionID string, } defer resp.Body.Close() - newSessionID := resp.Header.Get("Mcp-Session-Id") + newSessionID := resp.Header.Get(sessionIDHeader) contentType := resp.Header.Get("Content-Type") var respBody []byte @@ -1079,6 +1068,15 @@ func TestEventID(t *testing.T) { } func TestStreamableStateless(t *testing.T) { + initReq := req(1, methodInitialize, &InitializeParams{}) + initResp := resp(1, &InitializeResult{ + Capabilities: &ServerCapabilities{ + Logging: &LoggingCapabilities{}, + Tools: &ToolCapabilities{ListChanged: true}, + }, + ProtocolVersion: latestProtocolVersion, + ServerInfo: &Implementation{Name: "test", Version: "v1.0.0"}, + }, nil) // This version of sayHi expects // that request from our client). sayHi := func(ctx context.Context, req *CallToolRequest, args hiParams) (*CallToolResult, any, error) { @@ -1092,17 +1090,22 @@ func TestStreamableStateless(t *testing.T) { AddTool(server, &Tool{Name: "greet", Description: "say hi"}, sayHi) requests := []streamableRequest{ + { + method: "POST", + messages: []jsonrpc.Message{initReq}, + wantStatusCode: http.StatusOK, + wantMessages: []jsonrpc.Message{initResp}, + wantSessionID: false, // sessionless + }, { method: "POST", wantStatusCode: http.StatusOK, messages: []jsonrpc.Message{req(1, "tools/list", struct{}{})}, wantBodyContaining: "greet", - wantSessionID: false, }, { method: "GET", wantStatusCode: http.StatusMethodNotAllowed, - wantSessionID: false, }, { method: "POST", @@ -1116,7 +1119,6 @@ func TestStreamableStateless(t *testing.T) { StructuredContent: json.RawMessage("null"), }, nil), }, - wantSessionID: false, }, { method: "POST", @@ -1130,7 +1132,6 @@ func TestStreamableStateless(t *testing.T) { StructuredContent: json.RawMessage("null"), }, nil), }, - wantSessionID: false, }, } @@ -1166,13 +1167,7 @@ func TestStreamableStateless(t *testing.T) { // // This can be used by tools to look up application state preserved across // subsequent requests. - for i, req := range requests { - // Now, we want a session for all (valid) requests. - if req.wantStatusCode != http.StatusMethodNotAllowed { - req.wantSessionID = true - } - requests[i] = req - } + requests[0].wantSessionID = true // now expect a session ID for initialize statelessHandler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{ Stateless: true, }) From 9d34aff732740c09ade9f39dc5f61466ff33d1f2 Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Thu, 4 Sep 2025 19:58:12 +0000 Subject: [PATCH 025/418] mcp: avoid "null" in structuredContent Avoid a typed nil in structuredContent (yet another typed nil), leading to "null" on the wire. Fixes #417 --- mcp/server.go | 26 +++++++------- mcp/streamable_test.go | 6 ++-- mcp/testdata/conformance/server/tools.txtar | 38 ++++++++++++++++++++- 3 files changed, 53 insertions(+), 17 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index 19d902f..508552e 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -267,19 +267,21 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHan outval = elemZero } } - outbytes, err := json.Marshal(outval) - if err != nil { - return nil, fmt.Errorf("marshaling output: %w", err) - } - res.StructuredContent = json.RawMessage(outbytes) // avoid a second marshal over the wire + if outval != nil { + outbytes, err := json.Marshal(outval) + if err != nil { + return nil, fmt.Errorf("marshaling output: %w", err) + } + res.StructuredContent = json.RawMessage(outbytes) // avoid a second marshal over the wire - // If the Content field isn't being used, return the serialized JSON in a - // TextContent block, as the spec suggests: - // https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content. - if res.Content == nil { - res.Content = []Content{&TextContent{ - Text: string(outbytes), - }} + // If the Content field isn't being used, return the serialized JSON in a + // TextContent block, as the spec suggests: + // https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content. + if res.Content == nil { + res.Content = []Content{&TextContent{ + Text: string(outbytes), + }} + } } return res, nil } // end of handler diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 3f897ba..1e7a63c 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1115,8 +1115,7 @@ func TestStreamableStateless(t *testing.T) { }, wantMessages: []jsonrpc.Message{ resp(2, &CallToolResult{ - Content: []Content{&TextContent{Text: "hi World"}}, - StructuredContent: json.RawMessage("null"), + Content: []Content{&TextContent{Text: "hi World"}}, }, nil), }, }, @@ -1128,8 +1127,7 @@ func TestStreamableStateless(t *testing.T) { }, wantMessages: []jsonrpc.Message{ resp(2, &CallToolResult{ - Content: []Content{&TextContent{Text: "hi foo"}}, - StructuredContent: json.RawMessage("null"), + Content: []Content{&TextContent{Text: "hi foo"}}, }, nil), }, }, diff --git a/mcp/testdata/conformance/server/tools.txtar b/mcp/testdata/conformance/server/tools.txtar index 870e9ea..c39e3ec 100644 --- a/mcp/testdata/conformance/server/tools.txtar +++ b/mcp/testdata/conformance/server/tools.txtar @@ -21,11 +21,15 @@ structured "clientInfo": { "name": "ExampleClient", "version": "1.0.0" } } } -{"jsonrpc":"2.0", "method": "notifications/initialized"} +{ "jsonrpc":"2.0", "method": "notifications/initialized" } { "jsonrpc": "2.0", "id": 2, "method": "tools/list" } { "jsonrpc": "2.0", "id": 3, "method": "resources/list" } { "jsonrpc": "2.0", "id": 4, "method": "prompts/list" } { "jsonrpc": "2.0", "id": 5, "method": "tools/call" } +{ "jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": {"name": "greet", "arguments": {"name": "you"} } } +{ "jsonrpc": "2.0", "id": 1, "result": {} } +{ "jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": {"name": "structured", "arguments": {"In": "input"} } } + -- server -- { "jsonrpc": "2.0", @@ -119,3 +123,35 @@ structured "message": "invalid request: missing required \"params\"" } } +{ + "jsonrpc": "2.0", + "id": 1, + "method": "ping" +} +{ + "jsonrpc": "2.0", + "id": 6, + "result": { + "content": [ + { + "type": "text", + "text": "hi you" + } + ] + } +} +{ + "jsonrpc": "2.0", + "id": 7, + "result": { + "content": [ + { + "type": "text", + "text": "{\"Out\":\"Ack input\"}" + } + ], + "structuredContent": { + "Out": "Ack input" + } + } +} From b774809086db4bcf9262d40049ff82855da5ab5a Mon Sep 17 00:00:00 2001 From: Kason Braley <59150626+KasonBraley@users.noreply.github.com> Date: Fri, 5 Sep 2025 04:58:26 -0700 Subject: [PATCH 026/418] examples: Update and fix outdated (#420) Update examples/server/rate-limiting to use the same replace directive strategy as the auth-middleware example, and fix the breakages. Also remove the unneeded type parameters in examples/client/middleware. --- examples/client/middleware/main.go | 4 +-- examples/server/rate-limiting/go.mod | 11 +++++--- examples/server/rate-limiting/go.sum | 6 +++-- examples/server/rate-limiting/main.go | 36 +++++++++++++-------------- 4 files changed, 32 insertions(+), 25 deletions(-) diff --git a/examples/client/middleware/main.go b/examples/client/middleware/main.go index 6ae87df..9b6d1bb 100644 --- a/examples/client/middleware/main.go +++ b/examples/client/middleware/main.go @@ -17,10 +17,10 @@ var nextProgressToken atomic.Int64 // from the client. func main() { c := mcp.NewClient(&mcp.Implementation{Name: "test"}, nil) - c.AddSendingMiddleware(addProgressToken[*mcp.ClientSession]) + c.AddSendingMiddleware(addProgressToken) } -func addProgressToken[S mcp.Session](h mcp.MethodHandler) mcp.MethodHandler { +func addProgressToken(h mcp.MethodHandler) mcp.MethodHandler { return func(ctx context.Context, method string, req mcp.Request) (result mcp.Result, err error) { if rp, ok := req.GetParams().(mcp.RequestParams); ok { rp.SetProgressToken(nextProgressToken.Add(1)) diff --git a/examples/server/rate-limiting/go.mod b/examples/server/rate-limiting/go.mod index 5b76b16..2b4b00d 100644 --- a/examples/server/rate-limiting/go.mod +++ b/examples/server/rate-limiting/go.mod @@ -2,9 +2,14 @@ module github.com/modelcontextprotocol/go-sdk/examples/rate-limiting go 1.23.0 -toolchain go1.24.4 - require ( - github.com/modelcontextprotocol/go-sdk v0.1.0 + github.com/modelcontextprotocol/go-sdk v0.3.0 golang.org/x/time v0.12.0 ) + +require ( + github.com/google/jsonschema-go v0.2.1-0.20250825175020-748c325cec76 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect +) + +replace github.com/modelcontextprotocol/go-sdk => ../../../ diff --git a/examples/server/rate-limiting/go.sum b/examples/server/rate-limiting/go.sum index d73f0a5..16b70ef 100644 --- a/examples/server/rate-limiting/go.sum +++ b/examples/server/rate-limiting/go.sum @@ -1,7 +1,9 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/modelcontextprotocol/go-sdk v0.1.0 h1:ItzbFWYNt4EHcUrScX7P8JPASn1FVYb29G773Xkl+IU= -github.com/modelcontextprotocol/go-sdk v0.1.0/go.mod h1:DcXfbr7yl7e35oMpzHfKw2nUYRjhIGS2uou/6tdsTB0= +github.com/google/jsonschema-go v0.2.1-0.20250825175020-748c325cec76 h1:mBlBwtDebdDYr+zdop8N62a44g+Nbv7o2KjWyS1deR4= +github.com/google/jsonschema-go v0.2.1-0.20250825175020-748c325cec76/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= diff --git a/examples/server/rate-limiting/main.go b/examples/server/rate-limiting/main.go index c3265c4..e107183 100644 --- a/examples/server/rate-limiting/main.go +++ b/examples/server/rate-limiting/main.go @@ -18,13 +18,13 @@ import ( // GlobalRateLimiterMiddleware creates a middleware that applies a global rate limit. // Every request attempting to pass through will try to acquire a token. // If a token cannot be acquired immediately, the request will be rejected. -func GlobalRateLimiterMiddleware[S mcp.Session](limiter *rate.Limiter) mcp.Middleware[S] { - return func(next mcp.MethodHandler[S]) mcp.MethodHandler[S] { - return func(ctx context.Context, session S, method string, params mcp.Params) (mcp.Result, error) { +func GlobalRateLimiterMiddleware(limiter *rate.Limiter) mcp.Middleware { + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { if !limiter.Allow() { return nil, errors.New("JSON RPC overloaded") } - return next(ctx, session, method, params) + return next(ctx, method, req) } } } @@ -32,40 +32,40 @@ func GlobalRateLimiterMiddleware[S mcp.Session](limiter *rate.Limiter) mcp.Middl // PerMethodRateLimiterMiddleware creates a middleware that applies rate limiting // on a per-method basis. // Methods not specified in limiters will not be rate limited by this middleware. -func PerMethodRateLimiterMiddleware[S mcp.Session](limiters map[string]*rate.Limiter) mcp.Middleware[S] { - return func(next mcp.MethodHandler[S]) mcp.MethodHandler[S] { - return func(ctx context.Context, session S, method string, params mcp.Params) (mcp.Result, error) { +func PerMethodRateLimiterMiddleware(limiters map[string]*rate.Limiter) mcp.Middleware { + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { if limiter, ok := limiters[method]; ok { if !limiter.Allow() { return nil, errors.New("JSON RPC overloaded") } } - return next(ctx, session, method, params) + return next(ctx, method, req) } } } // PerSessionRateLimiterMiddleware creates a middleware that applies rate limiting // on a per-session basis for receiving requests. -func PerSessionRateLimiterMiddleware[S mcp.Session](limit rate.Limit, burst int) mcp.Middleware[S] { +func PerSessionRateLimiterMiddleware(limit rate.Limit, burst int) mcp.Middleware { // A map to store limiters, keyed by the session ID. var ( sessionLimiters = make(map[string]*rate.Limiter) mu sync.Mutex ) - return func(next mcp.MethodHandler[S]) mcp.MethodHandler[S] { - return func(ctx context.Context, session S, method string, params mcp.Params) (mcp.Result, error) { + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { // It's possible that session.ID() may be empty at this point in time // for some transports (e.g., stdio) or until the MCP initialize handshake // has completed. - sessionID := session.ID() + sessionID := req.GetSession().ID() if sessionID == "" { // In this situation, you could apply a single global identifier // if session ID is empty or bypass the rate limiter. // In this example, we bypass the rate limiter. log.Printf("Warning: Session ID is empty for method %q. Skipping per-session rate limiting.", method) - return next(ctx, session, method, params) // Skip limiting if ID is unavailable + return next(ctx, method, req) // Skip limiting if ID is unavailable } mu.Lock() limiter, ok := sessionLimiters[sessionID] @@ -77,19 +77,19 @@ func PerSessionRateLimiterMiddleware[S mcp.Session](limit rate.Limit, burst int) if !limiter.Allow() { return nil, errors.New("JSON RPC overloaded") } - return next(ctx, session, method, params) + return next(ctx, method, req) } } } func main() { - server := mcp.NewServer("greeter1", "v0.0.1", nil) - server.AddReceivingMiddleware(GlobalRateLimiterMiddleware[*mcp.ServerSession](rate.NewLimiter(rate.Every(time.Second/5), 10))) - server.AddReceivingMiddleware(PerMethodRateLimiterMiddleware[*mcp.ServerSession](map[string]*rate.Limiter{ + server := mcp.NewServer(&mcp.Implementation{Name: "greeter1", Version: "v0.0.1"}, nil) + server.AddReceivingMiddleware(GlobalRateLimiterMiddleware(rate.NewLimiter(rate.Every(time.Second/5), 10))) + server.AddReceivingMiddleware(PerMethodRateLimiterMiddleware(map[string]*rate.Limiter{ "callTool": rate.NewLimiter(rate.Every(time.Second), 5), // once a second with a burst up to 5 "listTools": rate.NewLimiter(rate.Every(time.Minute), 20), // once a minute with a burst up to 20 })) - server.AddReceivingMiddleware(PerSessionRateLimiterMiddleware[*mcp.ServerSession](rate.Every(time.Second/5), 10)) + server.AddReceivingMiddleware(PerSessionRateLimiterMiddleware(rate.Every(time.Second/5), 10)) // Run Server logic. log.Println("MCP Server instance created with Middleware (but not running).") log.Println("This example demonstrates configuration, not live interaction.") From 5ccd97fafcaed9725b5e26ca84f9023a110531c0 Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Fri, 5 Sep 2025 01:10:52 +0000 Subject: [PATCH 027/418] mcp: allow requests before notifications/initialized Upon a closer reading of the spec, it is OK for the server to response to client requests before it has received notifications/initialized (as long as it has received initialize). This effectively rolls back the fix from #225. Some hooks are left for enforcing strictness around server->client requests prior to initialized. These will be revisited in subsequent CLs. For #395 --- mcp/mcp_test.go | 2 +- mcp/server.go | 38 ++++++++++++++++++- mcp/shared.go | 5 ++- .../conformance/server/lifecycle.txtar | 14 +++++-- 4 files changed, 53 insertions(+), 6 deletions(-) diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 28a8b97..aee06a3 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -1415,7 +1415,7 @@ func TestElicitationCapabilityDeclaration(t *testing.T) { RequestedSchema: &jsonschema.Schema{Type: "object"}, }) if err != nil { - t.Errorf("elicitation should work when capability is declared, got error: %v", err) + t.Fatalf("elicitation should work when capability is declared, got error: %v", err) } if result.Action != "cancel" { t.Errorf("got action %q, want %q", result.Action, "cancel") diff --git a/mcp/server.go b/mcp/server.go index 508552e..df97b58 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -844,6 +844,33 @@ func (ss *ServerSession) updateState(mut func(*ServerSessionState)) { } } +// hasInitialized reports whether the server has received the initialized +// notification. +// +// TODO(findleyr): use this to prevent change notifications. +func (ss *ServerSession) hasInitialized() bool { + ss.mu.Lock() + defer ss.mu.Unlock() + return ss.state.InitializedParams != nil +} + +// checkInitialized returns a formatted error if the server has not yet +// received the initialized notification. +func (ss *ServerSession) checkInitialized(method string) error { + if !ss.hasInitialized() { + // TODO(rfindley): enable this check. + // Right now is is flaky, because server tests don't await the initialized notification. + // Perhaps requests should simply block until they have received the initialized notification + + // if strings.HasPrefix(method, "notifications/") { + // return fmt.Errorf("must not send %q before %q is received", method, notificationInitialized) + // } else { + // return fmt.Errorf("cannot call %q before %q is received", method, notificationInitialized) + // } + } + return nil +} + func (ss *ServerSession) ID() string { if c, ok := ss.mcpConn.(hasSessionID); ok { return c.SessionID() @@ -859,11 +886,17 @@ func (ss *ServerSession) Ping(ctx context.Context, params *PingParams) error { // ListRoots lists the client roots. func (ss *ServerSession) ListRoots(ctx context.Context, params *ListRootsParams) (*ListRootsResult, error) { + if err := ss.checkInitialized(methodListRoots); err != nil { + return nil, err + } return handleSend[*ListRootsResult](ctx, methodListRoots, newServerRequest(ss, orZero[Params](params))) } // CreateMessage sends a sampling request to the client. func (ss *ServerSession) CreateMessage(ctx context.Context, params *CreateMessageParams) (*CreateMessageResult, error) { + if err := ss.checkInitialized(methodCreateMessage); err != nil { + return nil, err + } if params == nil { params = &CreateMessageParams{Messages: []*SamplingMessage{}} } @@ -877,6 +910,9 @@ func (ss *ServerSession) CreateMessage(ctx context.Context, params *CreateMessag // Elicit sends an elicitation request to the client asking for user input. func (ss *ServerSession) Elicit(ctx context.Context, params *ElicitParams) (*ElicitResult, error) { + if err := ss.checkInitialized(methodElicit); err != nil { + return nil, err + } return handleSend[*ElicitResult](ctx, methodElicit, newServerRequest(ss, orZero[Params](params))) } @@ -978,7 +1014,7 @@ func (ss *ServerSession) getConn() *jsonrpc2.Connection { return ss.conn } // handle invokes the method described by the given JSON RPC request. func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, error) { ss.mu.Lock() - initialized := ss.state.InitializedParams != nil + initialized := ss.state.InitializeParams != nil ss.mu.Unlock() // From the spec: diff --git a/mcp/shared.go b/mcp/shared.go index e3ad6ff..2990392 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -346,9 +346,12 @@ func notifySessions[S Session, P Params](sessions []S, method string, params P) if sessions == nil { return } - // TODO: make this timeout configurable, or call Notify asynchronously. + // TODO: make this timeout configurable, or call handleNotify asynchronously. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() + + // TODO: there's a potential spec violation here, when the feature list + // changes before the session (client or server) is initialized. for _, s := range sessions { req := newRequest(s, params) if err := handleNotify(ctx, method, req); err != nil { diff --git a/mcp/testdata/conformance/server/lifecycle.txtar b/mcp/testdata/conformance/server/lifecycle.txtar index eba287e..0a8cf34 100644 --- a/mcp/testdata/conformance/server/lifecycle.txtar +++ b/mcp/testdata/conformance/server/lifecycle.txtar @@ -5,6 +5,7 @@ See also modelcontextprotocol/go-sdk#225. -- client -- { "jsonrpc":"2.0", "method": "notifications/initialized" } +{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" } { "jsonrpc": "2.0", "id": 1, @@ -21,6 +22,14 @@ See also modelcontextprotocol/go-sdk#225. { "jsonrpc": "2.0", "id": 3, "method": "tools/list" } -- server -- +{ + "jsonrpc": "2.0", + "id": 2, + "error": { + "code": 0, + "message": "method \"tools/list\" is invalid during session initialization" + } +} { "jsonrpc": "2.0", "id": 1, @@ -43,9 +52,8 @@ See also modelcontextprotocol/go-sdk#225. { "jsonrpc": "2.0", "id": 2, - "error": { - "code": 0, - "message": "method \"tools/list\" is invalid during session initialization" + "result": { + "tools": [] } } { From 40b6bd30f1fbaf520d33d0c941d6cd6b0b45c6b7 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Fri, 5 Sep 2025 14:11:11 -0400 Subject: [PATCH 028/418] all: add examples of customizing tool schemas, and clarify documentation (#421) Add example/server/toolschemas, which demonstrates various ways to customize tool schemas, and illustrates the differences between ToolHandler and ToolHandlerFor. Fixes #385 Fixes #386 For #368 --- README.md | 105 ++++++++++---------- examples/server/toolschemas/main.go | 146 ++++++++++++++++++++++++++++ internal/readme/README.src.md | 50 +++++----- internal/readme/server/server.go | 13 +-- mcp/protocol.go | 39 ++++++-- mcp/server.go | 20 ++-- mcp/tool.go | 40 +++++++- 7 files changed, 311 insertions(+), 102 deletions(-) create mode 100644 examples/server/toolschemas/main.go diff --git a/README.md b/README.md index 126aa52..606f579 100644 --- a/README.md +++ b/README.md @@ -19,18 +19,6 @@ software development kit (SDK) for the Model Context Protocol (MCP). > changes. We aim to tag v1.0.0 in September, 2025. See > https://github.com/modelcontextprotocol/go-sdk/issues/328 for details. -## Design - -The design doc for this SDK is at [design.md](./design/design.md), which was -initially reviewed at -[modelcontextprotocol/discussions/364](https://github.com/orgs/modelcontextprotocol/discussions/364). - -Further design discussion should occur in -[issues](https://github.com/modelcontextprotocol/go-sdk/issues) (for concrete -proposals) or -[discussions](https://github.com/modelcontextprotocol/go-sdk/discussions) for -open-ended discussion. See [CONTRIBUTING.md](/CONTRIBUTING.md) for details. - ## Package documentation The SDK consists of two importable packages: @@ -42,12 +30,53 @@ The SDK consists of two importable packages: - The [`github.com/modelcontextprotocol/go-sdk/jsonrpc`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/jsonrpc) package is for users implementing their own transports. - +- The + [`github.com/modelcontextprotocol/go-sdk/auth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth) + package provides some primitives for supporting oauth. -## Example +## Getting started -In this example, an MCP client communicates with an MCP server running in a -sidecar process: +To get started creating an MCP server, create an `mcp.Server` instance, add +features to it, and then run it over an `mcp.Transport`. For example, this +server adds a single simple tool, and then connects it to clients over +stdin/stdout: + +```go +package main + +import ( + "context" + "log" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type Input struct { + Name string `json:"name" jsonschema:"the name of the person to greet"` +} + +type Output struct { + Greeting string `json:"greeting" jsonschema:"the greeting to tell to the user"` +} + +func SayHi(ctx context.Context, req *mcp.CallToolRequest, input Input) (*mcp.CallToolResult, Output, error) { + return nil, Output{Greeting: "Hi " + input.Name}, nil +} + +func main() { + // Create a server with a single tool. + server := mcp.NewServer(&mcp.Implementation{Name: "greeter", Version: "v1.0.0"}, nil) + mcp.AddTool(server, &mcp.Tool{Name: "greet", Description: "say hi"}, SayHi) + // Run the server over stdin/stdout, until the client disconnects + if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil { + log.Fatal(err) + } +} +``` + +To communicate with that server, we can similarly create an `mcp.Client` and +connect it to the corresponding server, by running the server command and +communicating over its stdin/stdout: ```go package main @@ -92,42 +121,20 @@ func main() { } ``` -Here's an example of the corresponding server component, which communicates -with its client over stdin/stdout: +The [`examples/`](/examples/) directory contains more example clients and +servers. -```go -package main +## Design -import ( - "context" - "log" +The design doc for this SDK is at [design.md](./design/design.md), which was +initially reviewed at +[modelcontextprotocol/discussions/364](https://github.com/orgs/modelcontextprotocol/discussions/364). - "github.com/modelcontextprotocol/go-sdk/mcp" -) - -type HiParams struct { - Name string `json:"name" jsonschema:"the name of the person to greet"` -} - -func SayHi(ctx context.Context, req *mcp.CallToolRequest, args HiParams) (*mcp.CallToolResult, any, error) { - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: "Hi " + args.Name}}, - }, nil, nil -} - -func main() { - // Create a server with a single tool. - server := mcp.NewServer(&mcp.Implementation{Name: "greeter", Version: "v1.0.0"}, nil) - - mcp.AddTool(server, &mcp.Tool{Name: "greet", Description: "say hi"}, SayHi) - // Run the server over stdin/stdout, until the client disconnects - if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil { - log.Fatal(err) - } -} -``` - -The [`examples/`](/examples/) directory contains more example clients and servers. +Further design discussion should occur in +[issues](https://github.com/modelcontextprotocol/go-sdk/issues) (for concrete +proposals) or +[discussions](https://github.com/modelcontextprotocol/go-sdk/discussions) for +open-ended discussion. See [CONTRIBUTING.md](/CONTRIBUTING.md) for details. ## Acknowledgements diff --git a/examples/server/toolschemas/main.go b/examples/server/toolschemas/main.go new file mode 100644 index 0000000..36ee049 --- /dev/null +++ b/examples/server/toolschemas/main.go @@ -0,0 +1,146 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// The toolschemas example demonstrates how to create tools using both the +// low-level [ToolHandler] and high level [ToolHandlerFor], as well as how to +// customize schemas in both cases. +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Input is the input into all the tools handlers below. +type Input struct { + Name string `json:"name" jsonschema:"the person to greet"` +} + +// Output is the structured output of the tool. +// +// Not every tool needs to have structured output. +type Output struct { + Greeting string `json:"greeting" jsonschema:"the greeting to send to the user"` +} + +// simpleGreeting is an [mcp.ToolHandlerFor] that only cares about input and output. +func simpleGreeting(_ context.Context, _ *mcp.CallToolRequest, input Input) (*mcp.CallToolResult, Output, error) { + return nil, Output{"Hi " + input.Name}, nil +} + +// manualGreeter handles the parsing and validation of input and output manually. +// +// Therfore, it needs to close over its resolved schemas, to use them in +// validation. +type manualGreeter struct { + inputSchema *jsonschema.Resolved + outputSchema *jsonschema.Resolved +} + +func (t *manualGreeter) greet(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // errf produces a 'tool error', embedding the error in a CallToolResult. + errf := func(format string, args ...any) *mcp.CallToolResult { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf(format, args...)}}, + IsError: true, + } + } + // Handle the parsing and validation of input and output. + // + // Note that errors here are treated as tool errors, not protocol errors. + var input Input + if err := json.Unmarshal(req.Params.Arguments, &input); err != nil { + return errf("failed to unmarshal arguments: %v", err), nil + } + if err := t.inputSchema.Validate(input); err != nil { + return errf("invalid input: %v", err), nil + } + output := Output{Greeting: "Hi " + input.Name} + if err := t.outputSchema.Validate(output); err != nil { + return errf("tool produced invalid output: %v", err), nil + } + outputJSON, err := json.Marshal(output) + if err != nil { + return errf("output failed to marshal: %v", err), nil + } + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: string(outputJSON)}}, + StructuredContent: output, + }, nil +} + +func main() { + server := mcp.NewServer(&mcp.Implementation{Name: "greeter"}, nil) + + // Add the 'greeting' tool in a few different ways. + + // First, we can just use [mcp.AddTool], and get the out-of-the-box handling + // it provides: + mcp.AddTool(server, &mcp.Tool{Name: "simple greeting"}, simpleGreeting) + + // Next, we can create our schemas entirely manually, and add them using + // [mcp.Server.AddTool]. Since we're working manually, we can add some + // constraints on the length of the name. + // + // We don't need to do all this work: below, we use jsonschema.For to start + // from the default schema. + var ( + manual manualGreeter + err error + ) + inputSchema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": {Type: "string", MaxLength: jsonschema.Ptr(10)}, + }, + } + manual.inputSchema, err = inputSchema.Resolve(nil) + if err != nil { + log.Fatal(err) + } + outputSchema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "greeting": {Type: "string"}, + }, + } + manual.outputSchema, err = outputSchema.Resolve(nil) + if err != nil { + log.Fatal(err) + } + server.AddTool(&mcp.Tool{ + Name: "manual greeting", + InputSchema: inputSchema, + OutputSchema: outputSchema, + }, manual.greet) + + // Finally, note that we can also use custom schemas with a ToolHandlerFor. + // We can do this in two ways: by using one of the schema values constructed + // above, or by using jsonschema.For and adjusting the resulting schema. + mcp.AddTool(server, &mcp.Tool{ + Name: "customized greeting 1", + InputSchema: inputSchema, + // OutputSchema will still be derived from Output. + }, simpleGreeting) + + customSchema, err := jsonschema.For[Input](nil) + if err != nil { + log.Fatal(err) + } + customSchema.Properties["name"].MaxLength = jsonschema.Ptr(10) + mcp.AddTool(server, &mcp.Tool{ + Name: "customized greeting 2", + InputSchema: customSchema, + }, simpleGreeting) + + // Now run the server. + if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil { + log.Printf("Server failed: %v", err) + } +} diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md index 40e0aa1..d128c93 100644 --- a/internal/readme/README.src.md +++ b/internal/readme/README.src.md @@ -18,18 +18,6 @@ software development kit (SDK) for the Model Context Protocol (MCP). > changes. We aim to tag v1.0.0 in September, 2025. See > https://github.com/modelcontextprotocol/go-sdk/issues/328 for details. -## Design - -The design doc for this SDK is at [design.md](./design/design.md), which was -initially reviewed at -[modelcontextprotocol/discussions/364](https://github.com/orgs/modelcontextprotocol/discussions/364). - -Further design discussion should occur in -[issues](https://github.com/modelcontextprotocol/go-sdk/issues) (for concrete -proposals) or -[discussions](https://github.com/modelcontextprotocol/go-sdk/discussions) for -open-ended discussion. See [CONTRIBUTING.md](/CONTRIBUTING.md) for details. - ## Package documentation The SDK consists of two importable packages: @@ -41,21 +29,39 @@ The SDK consists of two importable packages: - The [`github.com/modelcontextprotocol/go-sdk/jsonrpc`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/jsonrpc) package is for users implementing their own transports. - +- The + [`github.com/modelcontextprotocol/go-sdk/auth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth) + package provides some primitives for supporting oauth. -## Example +## Getting started -In this example, an MCP client communicates with an MCP server running in a -sidecar process: - -%include client/client.go - - -Here's an example of the corresponding server component, which communicates -with its client over stdin/stdout: +To get started creating an MCP server, create an `mcp.Server` instance, add +features to it, and then run it over an `mcp.Transport`. For example, this +server adds a single simple tool, and then connects it to clients over +stdin/stdout: %include server/server.go - -The [`examples/`](/examples/) directory contains more example clients and servers. +To communicate with that server, we can similarly create an `mcp.Client` and +connect it to the corresponding server, by running the server command and +communicating over its stdin/stdout: + +%include client/client.go - + +The [`examples/`](/examples/) directory contains more example clients and +servers. + +## Design + +The design doc for this SDK is at [design.md](./design/design.md), which was +initially reviewed at +[modelcontextprotocol/discussions/364](https://github.com/orgs/modelcontextprotocol/discussions/364). + +Further design discussion should occur in +[issues](https://github.com/modelcontextprotocol/go-sdk/issues) (for concrete +proposals) or +[discussions](https://github.com/modelcontextprotocol/go-sdk/discussions) for +open-ended discussion. See [CONTRIBUTING.md](/CONTRIBUTING.md) for details. ## Acknowledgements diff --git a/internal/readme/server/server.go b/internal/readme/server/server.go index aff5fcd..e999602 100644 --- a/internal/readme/server/server.go +++ b/internal/readme/server/server.go @@ -12,20 +12,21 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) -type HiParams struct { +type Input struct { Name string `json:"name" jsonschema:"the name of the person to greet"` } -func SayHi(ctx context.Context, req *mcp.CallToolRequest, args HiParams) (*mcp.CallToolResult, any, error) { - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: "Hi " + args.Name}}, - }, nil, nil +type Output struct { + Greeting string `json:"greeting" jsonschema:"the greeting to tell to the user"` +} + +func SayHi(ctx context.Context, req *mcp.CallToolRequest, input Input) (*mcp.CallToolResult, Output, error) { + return nil, Output{Greeting: "Hi " + input.Name}, nil } func main() { // Create a server with a single tool. server := mcp.NewServer(&mcp.Implementation{Name: "greeter", Version: "v1.0.0"}, nil) - mcp.AddTool(server, &mcp.Tool{Name: "greet", Description: "say hi"}, SayHi) // Run the server over stdin/stdout, until the client disconnects if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil { diff --git a/mcp/protocol.go b/mcp/protocol.go index 2786065..a8e4817 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -49,7 +49,9 @@ type CallToolParams struct { Arguments any `json:"arguments,omitempty"` } -// CallToolParamsRaw is passed to tool handlers on the server. +// CallToolParamsRaw is passed to tool handlers on the server. Its arguments +// are not yet unmarshaled (hence "raw"), so that the handlers can perform +// unmarshaling themselves. type CallToolParamsRaw struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. @@ -58,29 +60,48 @@ type CallToolParamsRaw struct { Arguments json.RawMessage `json:"arguments,omitempty"` } -// The server's response to a tool call. +// A CallToolResult is the server's response to a tool call. +// +// The [ToolHandler] and [ToolHandlerFor] handler functions return this result, +// though [ToolHandlerFor] populates much of it automatically as documented at +// each field. type CallToolResult struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` + // A list of content objects that represent the unstructured result of the tool // call. + // + // When using a [ToolHandlerFor] with structured output, if Content is unset + // it will be populated with JSON text content corresponding to the + // structured output value. Content []Content `json:"content"` - // An optional JSON object that represents the structured result of the tool - // call. + + // StructuredContent is an optional value that represents the structured + // result of the tool call. It must marshal to a JSON object. + // + // When using a [ToolHandlerFor] with structured output, you should not + // populate this field. It will be automatically populated with the typed Out + // value. StructuredContent any `json:"structuredContent,omitempty"` - // Whether the tool call ended in an error. + + // IsError reports whether the tool call ended in an error. // // If not set, this is assumed to be false (the call was successful). // - // Any errors that originate from the tool should be reported inside the result - // object, with isError set to true, not as an MCP protocol-level error - // response. Otherwise, the LLM would not be able to see that an error occurred - // and self-correct. + // Any errors that originate from the tool should be reported inside the + // Content field, with IsError set to true, not as an MCP protocol-level + // error response. Otherwise, the LLM would not be able to see that an error + // occurred and self-correct. // // However, any errors in finding the tool, an error indicating that the // server does not support tool calls, or any other exceptional conditions, // should be reported as an MCP error response. + // + // When using a [ToolHandlerFor], this field is automatically set when the + // tool handler returns an error, and the error string is included as text in + // the Content field. IsError bool `json:"isError,omitempty"` } diff --git a/mcp/server.go b/mcp/server.go index df97b58..466c445 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -321,22 +321,18 @@ func setSchema[T any](sfield **jsonschema.Schema, rfield **jsonschema.Resolved) } // AddTool adds a tool and typed tool handler to the server. +// // If the tool's input schema is nil, it is set to the schema inferred from the -// In type parameter, using [jsonschema.For]. The In type parameter must be a +// In type parameter, using [jsonschema.For]. The In type argument must be a // map or a struct, so that its inferred JSON Schema has type "object". // -// For tools that don't return structured output, Out should be 'any'. -// Otherwise, if the tool's output schema is nil the output schema is set to -// the schema inferred from Out, which must be a map or a struct. +// If the tool's output schema is nil, and the Out type is not 'any', the +// output schema is set to the schema inferred from the Out type argument, +// which also must be a map or struct. // -// The In argument to the handler will contain the unmarshaled arguments from -// CallToolRequest.Params.Arguments. Most users can ignore the [CallToolRequest] -// argument to the handler. -// -// The handler's Out return value will be used to populate [CallToolResult.StructuredContent]. -// If the handler returns a non-nil error, [CallToolResult.IsError] will be set to true, -// and [CallToolResult.Content] will be set to the text of the error. -// Most users can ignore the [CallToolResult] return value. +// Unlike [Server.AddTool], AddTool does a lot automatically, and forces tools +// to conform to the MCP spec. See [ToolHandlerFor] for a detailed description +// of this automatic behavior. func AddTool[In, Out any](s *Server, t *Tool, h ToolHandlerFor[In, Out]) { tt, hh, err := toolForErr(t, h) if err != nil { diff --git a/mcp/tool.go b/mcp/tool.go index 0797700..ffccbf3 100644 --- a/mcp/tool.go +++ b/mcp/tool.go @@ -14,14 +14,46 @@ import ( ) // A ToolHandler handles a call to tools/call. -// This is a low-level API, for use with [Server.AddTool]. -// Most users will write a [ToolHandlerFor] and install it with [AddTool]. +// +// This is a low-level API, for use with [Server.AddTool]. It does not do any +// pre- or post-processing of the request or result: the params contain raw +// arguments, no input validation is performed, and the result is returned to +// the user as-is, without any validation of the output. +// +// Most users will write a [ToolHandlerFor] and install it with the generic +// [AddTool] function. +// +// If ToolHandler returns an error, it is treated as a protocol error. By +// contrast, [ToolHandlerFor] automatically populates [CallToolResult.IsError] +// and [CallToolResult.Content] accordingly. type ToolHandler func(context.Context, *CallToolRequest) (*CallToolResult, error) // A ToolHandlerFor handles a call to tools/call with typed arguments and results. +// // Use [AddTool] to add a ToolHandlerFor to a server. -// Most users can ignore the [CallToolRequest] argument and [CallToolResult] return value. -type ToolHandlerFor[In, Out any] func(context.Context, *CallToolRequest, In) (*CallToolResult, Out, error) +// +// Unlike [ToolHandler], [ToolHandlerFor] provides significant functionality +// out of the box, and enforces that the tool conforms to the MCP spec: +// - The In type provides a default input schema for the tool, though it may +// be overridden in [AddTool]. +// - The input value is automatically unmarshaled from req.Params.Arguments. +// - The input value is automatically validated against its input schema. +// Invalid input is rejected before getting to the handler. +// - If the Out type is not the empty interface [any], it provides the +// default output schema for the tool (which again may be overridden in +// [AddTool]). +// - The Out value is used to populate result.StructuredOutput. +// - If [CallToolResult.Content] is unset, it is populated with the JSON +// content of the output. +// - An error result is treated as a tool error, rather than a protocol +// error, and is therefore packed into CallToolResult.Content, with +// [IsError] set. +// +// For these reasons, most users can ignore the [CallToolRequest] argument and +// [CallToolResult] return values entirely. In fact, it is permissible to +// return a nil CallToolResult, if you only care about returning a output value +// or error. The effective result will be populated as described above. +type ToolHandlerFor[In, Out any] func(_ context.Context, request *CallToolRequest, input In) (result *CallToolResult, output Out, _ error) // A serverTool is a tool definition that is bound to a tool handler. type serverTool struct { From 2a00640e1e4a6e20a1e116216227800380d73dfa Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Fri, 5 Sep 2025 14:17:21 -0400 Subject: [PATCH 029/418] mcp/internal/oauthex: auth server metadata (#294) Implement the Authorization Server Metadata spec. --- internal/oauthex/auth_meta.go | 145 ++++++++++++++++++ internal/oauthex/auth_meta_test.go | 28 ++++ internal/oauthex/oauth2.go | 61 ++++++++ internal/oauthex/resource_meta.go | 28 +--- .../oauthex/testdata/google-auth-meta.json | 57 +++++++ 5 files changed, 293 insertions(+), 26 deletions(-) create mode 100644 internal/oauthex/auth_meta.go create mode 100644 internal/oauthex/auth_meta_test.go create mode 100644 internal/oauthex/testdata/google-auth-meta.json diff --git a/internal/oauthex/auth_meta.go b/internal/oauthex/auth_meta.go new file mode 100644 index 0000000..1d6deb9 --- /dev/null +++ b/internal/oauthex/auth_meta.go @@ -0,0 +1,145 @@ +// Copyright 2025 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This file implements Authorization Server Metadata. +// See https://www.rfc-editor.org/rfc/rfc8414.html. + +package oauthex + +import ( + "context" + "errors" + "fmt" + "net/http" +) + +// AuthServerMeta represents the metadata for an OAuth 2.0 authorization server, +// as defined in [RFC 8414]. +// +// Not supported: +// - signed metadata +// +// [RFC 8414]: https://tools.ietf.org/html/rfc8414) +type AuthServerMeta struct { + // GENERATED BY GEMINI 2.5. + + // Issuer is the REQUIRED URL identifying the authorization server. + Issuer string `json:"issuer"` + + // AuthorizationEndpoint is the REQUIRED URL of the server's OAuth 2.0 authorization endpoint. + AuthorizationEndpoint string `json:"authorization_endpoint"` + + // TokenEndpoint is the REQUIRED URL of the server's OAuth 2.0 token endpoint. + TokenEndpoint string `json:"token_endpoint"` + + // JWKSURI is the REQUIRED URL of the server's JSON Web Key Set [JWK] document. + JWKSURI string `json:"jwks_uri"` + + // RegistrationEndpoint is the RECOMMENDED URL of the server's OAuth 2.0 Dynamic Client Registration endpoint. + RegistrationEndpoint string `json:"registration_endpoint,omitempty"` + + // ScopesSupported is a RECOMMENDED JSON array of strings containing a list of the OAuth 2.0 + // "scope" values that this server supports. + ScopesSupported []string `json:"scopes_supported,omitempty"` + + // ResponseTypesSupported is a REQUIRED JSON array of strings containing a list of the OAuth 2.0 + // "response_type" values that this server supports. + ResponseTypesSupported []string `json:"response_types_supported"` + + // ResponseModesSupported is a RECOMMENDED JSON array of strings containing a list of the OAuth 2.0 + // "response_mode" values that this server supports. + ResponseModesSupported []string `json:"response_modes_supported,omitempty"` + + // GrantTypesSupported is a RECOMMENDED JSON array of strings containing a list of the OAuth 2.0 + // grant type values that this server supports. + GrantTypesSupported []string `json:"grant_types_supported,omitempty"` + + // TokenEndpointAuthMethodsSupported is a RECOMMENDED JSON array of strings containing a list of + // client authentication methods supported by this token endpoint. + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"` + + // TokenEndpointAuthSigningAlgValuesSupported is a RECOMMENDED JSON array of strings containing + // a list of the JWS signing algorithms ("alg" values) supported by the token endpoint for + // the signature on the JWT used to authenticate the client. + TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported,omitempty"` + + // ServiceDocumentation is a RECOMMENDED URL of a page containing human-readable documentation + // for the service. + ServiceDocumentation string `json:"service_documentation,omitempty"` + + // UILocalesSupported is a RECOMMENDED JSON array of strings representing supported + // BCP47 [RFC5646] language tag values for display in the user interface. + UILocalesSupported []string `json:"ui_locales_supported,omitempty"` + + // OpPolicyURI is a RECOMMENDED URL that the server provides to the person registering + // the client to read about the server's operator policies. + OpPolicyURI string `json:"op_policy_uri,omitempty"` + + // OpTOSURI is a RECOMMENDED URL that the server provides to the person registering the + // client to read about the server's terms of service. + OpTOSURI string `json:"op_tos_uri,omitempty"` + + // RevocationEndpoint is a RECOMMENDED URL of the server's OAuth 2.0 revocation endpoint. + RevocationEndpoint string `json:"revocation_endpoint,omitempty"` + + // RevocationEndpointAuthMethodsSupported is a RECOMMENDED JSON array of strings containing + // a list of client authentication methods supported by this revocation endpoint. + RevocationEndpointAuthMethodsSupported []string `json:"revocation_endpoint_auth_methods_supported,omitempty"` + + // RevocationEndpointAuthSigningAlgValuesSupported is a RECOMMENDED JSON array of strings + // containing a list of the JWS signing algorithms ("alg" values) supported by the revocation + // endpoint for the signature on the JWT used to authenticate the client. + RevocationEndpointAuthSigningAlgValuesSupported []string `json:"revocation_endpoint_auth_signing_alg_values_supported,omitempty"` + + // IntrospectionEndpoint is a RECOMMENDED URL of the server's OAuth 2.0 introspection endpoint. + IntrospectionEndpoint string `json:"introspection_endpoint,omitempty"` + + // IntrospectionEndpointAuthMethodsSupported is a RECOMMENDED JSON array of strings containing + // a list of client authentication methods supported by this introspection endpoint. + IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported,omitempty"` + + // IntrospectionEndpointAuthSigningAlgValuesSupported is a RECOMMENDED JSON array of strings + // containing a list of the JWS signing algorithms ("alg" values) supported by the introspection + // endpoint for the signature on the JWT used to authenticate the client. + IntrospectionEndpointAuthSigningAlgValuesSupported []string `json:"introspection_endpoint_auth_signing_alg_values_supported,omitempty"` + + // CodeChallengeMethodsSupported is a RECOMMENDED JSON array of strings containing a list of + // PKCE code challenge methods supported by this authorization server. + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"` +} + +var wellKnownPaths = []string{ + "/.well-known/oauth-authorization-server", + "/.well-known/openid-configuration", +} + +// GetAuthServerMeta issues a GET request to retrieve authorization server metadata +// from an OAuth authorization server with the given issuerURL. +// +// It follows [RFC 8414]: +// - The well-known paths specified there are inserted into the URL's path, one at time. +// The first to succeed is used. +// - The Issuer field is checked against issuerURL. +// +// [RFC 8414]: https://tools.ietf.org/html/rfc8414 +func GetAuthServerMeta(ctx context.Context, issuerURL string, c *http.Client) (*AuthServerMeta, error) { + var errs []error + for _, p := range wellKnownPaths { + u, err := prependToPath(issuerURL, p) + if err != nil { + // issuerURL is bad; no point in continuing. + return nil, err + } + asm, err := getJSON[AuthServerMeta](ctx, c, u, 1<<20) + if err == nil { + if asm.Issuer != issuerURL { // section 3.3 + // Security violation; don't keep trying. + return nil, fmt.Errorf("metadata issuer %q does not match issuer URL %q", asm.Issuer, issuerURL) + } + return asm, nil + } + errs = append(errs, err) + } + return nil, fmt.Errorf("failed to get auth server metadata from %q: %w", issuerURL, errors.Join(errs...)) +} diff --git a/internal/oauthex/auth_meta_test.go b/internal/oauthex/auth_meta_test.go new file mode 100644 index 0000000..724c78f --- /dev/null +++ b/internal/oauthex/auth_meta_test.go @@ -0,0 +1,28 @@ +// Copyright 2025 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package oauthex + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestAuthMetaParse(t *testing.T) { + // Verify that we parse Google's auth server metadata. + data, err := os.ReadFile(filepath.FromSlash("testdata/google-auth-meta.json")) + if err != nil { + t.Fatal(err) + } + var a AuthServerMeta + if err := json.Unmarshal(data, &a); err != nil { + t.Fatal(err) + } + // Spot check. + if g, w := a.Issuer, "https://accounts.google.com"; g != w { + t.Errorf("got %q, want %q", g, w) + } +} diff --git a/internal/oauthex/oauth2.go b/internal/oauthex/oauth2.go index d1166fe..cf06ab3 100644 --- a/internal/oauthex/oauth2.go +++ b/internal/oauthex/oauth2.go @@ -4,3 +4,64 @@ // Package oauthex implements extensions to OAuth2. package oauthex + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// prependToPath prepends pre to the path of urlStr. +// When pre is the well-known path, this is the algorithm specified in both RFC 9728 +// section 3.1 and RFC 8414 section 3.1. +func prependToPath(urlStr, pre string) (string, error) { + u, err := url.Parse(urlStr) + if err != nil { + return "", err + } + p := "/" + strings.Trim(pre, "/") + if u.Path != "" { + p += "/" + } + + u.Path = p + strings.TrimLeft(u.Path, "/") + return u.String(), nil +} + +// getJSON retrieves JSON and unmarshals JSON from the URL, as specified in both +// RFC 9728 and RFC 8414. +// It will not read more than limit bytes from the body. +func getJSON[T any](ctx context.Context, c *http.Client, url string, limit int64) (*T, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + if c == nil { + c = http.DefaultClient + } + res, err := c.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + // Specs require a 200. + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("bad status %s", res.Status) + } + // Specs require application/json. + if ct := res.Header.Get("Content-Type"); ct != "application/json" { + return nil, fmt.Errorf("bad content type %q", ct) + } + + var t T + dec := json.NewDecoder(io.LimitReader(res.Body, limit)) + if err := dec.Decode(&t); err != nil { + return nil, err + } + return &t, nil +} diff --git a/internal/oauthex/resource_meta.go b/internal/oauthex/resource_meta.go index eb981d2..3818d86 100644 --- a/internal/oauthex/resource_meta.go +++ b/internal/oauthex/resource_meta.go @@ -9,7 +9,6 @@ package oauthex import ( "context" - "encoding/json" "errors" "fmt" "net/http" @@ -164,38 +163,15 @@ func getPRM(ctx context.Context, url string, c *http.Client, wantResource string if !strings.HasPrefix(strings.ToUpper(url), "HTTPS://") { return nil, fmt.Errorf("resource URL %q does not use HTTPS", url) } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + prm, err := getJSON[ProtectedResourceMetadata](ctx, c, url, 1<<20) if err != nil { return nil, err } - if c == nil { - c = http.DefaultClient - } - res, err := c.Do(req) - if err != nil { - return nil, err - } - defer res.Body.Close() - - // Spec §3.2 requires a 200. - if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("bad status %s", res.Status) - } - // Spec §3.2 requires application/json. - if ct := res.Header.Get("Content-Type"); ct != "application/json" { - return nil, fmt.Errorf("bad content type %q", ct) - } - - var prm ProtectedResourceMetadata - dec := json.NewDecoder(res.Body) - if err := dec.Decode(&prm); err != nil { - return nil, err - } // Validate the Resource field to thwart impersonation attacks (section 3.3). if prm.Resource != wantResource { return nil, fmt.Errorf("got metadata resource %q, want %q", prm.Resource, wantResource) } - return &prm, nil + return prm, nil } // challenge represents a single authentication challenge from a WWW-Authenticate header. diff --git a/internal/oauthex/testdata/google-auth-meta.json b/internal/oauthex/testdata/google-auth-meta.json new file mode 100644 index 0000000..258b753 --- /dev/null +++ b/internal/oauthex/testdata/google-auth-meta.json @@ -0,0 +1,57 @@ +{ + "issuer": "https://accounts.google.com", + "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth", + "device_authorization_endpoint": "https://oauth2.googleapis.com/device/code", + "token_endpoint": "https://oauth2.googleapis.com/token", + "userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo", + "revocation_endpoint": "https://oauth2.googleapis.com/revoke", + "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs", + "response_types_supported": [ + "code", + "token", + "id_token", + "code token", + "code id_token", + "token id_token", + "code token id_token", + "none" + ], + "subject_types_supported": [ + "public" + ], + "id_token_signing_alg_values_supported": [ + "RS256" + ], + "scopes_supported": [ + "openid", + "email", + "profile" + ], + "token_endpoint_auth_methods_supported": [ + "client_secret_post", + "client_secret_basic" + ], + "claims_supported": [ + "aud", + "email", + "email_verified", + "exp", + "family_name", + "given_name", + "iat", + "iss", + "name", + "picture", + "sub" + ], + "code_challenge_methods_supported": [ + "plain", + "S256" + ], + "grant_types_supported": [ + "authorization_code", + "refresh_token", + "urn:ietf:params:oauth:grant-type:device_code", + "urn:ietf:params:oauth:grant-type:jwt-bearer" + ] +} From 46e767e646ad0c197bab8d7240aa7e1e03e87f4e Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Fri, 5 Sep 2025 16:30:59 -0400 Subject: [PATCH 030/418] Update README for v0.4.0 (#422) As we prepare to release v0.4.0, update the README to reference the new version. --- README.md | 4 ++-- internal/readme/README.src.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 606f579..0488525 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -# MCP Go SDK v0.3.0 +# MCP Go SDK v0.4.0 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/modelcontextprotocol/go-sdk) @@ -7,7 +7,7 @@ This version contains breaking changes. See the [release notes]( -https://github.com/modelcontextprotocol/go-sdk/releases/tag/v0.3.0) for details. +https://github.com/modelcontextprotocol/go-sdk/releases/tag/v0.4.0) for details. [![PkgGoDev](https://pkg.go.dev/badge/github.com/modelcontextprotocol/go-sdk)](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md index d128c93..1de4eba 100644 --- a/internal/readme/README.src.md +++ b/internal/readme/README.src.md @@ -1,4 +1,4 @@ -# MCP Go SDK v0.3.0 +# MCP Go SDK v0.4.0 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/modelcontextprotocol/go-sdk) @@ -6,7 +6,7 @@ This version contains breaking changes. See the [release notes]( -https://github.com/modelcontextprotocol/go-sdk/releases/tag/v0.3.0) for details. +https://github.com/modelcontextprotocol/go-sdk/releases/tag/v0.4.0) for details. [![PkgGoDev](https://pkg.go.dev/badge/github.com/modelcontextprotocol/go-sdk)](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) From 4656930de8ce967845da3e16c6afe31b260d66a4 Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Fri, 5 Sep 2025 20:48:32 +0000 Subject: [PATCH 031/418] mcp: avoid "null" when tools fail to return content For some reason, this was a regression from v0.3.1, for some of our examples. Fix it and address the existing TODO. --- mcp/server.go | 10 +++++++--- mcp/streamable_test.go | 12 ++++++------ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index 466c445..af9c2ab 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -496,9 +496,13 @@ func (s *Server) callTool(ctx context.Context, req *CallToolRequest) (*CallToolR Message: fmt.Sprintf("unknown tool %q", req.Params.Name), } } - // TODO: if handler returns nil content, it will serialize as null. - // Add a test and fix. - return st.handler(ctx, req) + res, err := st.handler(ctx, req) + if err == nil && res != nil && res.Content == nil { + res2 := *res + res2.Content = []Content{} // avoid "null" + res = &res2 + } + return res, err } func (s *Server) listResources(_ context.Context, req *ListResourcesRequest) (*ListResourcesResult, error) { diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 1e7a63c..a85fbec 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -520,7 +520,7 @@ func TestStreamableServerTransport(t *testing.T) { method: "POST", messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusOK, - wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{}, nil)}, + wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)}, }, }, }, @@ -547,14 +547,14 @@ func TestStreamableServerTransport(t *testing.T) { headers: http.Header{"Accept": {"text/plain", "*/*"}}, messages: []jsonrpc.Message{req(4, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusOK, - wantMessages: []jsonrpc.Message{resp(4, &CallToolResult{}, nil)}, + wantMessages: []jsonrpc.Message{resp(4, &CallToolResult{Content: []Content{}}, nil)}, }, { method: "POST", headers: http.Header{"Accept": {"text/*, application/*"}}, messages: []jsonrpc.Message{req(4, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusOK, - wantMessages: []jsonrpc.Message{resp(4, &CallToolResult{}, nil)}, + wantMessages: []jsonrpc.Message{resp(4, &CallToolResult{Content: []Content{}}, nil)}, }, }, }, @@ -592,7 +592,7 @@ func TestStreamableServerTransport(t *testing.T) { wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{ req(0, "notifications/progress", &ProgressNotificationParams{}), - resp(2, &CallToolResult{}, nil), + resp(2, &CallToolResult{Content: []Content{}}, nil), }, }, }, @@ -624,7 +624,7 @@ func TestStreamableServerTransport(t *testing.T) { wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{ req(1, "roots/list", &ListRootsParams{}), - resp(2, &CallToolResult{}, nil), + resp(2, &CallToolResult{Content: []Content{}}, nil), }, }, }, @@ -674,7 +674,7 @@ func TestStreamableServerTransport(t *testing.T) { }, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{ - resp(2, &CallToolResult{}, nil), + resp(2, &CallToolResult{Content: []Content{}}, nil), }, }, { From e06cc69dc42064c823e145c827a226da712922ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Flc=E3=82=9B?= Date: Mon, 8 Sep 2025 14:11:31 +0800 Subject: [PATCH 032/418] fix(docs): correct number in README (#427) --- README.md | 2 +- internal/readme/README.src.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0488525..d8bad49 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ software development kit (SDK) for the Model Context Protocol (MCP). ## Package documentation -The SDK consists of two importable packages: +The SDK consists of three importable packages: - The [`github.com/modelcontextprotocol/go-sdk/mcp`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp) diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md index 1de4eba..5ac7cb8 100644 --- a/internal/readme/README.src.md +++ b/internal/readme/README.src.md @@ -20,7 +20,7 @@ software development kit (SDK) for the Model Context Protocol (MCP). ## Package documentation -The SDK consists of two importable packages: +The SDK consists of three importable packages: - The [`github.com/modelcontextprotocol/go-sdk/mcp`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp) From bae3558d49b590d56e21c4987ef8154afc1fc755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20RUFFINONI?= Date: Mon, 8 Sep 2025 19:28:32 +0200 Subject: [PATCH 033/418] mcp: allow configurable terminate duration for CommandTransport (#363) Make the process termination timeout configurable instead of using a hardcoded 5-second delay. This allows applications to customize the termination behavior based on their specific needs. Fixes #322. --- mcp/cmd.go | 23 +++++++++++++---- mcp/cmd_test.go | 68 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/mcp/cmd.go b/mcp/cmd.go index 5ec8c9e..01195d2 100644 --- a/mcp/cmd.go +++ b/mcp/cmd.go @@ -13,10 +13,18 @@ import ( "time" ) +const ( + defaultTerminateDuration = 5 * time.Second +) + // A CommandTransport is a [Transport] that runs a command and communicates // with it over stdin/stdout, using newline-delimited JSON. type CommandTransport struct { Command *exec.Cmd + // TerminateDuration controls how long Close waits after closing stdin + // for the process to exit before sending SIGTERM. + // If zero or negative, the default of 5s is used. + TerminateDuration time.Duration } // NewCommandTransport returns a [CommandTransport] that runs the given command @@ -46,15 +54,20 @@ func (t *CommandTransport) Connect(ctx context.Context) (Connection, error) { if err := t.Command.Start(); err != nil { return nil, err } - return newIOConn(&pipeRWC{t.Command, stdout, stdin}), nil + td := t.TerminateDuration + if td <= 0 { + td = defaultTerminateDuration + } + return newIOConn(&pipeRWC{t.Command, stdout, stdin, td}), nil } // A pipeRWC is an io.ReadWriteCloser that communicates with a subprocess over // stdin/stdout pipes. type pipeRWC struct { - cmd *exec.Cmd - stdout io.ReadCloser - stdin io.WriteCloser + cmd *exec.Cmd + stdout io.ReadCloser + stdin io.WriteCloser + terminateDuration time.Duration } func (s *pipeRWC) Read(p []byte) (n int, err error) { @@ -85,7 +98,7 @@ func (s *pipeRWC) Close() error { select { case err := <-resChan: return err, true - case <-time.After(5 * time.Second): + case <-time.After(s.terminateDuration): } return nil, false } diff --git a/mcp/cmd_test.go b/mcp/cmd_test.go index 98354a9..146cbe1 100644 --- a/mcp/cmd_test.go +++ b/mcp/cmd_test.go @@ -251,6 +251,74 @@ func createServerCommand(t *testing.T, serverName string) *exec.Cmd { return cmd } +func TestCommandTransportTerminateDuration(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires POSIX signals") + } + requireExec(t) + + tests := []struct { + name string + duration time.Duration + wantMaxDuration time.Duration + }{ + { + name: "default duration (zero)", + duration: 0, + wantMaxDuration: 6 * time.Second, // default 5s + buffer + }, + { + name: "below minimum duration", + duration: 500 * time.Millisecond, + wantMaxDuration: 6 * time.Second, // should use default 5s + buffer + }, + { + name: "custom valid duration", + duration: 2 * time.Second, + wantMaxDuration: 3 * time.Second, // custom 2s + buffer + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Use a command that won't exit when stdin is closed + cmd := exec.Command("sleep", "20") + transport := &mcp.CommandTransport{ + Command: cmd, + TerminateDuration: tt.duration, + } + + conn, err := transport.Connect(ctx) + if err != nil { + t.Fatal(err) + } + + start := time.Now() + err = conn.Close() + elapsed := time.Since(start) + + if err != nil { + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("Close() failed with unexpected error: %v", err) + } + } + + if elapsed > tt.wantMaxDuration { + t.Errorf("Close() took %v, expected at most %v", elapsed, tt.wantMaxDuration) + } + + // Ensure the process was actually terminated + if cmd.Process != nil { + cmd.Process.Kill() + } + }) + } +} + func requireExec(t *testing.T) { t.Helper() From 4f197bc3b401a5c34e3fc25242b79ff893a9ee47 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Mon, 8 Sep 2025 13:36:50 -0400 Subject: [PATCH 034/418] internal/oauthex: fix license headers (#430) The files in the oauthex package used the default Go license header, rather than the MCP SDK license header (an accident of tooling). We should fix this while jba@google.com is the only author of this code. A subsequent PR will add a lint check to prevent this in the future. --- internal/oauthex/auth_meta.go | 4 ++-- internal/oauthex/auth_meta_test.go | 4 ++-- internal/oauthex/oauth2.go | 4 ++-- internal/oauthex/oauth2_test.go | 4 ++-- internal/oauthex/resource_meta.go | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/oauthex/auth_meta.go b/internal/oauthex/auth_meta.go index 1d6deb9..1f075f8 100644 --- a/internal/oauthex/auth_meta.go +++ b/internal/oauthex/auth_meta.go @@ -1,5 +1,5 @@ -// Copyright 2025 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. // This file implements Authorization Server Metadata. diff --git a/internal/oauthex/auth_meta_test.go b/internal/oauthex/auth_meta_test.go index 724c78f..b83402f 100644 --- a/internal/oauthex/auth_meta_test.go +++ b/internal/oauthex/auth_meta_test.go @@ -1,5 +1,5 @@ -// Copyright 2025 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. package oauthex diff --git a/internal/oauthex/oauth2.go b/internal/oauthex/oauth2.go index cf06ab3..de16449 100644 --- a/internal/oauthex/oauth2.go +++ b/internal/oauthex/oauth2.go @@ -1,5 +1,5 @@ -// Copyright 2025 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. // Package oauthex implements extensions to OAuth2. diff --git a/internal/oauthex/oauth2_test.go b/internal/oauthex/oauth2_test.go index 92017f8..9c3da15 100644 --- a/internal/oauthex/oauth2_test.go +++ b/internal/oauthex/oauth2_test.go @@ -1,5 +1,5 @@ -// Copyright 2025 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. package oauthex diff --git a/internal/oauthex/resource_meta.go b/internal/oauthex/resource_meta.go index 3818d86..71d52cd 100644 --- a/internal/oauthex/resource_meta.go +++ b/internal/oauthex/resource_meta.go @@ -1,5 +1,5 @@ -// Copyright 2025 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. // This file implements Protected Resource Metadata. From a082e0764db5268c3a443cc69f4a5f0bff7a1dcc Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Tue, 9 Sep 2025 10:34:38 -0400 Subject: [PATCH 035/418] go.mod: update to jsonschema v0.2.1 --- examples/server/auth-middleware/go.mod | 2 +- examples/server/auth-middleware/go.sum | 4 ++-- examples/server/rate-limiting/go.mod | 2 +- examples/server/rate-limiting/go.sum | 4 ++-- go.mod | 2 +- go.sum | 6 ++---- 6 files changed, 9 insertions(+), 11 deletions(-) diff --git a/examples/server/auth-middleware/go.mod b/examples/server/auth-middleware/go.mod index 402c0aa..f1b65d2 100644 --- a/examples/server/auth-middleware/go.mod +++ b/examples/server/auth-middleware/go.mod @@ -8,7 +8,7 @@ require ( ) require ( - github.com/google/jsonschema-go v0.2.1-0.20250825175020-748c325cec76 // indirect + github.com/google/jsonschema-go v0.2.1 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect ) diff --git a/examples/server/auth-middleware/go.sum b/examples/server/auth-middleware/go.sum index eea5bdb..ada94c0 100644 --- a/examples/server/auth-middleware/go.sum +++ b/examples/server/auth-middleware/go.sum @@ -2,8 +2,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeD github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/jsonschema-go v0.2.1-0.20250825175020-748c325cec76 h1:mBlBwtDebdDYr+zdop8N62a44g+Nbv7o2KjWyS1deR4= -github.com/google/jsonschema-go v0.2.1-0.20250825175020-748c325cec76/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.2.1 h1:Z3iINWAUmvS4+m9cMP5lWbn6WlX8Hy4rpUS4pULVliQ= +github.com/google/jsonschema-go v0.2.1/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= diff --git a/examples/server/rate-limiting/go.mod b/examples/server/rate-limiting/go.mod index 2b4b00d..8dd2f80 100644 --- a/examples/server/rate-limiting/go.mod +++ b/examples/server/rate-limiting/go.mod @@ -8,7 +8,7 @@ require ( ) require ( - github.com/google/jsonschema-go v0.2.1-0.20250825175020-748c325cec76 // indirect + github.com/google/jsonschema-go v0.2.1 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect ) diff --git a/examples/server/rate-limiting/go.sum b/examples/server/rate-limiting/go.sum index 16b70ef..9e6a390 100644 --- a/examples/server/rate-limiting/go.sum +++ b/examples/server/rate-limiting/go.sum @@ -1,7 +1,7 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/jsonschema-go v0.2.1-0.20250825175020-748c325cec76 h1:mBlBwtDebdDYr+zdop8N62a44g+Nbv7o2KjWyS1deR4= -github.com/google/jsonschema-go v0.2.1-0.20250825175020-748c325cec76/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.2.1 h1:Z3iINWAUmvS4+m9cMP5lWbn6WlX8Hy4rpUS4pULVliQ= +github.com/google/jsonschema-go v0.2.1/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= diff --git a/go.mod b/go.mod index ebcdc59..fa4d2c3 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.23.0 require ( github.com/google/go-cmp v0.7.0 - github.com/google/jsonschema-go v0.2.1-0.20250825175020-748c325cec76 + github.com/google/jsonschema-go v0.2.1 github.com/yosida95/uritemplate/v3 v3.0.2 golang.org/x/tools v0.34.0 ) diff --git a/go.sum b/go.sum index 9ae7018..7ed9f14 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,7 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/jsonschema-go v0.2.0 h1:Uh19091iHC56//WOsAd1oRg6yy1P9BpSvpjOL6RcjLQ= -github.com/google/jsonschema-go v0.2.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= -github.com/google/jsonschema-go v0.2.1-0.20250825175020-748c325cec76 h1:mBlBwtDebdDYr+zdop8N62a44g+Nbv7o2KjWyS1deR4= -github.com/google/jsonschema-go v0.2.1-0.20250825175020-748c325cec76/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.2.1 h1:Z3iINWAUmvS4+m9cMP5lWbn6WlX8Hy4rpUS4pULVliQ= +github.com/google/jsonschema-go v0.2.1/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= From 5bbca40032e75a02c7a83a4af3a8299d0c88dc7b Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Tue, 9 Sep 2025 14:37:03 +0000 Subject: [PATCH 036/418] mcp: fix typo in protocolVersion20241105 This version was so last year. Fixes #432 --- mcp/shared.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mcp/shared.go b/mcp/shared.go index 2990392..69b8836 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -36,13 +36,13 @@ const ( latestProtocolVersion = protocolVersion20250618 protocolVersion20250618 = "2025-06-18" protocolVersion20250326 = "2025-03-26" - protocolVersion20251105 = "2024-11-05" + protocolVersion20241105 = "2024-11-05" ) var supportedProtocolVersions = []string{ protocolVersion20250618, protocolVersion20250326, - protocolVersion20251105, + protocolVersion20241105, } // negotiatedVersion returns the effective protocol version to use, given a From b1c75f04d895b9ca4afafbf388e82a15de31f441 Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Wed, 10 Sep 2025 13:36:19 +0000 Subject: [PATCH 037/418] mcp: remove deprecated transport constructors In #272, we made transports open structs and deprecated their constructors. However, we left the constructors in place to allow go:fix directives to facilitate migration. Now let's remove the constructors before cutting the release. Fixes #305 --- mcp/cmd.go | 13 ----------- mcp/sse.go | 37 ----------------------------- mcp/streamable.go | 53 ------------------------------------------ mcp/streamable_test.go | 2 +- mcp/transport.go | 20 ---------------- 5 files changed, 1 insertion(+), 124 deletions(-) diff --git a/mcp/cmd.go b/mcp/cmd.go index 01195d2..55e5cca 100644 --- a/mcp/cmd.go +++ b/mcp/cmd.go @@ -27,19 +27,6 @@ type CommandTransport struct { TerminateDuration time.Duration } -// NewCommandTransport returns a [CommandTransport] that runs the given command -// and communicates with it over stdin/stdout. -// -// The resulting transport takes ownership of the command, starting it during -// [CommandTransport.Connect], and stopping it when the connection is closed. -// -// Deprecated: use a CommandTransport literal. -// -//go:fix inline -func NewCommandTransport(cmd *exec.Cmd) *CommandTransport { - return &CommandTransport{Command: cmd} -} - // Connect starts the command, and connects to it over stdin/stdout. func (t *CommandTransport) Connect(ctx context.Context) (Connection, error) { stdout, err := t.Command.StdoutPipe() diff --git a/mcp/sse.go b/mcp/sse.go index b7f0d4e..f39a039 100644 --- a/mcp/sse.go +++ b/mcp/sse.go @@ -114,19 +114,6 @@ type SSEServerTransport struct { done chan struct{} // closed when the connection is closed } -// NewSSEServerTransport creates a new SSE transport for the given messages -// endpoint, and hanging GET response. -// -// Deprecated: use an SSEServerTransport literal. -// -//go:fix inline -func NewSSEServerTransport(endpoint string, w http.ResponseWriter) *SSEServerTransport { - return &SSEServerTransport{ - Endpoint: endpoint, - Response: w, - } -} - // ServeHTTP handles POST requests to the transport endpoint. func (t *SSEServerTransport) ServeHTTP(w http.ResponseWriter, req *http.Request) { if t.incoming == nil { @@ -334,30 +321,6 @@ type SSEClientTransport struct { HTTPClient *http.Client } -// SSEClientTransportOptions provides options for the [NewSSEClientTransport] -// constructor. -// -// Deprecated: use an SSEClientTransport literal. -type SSEClientTransportOptions struct { - // HTTPClient is the client to use for making HTTP requests. If nil, - // http.DefaultClient is used. - HTTPClient *http.Client -} - -// NewSSEClientTransport returns a new client transport that connects to the -// SSE server at the provided URL. -// -// Deprecated: use an SSEClientTransport literal. -// -//go:fix inline -func NewSSEClientTransport(endpoint string, opts *SSEClientTransportOptions) *SSEClientTransport { - t := &SSEClientTransport{Endpoint: endpoint} - if opts != nil { - t.HTTPClient = opts.HTTPClient - } - return t -} - // Connect connects through the client endpoint. func (c *SSEClientTransport) Connect(ctx context.Context) (Connection, error) { parsedURL, err := url.Parse(c.Endpoint) diff --git a/mcp/streamable.go b/mcp/streamable.go index 1eef9a7..e820a2b 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -323,15 +323,6 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque transport.ServeHTTP(w, req) } -// StreamableServerTransportOptions configures the stramable server transport. -// -// Deprecated: use a StreamableServerTransport literal. -type StreamableServerTransportOptions struct { - // Storage for events, to enable stream resumption. - // If nil, a [MemoryEventStore] with the default maximum size will be used. - EventStore EventStore -} - // A StreamableServerTransport implements the server side of the MCP streamable // transport. // @@ -385,22 +376,6 @@ type StreamableServerTransport struct { connection *streamableServerConn } -// NewStreamableServerTransport returns a new [StreamableServerTransport] with -// the given session ID and options. -// -// Deprecated: use a StreamableServerTransport literal. -// -//go:fix inline. -func NewStreamableServerTransport(sessionID string, opts *StreamableServerTransportOptions) *StreamableServerTransport { - t := &StreamableServerTransport{ - SessionID: sessionID, - } - if opts != nil { - t.EventStore = opts.EventStore - } - return t -} - // Connect implements the [Transport] interface. func (t *StreamableServerTransport) Connect(ctx context.Context) (Connection, error) { if t.connection != nil { @@ -1025,34 +1000,6 @@ const ( reconnectMaxDelay = 30 * time.Second ) -// StreamableClientTransportOptions provides options for the -// [NewStreamableClientTransport] constructor. -// -// Deprecated: use a StremableClientTransport literal. -type StreamableClientTransportOptions struct { - // HTTPClient is the client to use for making HTTP requests. If nil, - // http.DefaultClient is used. - HTTPClient *http.Client - // MaxRetries is the maximum number of times to attempt a reconnect before giving up. - // It defaults to 5. To disable retries, use a negative number. - MaxRetries int -} - -// NewStreamableClientTransport returns a new client transport that connects to -// the streamable HTTP server at the provided URL. -// -// Deprecated: use a StreamableClientTransport literal. -// -//go:fix inline -func NewStreamableClientTransport(url string, opts *StreamableClientTransportOptions) *StreamableClientTransport { - t := &StreamableClientTransport{Endpoint: url} - if opts != nil { - t.HTTPClient = opts.HTTPClient - t.MaxRetries = opts.MaxRetries - } - return t -} - // Connect implements the [Transport] interface. // // The resulting [Connection] writes messages via POST requests to the diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index a85fbec..5c9685e 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1211,7 +1211,7 @@ func TestTokenInfo(t *testing.T) { httpServer := httptest.NewServer(handler) defer httpServer.Close() - transport := NewStreamableClientTransport(httpServer.URL, nil) + transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) session, err := client.Connect(ctx, transport, nil) if err != nil { diff --git a/mcp/transport.go b/mcp/transport.go index 5c7ca13..608247c 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -93,16 +93,6 @@ func (*StdioTransport) Connect(context.Context) (Connection, error) { return newIOConn(rwc{os.Stdin, os.Stdout}), nil } -// NewStdioTransport constructs a transport that communicates over -// stdin/stdout. -// -// Deprecated: use a StdioTransport literal. -// -//go:fix inline -func NewStdioTransport() *StdioTransport { - return &StdioTransport{} -} - // An InMemoryTransport is a [Transport] that communicates over an in-memory // network connection, using newline-delimited JSON. type InMemoryTransport struct { @@ -215,16 +205,6 @@ type LoggingTransport struct { Writer io.Writer } -// NewLoggingTransport creates a new LoggingTransport that delegates to the -// provided transport, writing RPC logs to the provided io.Writer. -// -// Deprecated: use a LoggingTransport literal. -// -//go:fix inline -func NewLoggingTransport(delegate Transport, w io.Writer) *LoggingTransport { - return &LoggingTransport{Transport: delegate, Writer: w} -} - // Connect connects the underlying transport, returning a [Connection] that writes // logs to the configured destination. func (t *LoggingTransport) Connect(ctx context.Context) (Connection, error) { From 728e0e3ac967a0cbf94eca9bce7a32b7ce0d287d Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Tue, 9 Sep 2025 14:36:19 +0000 Subject: [PATCH 038/418] mcp: improve error messages from Wait for streamable clients Previously, the error message received from ClientSession.Wait would only report the closeErr, which would often be nil even if the client transport was broken. Wait should return the reason the session terminated, if abnormal. I'm not sure of the exact semantics of this, but surely returning nil is less useful than returning a meaningful non-nil error. We can refine our handling of errors once we have more feedback. Also add a test for client termination on HTTP server shutdown, described in #265. This should work as long as (1) the session is stateful (with a hanging GET), or (2) the session is stateless but the client has a keepalive ping. Also: don't send DELETE if the session was terminated with 404; +test. Fixes #265 --- internal/jsonrpc2/conn.go | 27 +++++++++++-- mcp/streamable.go | 70 +++++++++++++++++++++++++-------- mcp/streamable_client_test.go | 52 +++++++++++++++++++++++- mcp/streamable_test.go | 74 +++++++++++++++++++++++++++++++++++ 4 files changed, 202 insertions(+), 21 deletions(-) diff --git a/internal/jsonrpc2/conn.go b/internal/jsonrpc2/conn.go index 49902b0..5549ee1 100644 --- a/internal/jsonrpc2/conn.go +++ b/internal/jsonrpc2/conn.go @@ -483,10 +483,32 @@ func (c *Connection) Cancel(id ID) { // Wait blocks until the connection is fully closed, but does not close it. func (c *Connection) Wait() error { + return c.wait(true) +} + +// wait for the connection to close, and aggregates the most cause of its +// termination, if abnormal. +// +// The fromWait argument allows this logic to be shared with Close, where we +// only want to expose the closeErr. +// +// (Previously, Wait also only returned the closeErr, which was misleading if +// the connection was broken for another reason). +func (c *Connection) wait(fromWait bool) error { var err error <-c.done c.updateInFlight(func(s *inFlightState) { - err = s.closeErr + if fromWait { + if !errors.Is(s.readErr, io.EOF) { + err = s.readErr + } + if err == nil && !errors.Is(s.writeErr, io.EOF) { + err = s.writeErr + } + } + if err == nil { + err = s.closeErr + } }) return err } @@ -502,8 +524,7 @@ func (c *Connection) Close() error { // Stop handling new requests, and interrupt the reader (by closing the // connection) as soon as the active requests finish. c.updateInFlight(func(s *inFlightState) { s.connClosing = true }) - - return c.Wait() + return c.wait(false) } // readIncoming collects inbound messages from the reader and delivers them, either responding diff --git a/mcp/streamable.go b/mcp/streamable.go index e820a2b..1e35ca5 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1066,6 +1066,17 @@ type streamableClientConn struct { sessionID string } +// errSessionMissing distinguishes if the session is known to not be present on +// the server (see [streamableClientConn.fail]). +// +// TODO(rfindley): should we expose this error value (and its corresponding +// API) to the user? +// +// The spec says that if the server returns 404, clients should reestablish +// a session. For now, we delegate that to the user, but do they need a way to +// differentiate a 'NotFound' error from other errors? +var errSessionMissing = errors.New("session not found") + var _ clientConnection = (*streamableClientConn)(nil) func (c *streamableClientConn) sessionUpdated(state clientSessionState) { @@ -1093,6 +1104,10 @@ func (c *streamableClientConn) sessionUpdated(state clientSessionState) { // // If err is non-nil, it is terminal, and subsequent (or pending) Reads will // fail. +// +// If err wraps errSessionMissing, the failure indicates that the session is no +// longer present on the server, and no final DELETE will be performed when +// closing the connection. func (c *streamableClientConn) fail(err error) { if err != nil { c.failOnce.Do(func() { @@ -1140,9 +1155,19 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e return err } + var requestSummary string + switch msg := msg.(type) { + case *jsonrpc.Request: + requestSummary = fmt.Sprintf("sending %q", msg.Method) + case *jsonrpc.Response: + requestSummary = fmt.Sprintf("sending jsonrpc response #%d", msg.ID) + default: + panic("unreachable") + } + data, err := jsonrpc.EncodeMessage(msg) if err != nil { - return err + return fmt.Errorf("%s: %v", requestSummary, err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(data)) @@ -1155,9 +1180,21 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e resp, err := c.client.Do(req) if err != nil { - return err + return fmt.Errorf("%s: %v", requestSummary, err) } + // Section 2.5.3: "The server MAY terminate the session at any time, after + // which it MUST respond to requests containing that session ID with HTTP + // 404 Not Found." + if resp.StatusCode == http.StatusNotFound { + // Fail the session immediately, rather than relying on jsonrpc2 to fail + // (and close) it, because we want the call to Close to know that this + // session is missing (and therefore not send the DELETE). + err := fmt.Errorf("%s: failed to send: %w", requestSummary, errSessionMissing) + c.fail(err) + resp.Body.Close() + return err + } if resp.StatusCode < 200 || resp.StatusCode >= 300 { resp.Body.Close() return fmt.Errorf("broken session: %v", resp.Status) @@ -1180,16 +1217,6 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e return nil } - var requestSummary string - switch msg := msg.(type) { - case *jsonrpc.Request: - requestSummary = fmt.Sprintf("sending %q", msg.Method) - case *jsonrpc.Response: - requestSummary = fmt.Sprintf("sending jsonrpc response #%d", msg.ID) - default: - panic("unreachable") - } - switch ct := resp.Header.Get("Content-Type"); ct { case "application/json": go c.handleJSON(requestSummary, resp) @@ -1280,6 +1307,11 @@ func (c *streamableClientConn) handleSSE(requestSummary string, initialResp *htt resp.Body.Close() return } + // (see equivalent handling in [streamableClientConn.Write]). + if resp.StatusCode == http.StatusNotFound { + c.fail(fmt.Errorf("%s: failed to reconnect: %w", requestSummary, errSessionMissing)) + return + } if resp.StatusCode < 200 || resp.StatusCode >= 300 { resp.Body.Close() c.fail(fmt.Errorf("%s: failed to reconnect: %v", requestSummary, http.StatusText(resp.StatusCode))) @@ -1370,13 +1402,17 @@ func (c *streamableClientConn) Close() error { c.cancel() close(c.done) - req, err := http.NewRequest(http.MethodDelete, c.url, nil) - if err != nil { - c.closeErr = err + if errors.Is(c.failure(), errSessionMissing) { + // If the session is missing, no need to delete it. } else { - c.setMCPHeaders(req) - if _, err := c.client.Do(req); err != nil { + req, err := http.NewRequest(http.MethodDelete, c.url, nil) + if err != nil { c.closeErr = err + } else { + c.setMCPHeaders(req) + if _, err := c.client.Do(req); err != nil { + c.closeErr = err + } } } }) diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index fe87b21..001d3a6 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -29,7 +29,7 @@ type streamableRequestKey struct { type header map[string]string type streamableResponse struct { - header header + header header // response headers status int // or http.StatusOK body string // or "" optional bool // if set, request need not be sent @@ -187,6 +187,56 @@ func TestStreamableClientTransportLifecycle(t *testing.T) { } } +func TestStreamableClientRedundantDelete(t *testing.T) { + ctx := context.Background() + + // The lifecycle test verifies various behavior of the streamable client + // initialization: + // - check that it can handle application/json responses + // - check that it sends the negotiated protocol version + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodInitialize}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "123", + }, + body: jsonBody(t, initResp), + }, + {"POST", "123", notificationInitialized}: { + status: http.StatusAccepted, + wantProtocolVersion: latestProtocolVersion, + }, + {"GET", "123", ""}: { + status: http.StatusMethodNotAllowed, + optional: true, + }, + {"POST", "123", methodListTools}: { + status: http.StatusNotFound, + }, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, nil) + if err != nil { + t.Fatalf("client.Connect() failed: %v", err) + } + _, err = session.ListTools(ctx, nil) + if err == nil { + t.Errorf("Listing tools: got nil error, want non-nil") + } + _ = session.Wait() // must not hang + if missing := fake.missingRequests(); len(missing) > 0 { + t.Errorf("did not receive expected requests: %v", missing) + } +} + func TestStreamableClientGETHandling(t *testing.T) { ctx := context.Background() diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 5c9685e..f731a08 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -190,6 +190,80 @@ func TestStreamableTransports(t *testing.T) { } } +func TestStreamableServerShutdown(t *testing.T) { + ctx := context.Background() + + // This test checks that closing the streamable HTTP server actually results + // in client session termination, provided one of following holds: + // 1. The server is stateful, and therefore the hanging GET fails the connection. + // 2. The server is stateless, and the client uses a KeepAlive. + tests := []struct { + name string + stateless, keepalive bool + }{ + {"stateful", false, false}, + {"stateless with keepalive", true, true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := NewServer(testImpl, nil) + // Add a tool, just so we can check things are working. + AddTool(server, &Tool{Name: "greet"}, sayHi) + + handler := NewStreamableHTTPHandler( + func(req *http.Request) *Server { return server }, + &StreamableHTTPOptions{Stateless: test.stateless}) + + // When we shut down the server, we need to explicitly close ongoing + // connections. Otherwise, the hanging GET may never terminate. + httpServer := httptest.NewUnstartedServer(handler) + httpServer.Config.RegisterOnShutdown(func() { + for session := range server.Sessions() { + session.Close() + } + }) + httpServer.Start() + defer httpServer.Close() + + // Connect and run a tool. + var opts ClientOptions + if test.keepalive { + opts.KeepAlive = 50 * time.Millisecond + } + client := NewClient(testImpl, &opts) + clientSession, err := client.Connect(ctx, &StreamableClientTransport{ + Endpoint: httpServer.URL, + MaxRetries: -1, // avoid slow tests during exponential retries + }, nil) + if err != nil { + t.Fatal(err) + } + + params := &CallToolParams{ + Name: "greet", + Arguments: map[string]any{"name": "foo"}, + } + // Verify that we can call a tool. + if _, err := clientSession.CallTool(ctx, params); err != nil { + t.Fatalf("CallTool() failed: %v", err) + } + + // Shut down the server. Sessions should terminate. + go func() { + if err := httpServer.Config.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) { + t.Errorf("closing http server: %v", err) + } + }() + + // Wait may return an error (after all, the connection failed), but it + // should not hang. + t.Log("Client waiting") + _ = clientSession.Wait() + }) + } +} + // TestClientReplay verifies that the client can recover from a mid-stream // network failure and receive replayed messages (if replay is configured). It // uses a proxy that is killed and restarted to simulate a recoverable network From 3a66cf399735f4185b494bad41c340b813580005 Mon Sep 17 00:00:00 2001 From: Sam Thanawalla <17936816+samthanawalla@users.noreply.github.com> Date: Thu, 11 Sep 2025 10:21:02 -0400 Subject: [PATCH 039/418] go.mod: update jsonschema to v0.2.2 (#446) This version of jsonschema fixes embedded structs. Fixes: #437 --- go.mod | 2 +- go.sum | 4 ++-- mcp/mcp_test.go | 60 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index fa4d2c3..0e18643 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.23.0 require ( github.com/google/go-cmp v0.7.0 - github.com/google/jsonschema-go v0.2.1 + github.com/google/jsonschema-go v0.2.2 github.com/yosida95/uritemplate/v3 v3.0.2 golang.org/x/tools v0.34.0 ) diff --git a/go.sum b/go.sum index 7ed9f14..169c67e 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/jsonschema-go v0.2.1 h1:Z3iINWAUmvS4+m9cMP5lWbn6WlX8Hy4rpUS4pULVliQ= -github.com/google/jsonschema-go v0.2.1/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.2.2 h1:qb9KM/pATIqIPuE9gEDwPsco8HHCTlA88IGFYHDl03A= +github.com/google/jsonschema-go v0.2.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index aee06a3..4b05ce7 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -1777,3 +1777,63 @@ func TestComplete(t *testing.T) { t.Errorf("Complete() mismatch (-want +got):\n%s", diff) } } + +// TestEmbeddedStructResponse performs a tool call to verify that a struct with +// an embedded pointer generates a correct, flattened JSON schema and that its +// response is validated successfully. +func TestEmbeddedStructResponse(t *testing.T) { + type foo struct { + ID string `json:"id"` + Name string `json:"name"` + } + + // bar embeds foo + type bar struct { + *foo // Embedded - should flatten in JSON + Extra string `json:"extra"` + } + + type response struct { + Data bar `json:"data"` + } + + // testTool demonstrates an embedded struct in its response. + testTool := func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, response, error) { + response := response{ + Data: bar{ + foo: &foo{ + ID: "foo", + Name: "Test Foo", + }, + Extra: "additional data", + }, + } + return nil, response, nil + } + ctx := context.Background() + clientTransport, serverTransport := NewInMemoryTransports() + server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) + AddTool(server, &Tool{ + Name: "test_embedded_struct", + }, testTool) + + serverSession, err := server.Connect(ctx, serverTransport, nil) + if err != nil { + t.Fatal(err) + } + defer serverSession.Close() + + client := NewClient(&Implementation{Name: "test-client"}, nil) + clientSession, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + t.Fatal(err) + } + defer clientSession.Close() + + _, err = clientSession.CallTool(ctx, &CallToolParams{ + Name: "test_embedded_struct", + }) + if err != nil { + t.Errorf("CallTool() failed: %v", err) + } +} From 872b437b2a098abba63abab3e953b156de02087e Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Wed, 10 Sep 2025 19:25:22 +0000 Subject: [PATCH 040/418] internal/readme: use go:generate rather than Make Simplify the readme generation to just use go:generate, no longer depending on Make or bash. Subsequent CLs will generate more documentation in a similar manner. For #442 --- .github/workflows/readme-check.yml | 7 +++---- internal/readme/Makefile | 19 ------------------- internal/readme/build.sh | 22 ---------------------- internal/readme/doc.go | 9 +++++++++ mcp/mcp.go | 2 -- 5 files changed, 12 insertions(+), 47 deletions(-) delete mode 100644 internal/readme/Makefile delete mode 100755 internal/readme/build.sh create mode 100644 internal/readme/doc.go diff --git a/.github/workflows/readme-check.yml b/.github/workflows/readme-check.yml index b3398ea..bed3ff4 100644 --- a/.github/workflows/readme-check.yml +++ b/.github/workflows/readme-check.yml @@ -19,14 +19,13 @@ jobs: uses: actions/checkout@v4 - name: Check README is up-to-date run: | - cd internal/readme - make + go generate ./internal/readme if [ -n "$(git status --porcelain)" ]; then echo "ERROR: README.md is not up-to-date!" echo "" - echo "The README.md file differs from what would be generated by running 'make' in internal/readme/." + echo "The README.md file differs from what would be generated by `go generate ./internal/readme`." echo "Please update internal/readme/README.src.md instead of README.md directly," - echo "then run 'make' in the internal/readme/ directory to regenerate README.md." + echo "then run `go generate ./internal/readme` to regenerate README.md." echo "" echo "Changes:" git status --porcelain diff --git a/internal/readme/Makefile b/internal/readme/Makefile deleted file mode 100644 index 5e48418..0000000 --- a/internal/readme/Makefile +++ /dev/null @@ -1,19 +0,0 @@ -# This makefile builds ../README.md from the files in this directory. - -OUTFILE=../../README.md - -$(OUTFILE): build README.src.md - go run golang.org/x/example/internal/cmd/weave@latest README.src.md > $(OUTFILE) - -# Compile all the code used in the README. -build: $(wildcard */*.go) - go build -o /tmp/mcp-readme/ ./... - -# Preview the README on GitHub. -# $HOME/markdown must be a github repo. -# Visit https://github.com/$HOME/markdown to see the result. -preview: $(OUTFILE) - cp $(OUTFILE) $$HOME/markdown/ - (cd $$HOME/markdown/ && git commit -m . README.md && git push) - -.PHONY: build preview diff --git a/internal/readme/build.sh b/internal/readme/build.sh deleted file mode 100755 index b721d50..0000000 --- a/internal/readme/build.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/sh - -# Build README.md from the files in this directory. -# Must be invoked from the mcp directory. - -cd ../internal/readme - -outfile=../../README.md - -# Compile all the code used in the README. -go build -o /tmp/mcp-readme/ ./... -# Combine the code with the text in README.src.md. -# TODO: when at Go 1.24, use a tool directive for weave. -go run golang.org/x/example/internal/cmd/weave@latest README.src.md > $outfile - -if [[ $1 = '-preview' ]]; then - # Preview the README on GitHub. - # $HOME/markdown must be a github repo. - # Visit https://github.com/$HOME/markdown to see the result. - cp $outfile $HOME/markdown/ - (cd $HOME/markdown/ && git commit -m . README.md && git push) -fi diff --git a/internal/readme/doc.go b/internal/readme/doc.go new file mode 100644 index 0000000..34bc60c --- /dev/null +++ b/internal/readme/doc.go @@ -0,0 +1,9 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +//go:generate go run golang.org/x/example/internal/cmd/weave@latest -o ../../README.md ./README.src.md + +// The readme package is used to generate README.md at the top-level of this +// repo. Regenerate the README with go generate. +package readme diff --git a/mcp/mcp.go b/mcp/mcp.go index 839f919..88321a1 100644 --- a/mcp/mcp.go +++ b/mcp/mcp.go @@ -2,8 +2,6 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. -//go:generate ../internal/readme/build.sh - // The mcp package provides an SDK for writing model context protocol clients // and servers. // From bf3ff50a1693a201bb5d1da49c505e4adf898101 Mon Sep 17 00:00:00 2001 From: ankitm123 Date: Thu, 11 Sep 2025 10:05:43 +0200 Subject: [PATCH 041/418] mcp: remove json-rpc batching for more recent protocol versions JSON-RPC batching support is removed for protocol versions greater than or equal to protocolVersion20250618 Fixes #21 --- mcp/streamable.go | 19 ++++++++++++------- mcp/streamable_test.go | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 1e35ca5..bfaccae 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -614,17 +614,22 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques http.Error(w, "POST requires a non-empty body", http.StatusBadRequest) return } - // TODO(#21): if the negotiated protocol version is 2025-06-18 or later, - // we should not allow batching here. - // - // This also requires access to the negotiated version, which would either be - // set by the MCP-Protocol-Version header, or would require peeking into the - // session. - incoming, _, err := readBatch(body) + incoming, isBatch, err := readBatch(body) if err != nil { http.Error(w, fmt.Sprintf("malformed payload: %v", err), http.StatusBadRequest) return } + + protocolVersion := req.Header.Get(protocolVersionHeader) + if protocolVersion == "" { + protocolVersion = protocolVersion20250326 + } + + if isBatch && protocolVersion >= protocolVersion20250618 { + http.Error(w, fmt.Sprintf("JSON-RPC batching is not supported in %s and later (request version: %s)", protocolVersion20250618, protocolVersion), http.StatusBadRequest) + return + } + requests := make(map[jsonrpc.ID]struct{}) tokenInfo := auth.TokenInfoFromContext(req.Context()) isInitialize := false diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index f731a08..8817784 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -647,6 +647,46 @@ func TestStreamableServerTransport(t *testing.T) { }, }, }, + { + name: "batch rejected on 2025-06-18", + requests: []streamableRequest{ + initialize, + initialized, + { + method: "POST", + // Explicitly set the protocol version header + headers: http.Header{"MCP-Protocol-Version": {"2025-06-18"}}, + // Two messages => batch. Expect reject. + messages: []jsonrpc.Message{ + req(101, "tools/call", &CallToolParams{Name: "tool"}), + req(102, "tools/call", &CallToolParams{Name: "tool"}), + }, + wantStatusCode: http.StatusBadRequest, + wantBodyContaining: "batch", + }, + }, + }, + { + name: "batch accepted on 2025-03-26", + requests: []streamableRequest{ + initialize, + initialized, + { + method: "POST", + headers: http.Header{"MCP-Protocol-Version": {"2025-03-26"}}, + // Two messages => batch. Expect OK with two responses in order. + messages: []jsonrpc.Message{ + req(201, "tools/call", &CallToolParams{Name: "tool"}), + req(202, "tools/call", &CallToolParams{Name: "tool"}), + }, + wantStatusCode: http.StatusOK, + wantMessages: []jsonrpc.Message{ + resp(201, &CallToolResult{Content: []Content{}}, nil), + resp(202, &CallToolResult{Content: []Content{}}, nil), + }, + }, + }, + }, { name: "tool notification", tool: func(t *testing.T, ctx context.Context, ss *ServerSession) { From b66679350e07b21fcd49a7c9200461d778a591ef Mon Sep 17 00:00:00 2001 From: Sam Thanawalla <17936816+samthanawalla@users.noreply.github.com> Date: Thu, 11 Sep 2025 13:33:46 -0400 Subject: [PATCH 042/418] transport.go: disable stdio batching for newer protocols (#453) This PR disables batching support for stdio by storing the negotiated protocol version and comparing it to protocolVersion20250618. Fixes: #21 --- mcp/transport.go | 17 +++++++++++++++++ mcp/transport_test.go | 37 ++++++++++++++++++++++++++++++++----- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/mcp/transport.go b/mcp/transport.go index 608247c..024863d 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -283,6 +283,8 @@ func (r rwc) Close() error { // // See [msgBatch] for more discussion of message batching. type ioConn struct { + protocolVersion string // negotiated version, set during session initialization. + writeMu sync.Mutex // guards Write, which must be concurrency safe. rwc io.ReadWriteCloser // the underlying stream @@ -360,6 +362,17 @@ func newIOConn(rwc io.ReadWriteCloser) *ioConn { func (c *ioConn) SessionID() string { return "" } +func (c *ioConn) sessionUpdated(state ServerSessionState) { + protocolVersion := "" + if state.InitializeParams != nil { + protocolVersion = state.InitializeParams.ProtocolVersion + } + if protocolVersion == "" { + protocolVersion = protocolVersion20250326 + } + c.protocolVersion = negotiatedVersion(protocolVersion) +} + // addBatch records a msgBatch for an incoming batch payload. // It returns an error if batch is malformed, containing previously seen IDs. // @@ -458,6 +471,10 @@ func (t *ioConn) Read(ctx context.Context) (jsonrpc.Message, error) { if err != nil { return nil, err } + if batch && t.protocolVersion >= protocolVersion20250618 { + return nil, fmt.Errorf("JSON-RPC batching is not supported in %s and later (request version: %s)", protocolVersion20250618, t.protocolVersion) + } + t.queue = msgs[1:] if batch { diff --git a/mcp/transport_test.go b/mcp/transport_test.go index 18a326e..d40ce10 100644 --- a/mcp/transport_test.go +++ b/mcp/transport_test.go @@ -55,17 +55,16 @@ func TestBatchFraming(t *testing.T) { func TestIOConnRead(t *testing.T) { tests := []struct { - name string - input string - want string + name string + input string + want string + protocolVersion string }{ - { name: "valid json input", input: `{"jsonrpc":"2.0","id":1,"method":"test","params":{}}`, want: "", }, - { name: "newline at the end of first valid json input", input: `{"jsonrpc":"2.0","id":1,"method":"test","params":{}} @@ -77,13 +76,41 @@ func TestIOConnRead(t *testing.T) { input: `{"jsonrpc":"2.0","id":1,"method":"test","params":{}},`, want: "invalid trailing data at the end of stream", }, + { + name: "batching unknown protocol", + input: `[{"jsonrpc":"2.0","id":1,"method":"test1"},{"jsonrpc":"2.0","id":2,"method":"test2"}]`, + want: "", + protocolVersion: "", + }, + { + name: "batching old protocol", + input: `[{"jsonrpc":"2.0","id":1,"method":"test1"},{"jsonrpc":"2.0","id":2,"method":"test2"}]`, + want: "", + protocolVersion: protocolVersion20241105, + }, + { + name: "batching new protocol", + input: `[{"jsonrpc":"2.0","id":1,"method":"test1"},{"jsonrpc":"2.0","id":2,"method":"test2"}]`, + want: "JSON-RPC batching is not supported in 2025-06-18 and later (request version: 2025-06-18)", + protocolVersion: protocolVersion20250618, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tr := newIOConn(rwc{ rc: io.NopCloser(strings.NewReader(tt.input)), }) + if tt.protocolVersion != "" { + tr.sessionUpdated(ServerSessionState{ + InitializeParams: &InitializeParams{ + ProtocolVersion: tt.protocolVersion, + }, + }) + } _, err := tr.Read(context.Background()) + if err == nil && tt.want != "" { + t.Errorf("ioConn.Read() got nil error but wanted %v", tt.want) + } if err != nil && err.Error() != tt.want { t.Errorf("ioConn.Read() = %v, want %v", err.Error(), tt.want) } From eddef064df1dada305c6198d9adcd63f8da25d42 Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Mon, 8 Sep 2025 18:32:15 +0000 Subject: [PATCH 043/418] all: add a test that checks for missing or mismatching file copyright In #294, we accidentally checked files with the default Go project copyright header. Avoid this in the future with a copyright test. --- copyright_test.go | 57 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 copyright_test.go diff --git a/copyright_test.go b/copyright_test.go new file mode 100644 index 0000000..287e594 --- /dev/null +++ b/copyright_test.go @@ -0,0 +1,57 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package gosdk + +import ( + "fmt" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "regexp" + "strings" + "testing" +) + +func TestCopyrightHeaders(t *testing.T) { + var re = regexp.MustCompile(`Copyright \d{4} The Go MCP SDK Authors. All rights reserved. +Use of this source code is governed by an MIT-style +license that can be found in the LICENSE file.`) + + err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip directories starting with "." or "_", and testdata directories. + if d.IsDir() && d.Name() != "." && + (strings.HasPrefix(d.Name(), ".") || + strings.HasPrefix(d.Name(), "_") || + filepath.Base(d.Name()) == "testdata") { + + return filepath.SkipDir + } + + // Skip non-go files. + if !strings.HasSuffix(path, ".go") { + return nil + } + + // Check the copyright header. + f, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ParseComments|parser.PackageClauseOnly) + if err != nil { + return fmt.Errorf("parsing %s: %v", path, err) + } + if len(f.Comments) == 0 { + t.Errorf("File %s must start with a copyright header matching %s", path, re) + } else if !re.MatchString(f.Comments[0].Text()) { + t.Errorf("Header comment for %s does not match expected copyright header.\ngot:\n%s\nwant matching:%s", path, f.Comments[0].Text(), re) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} From 9f1b8646bdf6a3c004e273c93d031baac706c352 Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Thu, 11 Sep 2025 19:48:09 +0000 Subject: [PATCH 044/418] mcp: propertly validate against JSON, independent of Go values Our validation logic was avoiding double-unmarshalling as much as possible, by parsing before validation and validating the Go type. This only works if the Go type has the same structure as its JSON representation, which may not be the case in the presence of types with custom MarshalJSON or UnmarshalJSON methods (such as time.Time). But even if the Go type doesn't use any custom marshalling, validation is broken, because we can't differentiate zero values from missing values. Bite the bullet and use double-unmarshalling for both input and output schemas. Coincidentally, this fixes three bugs: - We were accepting case-insensitive JSON keys, since we parsed first, even though they should have been rejected. A number of tests were wrong. - Defaults were overriding present-yet-zero values, as noted in an incorrect test case. - When "arguments" was missing, validation wasn't performed, no defaults were applied, and unmarshalling failed even if all properties were optional. First unmarshalling to map[string]any allows us to fix all these bugs. Unfortunately, it means a 3x increase in the number of reflection operations (we need to unmarshal, apply defaults and validate, re-marshal with the defaults, and then unmarshal into the Go type). However, this is not likely to be a significant overhead, and we can always optimize in the future. Update github.com/google/jsonschema-go to pick up necessary improvements supporting this change. Additionally, fix the error codes for invalid tool parameters, to be consistent with other SDKs (Invalid Params: -32602). Fixes #447 Fixes #449 Updates #450 --- go.mod | 2 +- go.sum | 4 +- mcp/conformance_test.go | 43 ++++++- mcp/mcp_test.go | 6 +- mcp/protocol.go | 7 ++ mcp/server.go | 45 ++++--- mcp/server_test.go | 53 +++++--- mcp/sse_example_test.go | 3 +- mcp/streamable_test.go | 5 +- mcp/testdata/conformance/server/tools.txtar | 127 +++++++++++++++++++- mcp/tool.go | 64 +++++----- mcp/tool_test.go | 13 +- 12 files changed, 287 insertions(+), 85 deletions(-) diff --git a/go.mod b/go.mod index 0e18643..d303ef0 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.23.0 require ( github.com/google/go-cmp v0.7.0 - github.com/google/jsonschema-go v0.2.2 + github.com/google/jsonschema-go v0.2.3-0.20250911201137-bbdc431016d2 github.com/yosida95/uritemplate/v3 v3.0.2 golang.org/x/tools v0.34.0 ) diff --git a/go.sum b/go.sum index 169c67e..6903b65 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/jsonschema-go v0.2.2 h1:qb9KM/pATIqIPuE9gEDwPsco8HHCTlA88IGFYHDl03A= -github.com/google/jsonschema-go v0.2.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.2.3-0.20250911201137-bbdc431016d2 h1:IIj7X4SH1HKy0WfPR4nNEj4dhIJWGdXM5YoBAbfpdoo= +github.com/google/jsonschema-go v0.2.3-0.20250911201137-bbdc431016d2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= diff --git a/mcp/conformance_test.go b/mcp/conformance_test.go index a8da4fb..3393efc 100644 --- a/mcp/conformance_test.go +++ b/mcp/conformance_test.go @@ -20,9 +20,11 @@ import ( "strings" "testing" "testing/synctest" + "time" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/jsonrpc" "golang.org/x/tools/txtar" @@ -97,16 +99,40 @@ func TestServerConformance(t *testing.T) { } } -type input struct { +type structuredInput struct { In string `jsonschema:"the input"` } -type output struct { +type structuredOutput struct { Out string `jsonschema:"the output"` } -func structuredTool(ctx context.Context, req *CallToolRequest, args *input) (*CallToolResult, *output, error) { - return nil, &output{"Ack " + args.In}, nil +func structuredTool(ctx context.Context, req *CallToolRequest, args *structuredInput) (*CallToolResult, *structuredOutput, error) { + return nil, &structuredOutput{"Ack " + args.In}, nil +} + +type tomorrowInput struct { + Now time.Time +} + +type tomorrowOutput struct { + Tomorrow time.Time +} + +func tomorrowTool(ctx context.Context, req *CallToolRequest, args tomorrowInput) (*CallToolResult, tomorrowOutput, error) { + return nil, tomorrowOutput{args.Now.Add(24 * time.Hour)}, nil +} + +type incInput struct { + X int `json:"x,omitempty"` +} + +type incOutput struct { + Y int `json:"y"` +} + +func incTool(_ context.Context, _ *CallToolRequest, args incInput) (*CallToolResult, incOutput, error) { + return nil, incOutput{args.X + 1}, nil } // runServerTest runs the server conformance test. @@ -124,6 +150,15 @@ func runServerTest(t *testing.T, test *conformanceTest) { }, sayHi) case "structured": AddTool(s, &Tool{Name: "structured"}, structuredTool) + case "tomorrow": + AddTool(s, &Tool{Name: "tomorrow"}, tomorrowTool) + case "inc": + inSchema, err := jsonschema.For[incInput](nil) + if err != nil { + t.Fatal(err) + } + inSchema.Properties["x"].Default = json.RawMessage(`6`) + AddTool(s, &Tool{Name: "inc", InputSchema: inSchema}, incTool) default: t.Fatalf("unknown tool %q", tn) } diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 4b05ce7..6191954 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -224,7 +224,7 @@ func TestEndToEnd(t *testing.T) { // ListTools is tested in client_list_test.go. gotHi, err := cs.CallTool(ctx, &CallToolParams{ Name: "greet", - Arguments: map[string]any{"name": "user"}, + Arguments: map[string]any{"Name": "user"}, }) if err != nil { t.Fatal(err) @@ -648,7 +648,7 @@ func TestServerClosing(t *testing.T) { }() if _, err := cs.CallTool(ctx, &CallToolParams{ Name: "greet", - Arguments: map[string]any{"name": "user"}, + Arguments: map[string]any{"Name": "user"}, }); err != nil { t.Fatalf("after connecting: %v", err) } @@ -1646,7 +1646,7 @@ var testImpl = &Implementation{Name: "test", Version: "v1.0.0"} // If anyone asks, we can add an option that controls how pointers are treated. func TestPointerArgEquivalence(t *testing.T) { type input struct { - In string + In string `json:",omitempty"` } type output struct { Out string diff --git a/mcp/protocol.go b/mcp/protocol.go index a8e4817..aeb9adb 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -105,6 +105,13 @@ type CallToolResult struct { IsError bool `json:"isError,omitempty"` } +// TODO(#64): consider exposing setError (and getError), by adding an error +// field on CallToolResult. +func (r *CallToolResult) setError(err error) { + r.Content = []Content{&TextContent{Text: err.Error()}} + r.IsError = true +} + func (*CallToolResult) isResult() {} // UnmarshalJSON handles the unmarshalling of content into the Content diff --git a/mcp/server.go b/mcp/server.go index af9c2ab..69808ac 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -189,7 +189,6 @@ func (s *Server) AddTool(t *Tool, h ToolHandler) { func() bool { s.tools.add(st); return true }) } -// TODO(v0.3.0): test func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHandler, error) { tt := *t @@ -221,11 +220,23 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHan } th := func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { + var input json.RawMessage + if req.Params.Arguments != nil { + input = req.Params.Arguments + } + // Validate input and apply defaults. + var err error + input, err = applySchema(input, inputResolved) + if err != nil { + // TODO(#450): should this be considered a tool error? (and similar below) + return nil, fmt.Errorf("%w: validating \"arguments\": %v", jsonrpc2.ErrInvalidParams, err) + } + // Unmarshal and validate args. var in In - if req.Params.Arguments != nil { - if err := unmarshalSchema(req.Params.Arguments, inputResolved, &in); err != nil { - return nil, err + if input != nil { + if err := json.Unmarshal(input, &in); err != nil { + return nil, fmt.Errorf("%w: %v", jsonrpc2.ErrInvalidParams, err) } } @@ -241,22 +252,15 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHan return nil, wireErr } // For regular errors, embed them in the tool result as per MCP spec - return &CallToolResult{ - Content: []Content{&TextContent{Text: err.Error()}}, - IsError: true, - }, nil - } - - // Validate output schema, if any. - // Skip if out is nil: we've removed "null" from the output schema, so nil won't validate. - if v := reflect.ValueOf(out); v.Kind() == reflect.Pointer && v.IsNil() { - } else if err := validateSchema(outputResolved, &out); err != nil { - return nil, fmt.Errorf("tool output: %w", err) + var errRes CallToolResult + errRes.setError(err) + return &errRes, nil } if res == nil { res = &CallToolResult{} } + // Marshal the output and put the RawMessage in the StructuredContent field. var outval any = out if elemZero != nil { @@ -272,7 +276,16 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHan if err != nil { return nil, fmt.Errorf("marshaling output: %w", err) } - res.StructuredContent = json.RawMessage(outbytes) // avoid a second marshal over the wire + outJSON := json.RawMessage(outbytes) + // Validate the output JSON, and apply defaults. + // + // We validate against the JSON, rather than the output value, as + // some types may have custom JSON marshalling (issue #447). + outJSON, err = applySchema(outJSON, outputResolved) + if err != nil { + return nil, fmt.Errorf("validating tool output: %w", err) + } + res.StructuredContent = outJSON // avoid a second marshal over the wire // If the Content field isn't being used, return the serialized JSON in a // TextContent block, as the spec suggests: diff --git a/mcp/server_test.go b/mcp/server_test.go index e46be37..4456495 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -9,6 +9,7 @@ import ( "encoding/json" "log" "slices" + "strings" "testing" "time" @@ -491,7 +492,7 @@ func TestAddTool(t *testing.T) { type schema = jsonschema.Schema -func testToolForSchema[In, Out any](t *testing.T, tool *Tool, in string, out Out, wantIn, wantOut *schema, wantErr bool) { +func testToolForSchema[In, Out any](t *testing.T, tool *Tool, in string, out Out, wantIn, wantOut *schema, wantErrContaining string) { t.Helper() th := func(context.Context, *CallToolRequest, In) (*CallToolResult, Out, error) { return nil, out, nil @@ -513,34 +514,48 @@ func testToolForSchema[In, Out any](t *testing.T, tool *Tool, in string, out Out } _, err = goth(context.Background(), ctr) - if gotErr := err != nil; gotErr != wantErr { - t.Errorf("got error: %t, want error: %t", gotErr, wantErr) + if wantErrContaining != "" { + if err == nil { + t.Errorf("got nil error, want error containing %q", wantErrContaining) + } else { + if !strings.Contains(err.Error(), wantErrContaining) { + t.Errorf("got error %q, want containing %q", err, wantErrContaining) + } + } + } else if err != nil { + t.Errorf("got error %v, want no error", err) } } func TestToolForSchemas(t *testing.T) { - // Validate that ToolFor handles schemas properly. + // Validate that toolForErr handles schemas properly. + type in struct { + P int `json:"p,omitempty"` + } + type out struct { + B bool `json:"b,omitempty"` + } + + var ( + falseSchema = &schema{Not: &schema{}} + inSchema = &schema{Type: "object", AdditionalProperties: falseSchema, Properties: map[string]*schema{"p": {Type: "integer"}}} + inSchema2 = &schema{Type: "object", AdditionalProperties: falseSchema, Properties: map[string]*schema{"p": {Type: "string"}}} + outSchema = &schema{Type: "object", AdditionalProperties: falseSchema, Properties: map[string]*schema{"b": {Type: "boolean"}}} + outSchema2 = &schema{Type: "object", AdditionalProperties: falseSchema, Properties: map[string]*schema{"b": {Type: "integer"}}} + ) // Infer both schemas. - testToolForSchema[int](t, &Tool{}, "3", true, - &schema{Type: "integer"}, &schema{Type: "boolean"}, false) + testToolForSchema[in](t, &Tool{}, `{"p":3}`, out{true}, inSchema, outSchema, "") // Validate the input schema: expect an error if it's wrong. // We can't test that the output schema is validated, because it's typed. - testToolForSchema[int](t, &Tool{}, `"x"`, true, - &schema{Type: "integer"}, &schema{Type: "boolean"}, true) - + testToolForSchema[in](t, &Tool{}, `{"p":"x"}`, out{true}, inSchema, outSchema, `want "integer"`) // Ignore type any for output. - testToolForSchema[int, any](t, &Tool{}, "3", 0, - &schema{Type: "integer"}, nil, false) + testToolForSchema[in, any](t, &Tool{}, `{"p":3}`, 0, inSchema, nil, "") // Input is still validated. - testToolForSchema[int, any](t, &Tool{}, `"x"`, 0, - &schema{Type: "integer"}, nil, true) - + testToolForSchema[in, any](t, &Tool{}, `{"p":"x"}`, 0, inSchema, nil, `want "integer"`) // Tool sets input schema: that is what's used. - testToolForSchema[int, any](t, &Tool{InputSchema: &schema{Type: "string"}}, "3", 0, - &schema{Type: "string"}, nil, true) // error: 3 is not a string - + testToolForSchema[in, any](t, &Tool{InputSchema: inSchema2}, `{"p":3}`, 0, inSchema2, nil, `want "string"`) // Tool sets output schema: that is what's used, and validation happens. - testToolForSchema[string, any](t, &Tool{OutputSchema: &schema{Type: "integer"}}, "3", "x", - &schema{Type: "string"}, &schema{Type: "integer"}, true) // error: "x" is not an integer + testToolForSchema[in, any](t, &Tool{OutputSchema: outSchema2}, `{"p":3}`, out{true}, + inSchema, outSchema2, `want "integer"`) } diff --git a/mcp/sse_example_test.go b/mcp/sse_example_test.go index 7d77711..d06ea62 100644 --- a/mcp/sse_example_test.go +++ b/mcp/sse_example_test.go @@ -15,7 +15,8 @@ import ( ) type AddParams struct { - X, Y int + X int `json:"x"` + Y int `json:"y"` } func Add(ctx context.Context, req *mcp.CallToolRequest, args AddParams) (*mcp.CallToolResult, any, error) { diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 8817784..e077308 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -144,7 +144,7 @@ func TestStreamableTransports(t *testing.T) { // The "greet" tool should just work. params := &CallToolParams{ Name: "greet", - Arguments: map[string]any{"name": "foo"}, + Arguments: map[string]any{"Name": "foo"}, } got, err := session.CallTool(ctx, params) if err != nil { @@ -239,10 +239,11 @@ func TestStreamableServerShutdown(t *testing.T) { if err != nil { t.Fatal(err) } + defer clientSession.Close() params := &CallToolParams{ Name: "greet", - Arguments: map[string]any{"name": "foo"}, + Arguments: map[string]any{"Name": "foo"}, } // Verify that we can call a tool. if _, err := clientSession.CallTool(ctx, params); err != nil { diff --git a/mcp/testdata/conformance/server/tools.txtar b/mcp/testdata/conformance/server/tools.txtar index c39e3ec..b582dda 100644 --- a/mcp/testdata/conformance/server/tools.txtar +++ b/mcp/testdata/conformance/server/tools.txtar @@ -5,10 +5,17 @@ Fixed bugs: - "_meta" should not be nil - empty resource or prompts should not be returned as 'null' - the server should not crash when params are passed to tools/call +- missing required input fields should be rejected (#449) +- output and input should be validated against their actual json, not Go + representation +- When arguments are missing, the request should succeed if all properties are + optional, and observe any default values. -- tools -- greet structured +tomorrow +inc -- client -- { @@ -26,9 +33,14 @@ structured { "jsonrpc": "2.0", "id": 3, "method": "resources/list" } { "jsonrpc": "2.0", "id": 4, "method": "prompts/list" } { "jsonrpc": "2.0", "id": 5, "method": "tools/call" } -{ "jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": {"name": "greet", "arguments": {"name": "you"} } } +{ "jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": {"name": "greet", "arguments": {"Name": "you"} } } { "jsonrpc": "2.0", "id": 1, "result": {} } { "jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": {"name": "structured", "arguments": {"In": "input"} } } +{ "jsonrpc": "2.0", "id": 8, "method": "tools/call", "params": {"name": "structured", "arguments": {} } } +{ "jsonrpc": "2.0", "id": 9, "method": "tools/call", "params": {"name": "tomorrow", "arguments": { "Now": "2025-06-18T15:04:05Z" } } } +{ "jsonrpc": "2.0", "id": 10, "method": "tools/call", "params": {"name": "greet" } } +{ "jsonrpc": "2.0", "id": 11, "method": "tools/call", "params": {"name": "inc", "arguments": { "x": 3 } } } +{ "jsonrpc": "2.0", "id": 11, "method": "tools/call", "params": {"name": "inc" } } -- server -- { @@ -69,6 +81,31 @@ structured }, "name": "greet" }, + { + "inputSchema": { + "type": "object", + "properties": { + "x": { + "type": "integer", + "default": 6 + } + }, + "additionalProperties": false + }, + "name": "inc", + "outputSchema": { + "type": "object", + "required": [ + "y" + ], + "properties": { + "y": { + "type": "integer" + } + }, + "additionalProperties": false + } + }, { "inputSchema": { "type": "object", @@ -97,6 +134,33 @@ structured }, "additionalProperties": false } + }, + { + "inputSchema": { + "type": "object", + "required": [ + "Now" + ], + "properties": { + "Now": { + "type": "string" + } + }, + "additionalProperties": false + }, + "name": "tomorrow", + "outputSchema": { + "type": "object", + "required": [ + "Tomorrow" + ], + "properties": { + "Tomorrow": { + "type": "string" + } + }, + "additionalProperties": false + } } ] } @@ -155,3 +219,64 @@ structured } } } +{ + "jsonrpc": "2.0", + "id": 8, + "error": { + "code": -32602, + "message": "invalid params: validating \"arguments\": validating root: required: missing properties: [\"In\"]" + } +} +{ + "jsonrpc": "2.0", + "id": 9, + "result": { + "content": [ + { + "type": "text", + "text": "{\"Tomorrow\":\"2025-06-19T15:04:05Z\"}" + } + ], + "structuredContent": { + "Tomorrow": "2025-06-19T15:04:05Z" + } + } +} +{ + "jsonrpc": "2.0", + "id": 10, + "error": { + "code": -32602, + "message": "invalid params: validating \"arguments\": validating root: required: missing properties: [\"Name\"]" + } +} +{ + "jsonrpc": "2.0", + "id": 11, + "result": { + "content": [ + { + "type": "text", + "text": "{\"y\":4}" + } + ], + "structuredContent": { + "y": 4 + } + } +} +{ + "jsonrpc": "2.0", + "id": 11, + "result": { + "content": [ + { + "type": "text", + "text": "{\"y\":7}" + } + ], + "structuredContent": { + "y": 7 + } + } +} diff --git a/mcp/tool.go b/mcp/tool.go index ffccbf3..12b02b7 100644 --- a/mcp/tool.go +++ b/mcp/tool.go @@ -5,7 +5,6 @@ package mcp import ( - "bytes" "context" "encoding/json" "fmt" @@ -61,41 +60,44 @@ type serverTool struct { handler ToolHandler } -// unmarshalSchema unmarshals data into v and validates the result according to -// the given resolved schema. -func unmarshalSchema(data json.RawMessage, resolved *jsonschema.Resolved, v any) error { +// applySchema validates whether data is valid JSON according to the provided +// schema, after applying schema defaults. +// +// Returns the JSON value augmented with defaults. +func applySchema(data json.RawMessage, resolved *jsonschema.Resolved) (json.RawMessage, error) { // TODO: use reflection to create the struct type to unmarshal into. // Separate validation from assignment. - // Disallow unknown fields. - // Otherwise, if the tool was built with a struct, the client could send extra - // fields and json.Unmarshal would ignore them, so the schema would never get - // a chance to declare the extra args invalid. - dec := json.NewDecoder(bytes.NewReader(data)) - dec.DisallowUnknownFields() - if err := dec.Decode(v); err != nil { - return fmt.Errorf("unmarshaling: %w", err) - } - return validateSchema(resolved, v) -} - -func validateSchema(resolved *jsonschema.Resolved, value any) error { + // Use default JSON marshalling for validation. + // + // This avoids inconsistent representation due to custom marshallers, such as + // time.Time (issue #449). + // + // Additionally, unmarshalling into a map ensures that the resulting JSON is + // at least {}, even if data is empty. For example, arguments is technically + // an optional property of callToolParams, and we still want to apply the + // defaults in this case. + // + // TODO(rfindley): in which cases can resolved be nil? if resolved != nil { - if err := resolved.ApplyDefaults(value); err != nil { - return fmt.Errorf("applying defaults from \n\t%s\nto\n\t%v:\n%w", schemaJSON(resolved.Schema()), value, err) + v := make(map[string]any) + if len(data) > 0 { + if err := json.Unmarshal(data, &v); err != nil { + return nil, fmt.Errorf("unmarshaling arguments: %w", err) + } } - if err := resolved.Validate(value); err != nil { - return fmt.Errorf("validating\n\t%v\nagainst\n\t %s:\n %w", value, schemaJSON(resolved.Schema()), err) + if err := resolved.ApplyDefaults(&v); err != nil { + return nil, fmt.Errorf("applying schema defaults:\n%w", err) + } + if err := resolved.Validate(&v); err != nil { + return nil, err + } + // We must re-marshal with the default values applied. + var err error + data, err = json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("marshalling with defaults: %v", err) } } - return nil -} - -// schemaJSON returns the JSON value for s as a string, or a string indicating an error. -func schemaJSON(s *jsonschema.Schema) string { - m, err := json.Marshal(s) - if err != nil { - return fmt.Sprintf("", err) - } - return string(m) + return data, nil } diff --git a/mcp/tool_test.go b/mcp/tool_test.go index 2722a9a..ef26e9d 100644 --- a/mcp/tool_test.go +++ b/mcp/tool_test.go @@ -17,7 +17,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" ) -func TestUnmarshalSchema(t *testing.T) { +func TestApplySchema(t *testing.T) { schema := &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ @@ -39,20 +39,23 @@ func TestUnmarshalSchema(t *testing.T) { want any }{ {`{"x": 1}`, new(S), &S{X: 1}}, - {`{}`, new(S), &S{X: 3}}, // default applied - {`{"x": 0}`, new(S), &S{X: 3}}, // FAIL: should be 0. (requires double unmarshal) + {`{}`, new(S), &S{X: 3}}, // default applied + {`{"x": 0}`, new(S), &S{X: 0}}, {`{"x": 1}`, new(map[string]any), &map[string]any{"x": 1.0}}, {`{}`, new(map[string]any), &map[string]any{"x": 3.0}}, // default applied {`{"x": 0}`, new(map[string]any), &map[string]any{"x": 0.0}}, } { raw := json.RawMessage(tt.data) - if err := unmarshalSchema(raw, resolved, tt.v); err != nil { + raw, err = applySchema(raw, resolved) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &tt.v); err != nil { t.Fatal(err) } if !reflect.DeepEqual(tt.v, tt.want) { t.Errorf("got %#v, want %#v", tt.v, tt.want) } - } } From 159933cfa36628d1b2b6704152776356e51cb522 Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Fri, 12 Sep 2025 14:42:07 +0000 Subject: [PATCH 045/418] mcp: speed up TestCommandTransportDuration At 7s, this test was 70% of our test execution time on my computer. Speed it up by mutating the defaultTerminateDuration for the purposes of the test. Also update our test workflow to use go1.25, now that it's out. --- .github/workflows/test.yml | 2 +- mcp/cmd.go | 4 +--- mcp/cmd_export_test.go | 20 ++++++++++++++++++++ mcp/cmd_test.go | 24 ++++++++++++++++++------ 4 files changed, 40 insertions(+), 10 deletions(-) create mode 100644 mcp/cmd_export_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d8e2d31..9ad50c1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,7 +39,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - go: ["1.23", "1.24", "1.25.0-rc.3"] + go: ["1.23", "1.24", "1.25"] steps: - name: Check out code uses: actions/checkout@v4 diff --git a/mcp/cmd.go b/mcp/cmd.go index 55e5cca..b531eaf 100644 --- a/mcp/cmd.go +++ b/mcp/cmd.go @@ -13,9 +13,7 @@ import ( "time" ) -const ( - defaultTerminateDuration = 5 * time.Second -) +var defaultTerminateDuration = 5 * time.Second // mutable for testing // A CommandTransport is a [Transport] that runs a command and communicates // with it over stdin/stdout, using newline-delimited JSON. diff --git a/mcp/cmd_export_test.go b/mcp/cmd_export_test.go new file mode 100644 index 0000000..331e8bd --- /dev/null +++ b/mcp/cmd_export_test.go @@ -0,0 +1,20 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +import "time" + +// This file exports some helpers for mutating internals of the command +// transport for testing. + +// SetDefaultTerminateDuration sets the default command terminate duration, +// and returns a function to reset it to the default. +func SetDefaultTerminateDuration(d time.Duration) (reset func()) { + initial := defaultTerminateDuration + defaultTerminateDuration = d + return func() { + defaultTerminateDuration = initial + } +} diff --git a/mcp/cmd_test.go b/mcp/cmd_test.go index 146cbe1..0df4570 100644 --- a/mcp/cmd_test.go +++ b/mcp/cmd_test.go @@ -257,25 +257,35 @@ func TestCommandTransportTerminateDuration(t *testing.T) { } requireExec(t) + // Unfortunately, since it does I/O, this test needs to rely on timing (we + // can't use synctest). However, we can still decreate the default + // termination duration to speed up the test. + const defaultDur = 50 * time.Millisecond + defer mcp.SetDefaultTerminateDuration(defaultDur)() + tests := []struct { name string duration time.Duration + wantMinDuration time.Duration wantMaxDuration time.Duration }{ { name: "default duration (zero)", duration: 0, - wantMaxDuration: 6 * time.Second, // default 5s + buffer + wantMinDuration: defaultDur, + wantMaxDuration: 1 * time.Second, // default + buffer }, { name: "below minimum duration", - duration: 500 * time.Millisecond, - wantMaxDuration: 6 * time.Second, // should use default 5s + buffer + duration: -500 * time.Millisecond, + wantMinDuration: defaultDur, + wantMaxDuration: 1 * time.Second, // should use default + buffer }, { name: "custom valid duration", - duration: 2 * time.Second, - wantMaxDuration: 3 * time.Second, // custom 2s + buffer + duration: 200 * time.Millisecond, + wantMinDuration: 200 * time.Millisecond, + wantMaxDuration: 1 * time.Second, // custom + buffer }, } @@ -306,7 +316,9 @@ func TestCommandTransportTerminateDuration(t *testing.T) { t.Fatalf("Close() failed with unexpected error: %v", err) } } - + if elapsed < tt.wantMinDuration { + t.Errorf("Close() took %v, expected at least %v", elapsed, tt.wantMinDuration) + } if elapsed > tt.wantMaxDuration { t.Errorf("Close() took %v, expected at most %v", elapsed, tt.wantMaxDuration) } From c2c810bf40eb390df87680736fa9efd642b399dc Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Fri, 12 Sep 2025 15:09:51 +0000 Subject: [PATCH 046/418] mcp: update jsonschema-go to v0.2.3, prepare README --- README.md | 4 ++-- go.mod | 2 +- go.sum | 2 ++ internal/readme/README.src.md | 4 ++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d8bad49..2888b74 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -# MCP Go SDK v0.4.0 +# MCP Go SDK v0.5.0 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/modelcontextprotocol/go-sdk) @@ -7,7 +7,7 @@ This version contains breaking changes. See the [release notes]( -https://github.com/modelcontextprotocol/go-sdk/releases/tag/v0.4.0) for details. +https://github.com/modelcontextprotocol/go-sdk/releases/tag/v0.5.0) for details. [![PkgGoDev](https://pkg.go.dev/badge/github.com/modelcontextprotocol/go-sdk)](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) diff --git a/go.mod b/go.mod index d303ef0..5691089 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.23.0 require ( github.com/google/go-cmp v0.7.0 - github.com/google/jsonschema-go v0.2.3-0.20250911201137-bbdc431016d2 + github.com/google/jsonschema-go v0.2.3 github.com/yosida95/uritemplate/v3 v3.0.2 golang.org/x/tools v0.34.0 ) diff --git a/go.sum b/go.sum index 6903b65..d30dd34 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.2.3-0.20250911201137-bbdc431016d2 h1:IIj7X4SH1HKy0WfPR4nNEj4dhIJWGdXM5YoBAbfpdoo= github.com/google/jsonschema-go v0.2.3-0.20250911201137-bbdc431016d2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.2.3 h1:dkP3B96OtZKKFvdrUSaDkL+YDx8Uw9uC4Y+eukpCnmM= +github.com/google/jsonschema-go v0.2.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md index 5ac7cb8..45a57f6 100644 --- a/internal/readme/README.src.md +++ b/internal/readme/README.src.md @@ -1,4 +1,4 @@ -# MCP Go SDK v0.4.0 +# MCP Go SDK v0.5.0 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/modelcontextprotocol/go-sdk) @@ -6,7 +6,7 @@ This version contains breaking changes. See the [release notes]( -https://github.com/modelcontextprotocol/go-sdk/releases/tag/v0.4.0) for details. +https://github.com/modelcontextprotocol/go-sdk/releases/tag/v0.5.0) for details. [![PkgGoDev](https://pkg.go.dev/badge/github.com/modelcontextprotocol/go-sdk)](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) From ab4d6218c6cecebc9e008fe94a6bff3106e91995 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Tue, 16 Sep 2025 07:58:37 -0400 Subject: [PATCH 047/418] mcp: tweak ProgressNotificationParams --- mcp/protocol.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mcp/protocol.go b/mcp/protocol.go index aeb9adb..7be8ea1 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -625,15 +625,16 @@ type ProgressNotificationParams struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` + // The progress token which was given in the initial request, used to associate + // this notification with the request that is proceeding. + ProgressToken any `json:"progressToken"` // An optional message describing the current progress. Message string `json:"message,omitempty"` // The progress thus far. This should increase every time progress is made, even // if the total is unknown. Progress float64 `json:"progress"` - // The progress token which was given in the initial request, used to associate - // this notification with the request that is proceeding. - ProgressToken any `json:"progressToken"` // Total number of items to process (or total progress required), if known. + // Zero means unknown. Total float64 `json:"total,omitempty"` } From ac2f1752141b9ab81c8c26da375131aa779ef52a Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Tue, 16 Sep 2025 11:13:40 -0400 Subject: [PATCH 048/418] docs: add feature and troubleshooting documentation (#463) Add a framework for feature documentation, and start populating it with our SDK documentation. This framework is as follows: - internal/docs/**.src.md is the markdown source for the docs/ directory. - The x/example/internal/cmd/weave tool is used to compile these docs to the top-level docs, supporting both linked code samples and generated tables of contents. - The readme-check workflow is updated to check these docs as well. - The structure of these docs follows the MCP spec. - Wherever possible, example code is linked from actual Go documentation examples, so that it is testable. Some minor modifications to the weave tool were made to support this framework. Additionally, partially fill out this documentation with content on base protocol and client features, as well as troubleshooting help. Along the way, a bug was encountered that our LoggingTransport was not concurrency safe. This is fixed with a mutex. Fixes https://github.com/modelcontextprotocol/go-sdk/issues/466 Fixes https://github.com/modelcontextprotocol/go-sdk/issues/409 Updates https://github.com/modelcontextprotocol/go-sdk/issues/442 --- .github/workflows/readme-check.yml | 20 +- docs/README.md | 40 +++ docs/client.md | 168 ++++++++++++ docs/protocol.md | 368 +++++++++++++++++++++++++++ docs/server.md | 38 +++ docs/troubleshooting.md | 90 +++++++ internal/docs/README.src.md | 39 +++ internal/docs/client.src.md | 55 ++++ internal/docs/doc.go | 15 ++ internal/docs/protocol.src.md | 198 ++++++++++++++ internal/docs/server.src.md | 31 +++ internal/docs/troubleshooting.src.md | 42 +++ internal/readme/client/client.go | 2 +- mcp/client.go | 17 +- mcp/client_example_test.go | 136 ++++++++++ mcp/mcp_example_test.go | 166 ++++++++++++ mcp/mcp_test.go | 8 +- mcp/root.go | 5 - mcp/streamable.go | 16 +- mcp/streamable_example_test.go | 86 +++++++ mcp/transport.go | 16 +- mcp/transport_example_test.go | 40 +++ 22 files changed, 1566 insertions(+), 30 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/client.md create mode 100644 docs/protocol.md create mode 100644 docs/server.md create mode 100644 docs/troubleshooting.md create mode 100644 internal/docs/README.src.md create mode 100644 internal/docs/client.src.md create mode 100644 internal/docs/doc.go create mode 100644 internal/docs/protocol.src.md create mode 100644 internal/docs/server.src.md create mode 100644 internal/docs/troubleshooting.src.md create mode 100644 mcp/client_example_test.go create mode 100644 mcp/mcp_example_test.go delete mode 100644 mcp/root.go create mode 100644 mcp/streamable_example_test.go create mode 100644 mcp/transport_example_test.go diff --git a/.github/workflows/readme-check.yml b/.github/workflows/readme-check.yml index bed3ff4..8709be1 100644 --- a/.github/workflows/readme-check.yml +++ b/.github/workflows/readme-check.yml @@ -1,11 +1,13 @@ name: README Check on: - workflow_dispatch: + workflow_dispatch: pull_request: paths: - 'internal/readme/**' - 'README.md' - + - 'internal/docs/**' + - 'docs/**' + permissions: contents: read @@ -17,15 +19,15 @@ jobs: uses: actions/setup-go@v5 - name: Check out code uses: actions/checkout@v4 - - name: Check README is up-to-date + - name: Check docs is up-to-date run: | - go generate ./internal/readme + go generate ./... if [ -n "$(git status --porcelain)" ]; then - echo "ERROR: README.md is not up-to-date!" + echo "ERROR: docs are not up-to-date!" echo "" - echo "The README.md file differs from what would be generated by `go generate ./internal/readme`." - echo "Please update internal/readme/README.src.md instead of README.md directly," - echo "then run `go generate ./internal/readme` to regenerate README.md." + echo "The docs differ from what would be generated by `go generate ./...`." + echo "Please update internal/**/*.src.md instead of directly editing README.md or docs/ files," + echo "then run `go generate ./...` to regenerate docs." echo "" echo "Changes:" git status --porcelain @@ -34,4 +36,4 @@ jobs: git diff exit 1 fi - echo "README.md is up-to-date" + echo "Docs are up-to-date." diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..81e7f5f --- /dev/null +++ b/docs/README.md @@ -0,0 +1,40 @@ + +These docs are a work-in-progress. + +# Features + +These docs mirror the [official MCP spec](https://modelcontextprotocol.io/specification/2025-06-18). + +## Base Protocol + +1. [Lifecycle (Clients, Servers, and Sessions)](protocol.md#lifecycle). +1. [Transports](protocol.md#transports) + 1. [Stdio transport](protocol.md#stdio-transport) + 1. [Streamable transport](protocol.md#streamable-transport) + 1. [Custom transports](protocol.md#stateless-mode) +1. [Authorization](protocol.md#authorization) +1. [Security](protocol.md#security) +1. [Utilities](protocol.md#utilities) + 1. [Cancellation](utilities.md#cancellation) + 1. [Ping](utilities.md#ping) + 1. [Progress](utilities.md#progress) + +## Client Features + +1. [Roots](client.md#roots) +1. [Sampling](client.md#sampling) +1. [Elicitation](clients.md#elicitation) + +## Server Features + +1. [Prompts](server.md#prompts) +1. [Resources](server.md#resources) +1. [Tools](tools.md) +1. [Utilities](server.md#utilities) + 1. [Completion](server.md#completion) + 1. [Logging](server.md#logging) + 1. [Pagination](server.md#pagination) + +# TroubleShooting + +See [troubleshooting.md](troubleshooting.md) for a troubleshooting guide. diff --git a/docs/client.md b/docs/client.md new file mode 100644 index 0000000..13c12d5 --- /dev/null +++ b/docs/client.md @@ -0,0 +1,168 @@ + +# Support for MCP client features + +1. [Roots](#roots) +1. [Sampling](#sampling) +1. [Elicitation](#elicitation) + +## Roots + +MCP allows clients to specify a set of filesystem +["roots"](https://modelcontextprotocol.io/specification/2025-06-18/client/roots). +The SDK supports this as follows: + +**Client-side**: The SDK client always has the `roots.listChanged` capability. +To add roots to a client, use the +[`Client.AddRoots`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#AddRoots) +and +[`Client.RemoveRoots`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Client.RemoveRoots) +methods. If any servers are already [connected](protocol.md#lifecycle) to the +client, a call to `AddRoot` or `RemoveRoots` will result in a +`notifications/roots/list_changed` notification to each connected server. + +**Server-side**: To query roots from the server, use the +[`ServerSession.ListRoots`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.ListRoots) +method. To receive notifications about root changes, set +[`ServerOptions.RootsListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.RootsListChangedHandler). + +```go +func Example_roots() { + ctx := context.Background() + + // Create a client with a single root. + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + c.AddRoots(&mcp.Root{URI: "file://a"}) + + // Now create a server with a handler to receive notifications about roots. + rootsChanged := make(chan struct{}) + handleRootsChanged := func(ctx context.Context, req *mcp.RootsListChangedRequest) { + rootList, err := req.Session.ListRoots(ctx, nil) + if err != nil { + log.Fatal(err) + } + var roots []string + for _, root := range rootList.Roots { + roots = append(roots, root.URI) + } + fmt.Println(roots) + close(rootsChanged) + } + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ + RootsListChangedHandler: handleRootsChanged, + }) + + // Connect the server and client... + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + if _, err := c.Connect(ctx, t2, nil); err != nil { + log.Fatal(err) + } + + // ...and add a root. The server is notified about the change. + c.AddRoots(&mcp.Root{URI: "file://b"}) + <-rootsChanged + // Output: [file://a file://b] +} +``` + +## Sampling + +[Sampling](https://modelcontextprotocol.io/specification/2025-06-18/client/sampling) +is a way for servers to leverage the client's AI capabilities. It is +implemented in the SDK as follows: + +**Client-side**: To add the `sampling` capability to a client, set +[`ClientOptions.CreateMessageHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.CreateMessageHandler). +This function is invoked whenever the server requests sampling. + +**Server-side**: To use sampling from the server, call +[`ServerSession.CreateMessage`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.CreateMessage). + +```go +func Example_sampling() { + ctx := context.Background() + + // Create a client with a sampling handler. + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + CreateMessageHandler: func(_ context.Context, req *mcp.CreateMessageRequest) (*mcp.CreateMessageResult, error) { + return &mcp.CreateMessageResult{ + Content: &mcp.TextContent{ + Text: "would have created a message", + }, + }, nil + }, + }) + + // Connect the server and client... + ct, st := mcp.NewInMemoryTransports() + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + session, err := s.Connect(ctx, st, nil) + if err != nil { + log.Fatal(err) + } + defer session.Close() + + if _, err := c.Connect(ctx, ct, nil); err != nil { + log.Fatal(err) + } + + msg, err := session.CreateMessage(ctx, &mcp.CreateMessageParams{}) + if err != nil { + log.Fatal(err) + } + fmt.Println(msg.Content.(*mcp.TextContent).Text) + // Output: would have created a message +} +``` + +## Elicitation + +[Elicitation](https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation) +allows servers to request user inputs. It is implemented in the SDK as follows: + +**Client-side**: To add the `elicitation` capability to a client, set +[`ClientOptions.ElicitationHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ElicitationHandler). +The elicitation handler must return a result that matches the requested schema; +otherwise, elicitation returns an error. + +**Server-side**: To use elicitation from the server, call +[`ServerSession.Elicit`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.Elicit). + +```go +func Example_elicitation() { + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + log.Fatal(err) + } + defer ss.Close() + + c := mcp.NewClient(testImpl, &mcp.ClientOptions{ + ElicitationHandler: func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"test": "value"}}, nil + }, + }) + if _, err := c.Connect(ctx, ct, nil); err != nil { + log.Fatal(err) + } + res, err := ss.Elicit(ctx, &mcp.ElicitParams{ + Message: "This should fail", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "test": {Type: "string"}, + }, + }, + }) + if err != nil { + log.Fatal(err) + } + fmt.Println(res.Content["test"]) + // Output: value +} +``` diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..dbc4c1c --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,368 @@ + +# Support for the MCP base protocol + +1. [Lifecycle](#lifecycle) +1. [Transports](#transports) + 1. [Stdio Transport](#stdio-transport) + 1. [Streamable Transport](#streamable-transport) + 1. [Custom transports](#custom-transports) + 1. [Concurrency](#concurrency) +1. [Authorization](#authorization) +1. [Security](#security) +1. [Utilities](#utilities) + 1. [Cancellation](#cancellation) + 1. [Ping](#ping) + 1. [Progress](#progress) + +## Lifecycle + +The SDK provides an API for defining both MCP clients and servers, and +connecting them over various transports. When a client and server are +connected, it creates a logical session, which follows the MCP spec's +[lifecycle](https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle). + +In this SDK, both a +[`Client`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Client) +and +[`Server`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server) +can handle multiple peers. Every time a new peer is connected, it creates a new +session. + +- A `Client` is a logical MCP client, configured with various + [`ClientOptions`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions). +- When a client is connected to a server using + [`Client.Connect`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Client.Connect), + it creates a + [`ClientSession`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession). + This session is initialized during the `Connect` method, and provides methods + to communicate with the server peer. +- A `Server` is a logical MCP server, configured with various + [`ServerOptions`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions). +- When a server is connected to a client using + [`Server.Connect`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.Connect), + it creates a + [`ServerSession`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession). + This session is not initialized until the client sends the + `notifications/initialized` message. Use `ServerOptions.InitializedHandler` + to listen for this event, or just use the session through various feature + handlers (such as a `ToolHandler`). Requests to the server are rejected until + the client has initialized the session. + +Both `ClientSession` and `ServerSession` have a `Close` method to terminate the +session, and a `Wait` method to await session termination by the peer. Typically, +it is the client's responsibility to end the session. + +```go +func Example_lifecycle() { + ctx := context.Background() + + // Create a client and server. + // Wait for the client to initialize the session. + client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ + InitializedHandler: func(context.Context, *mcp.InitializedRequest) { + fmt.Println("initialized!") + }, + }) + + // Connect the server and client using in-memory transports. + // + // Connect the server first so that it's ready to receive initialization + // messages from the client. + t1, t2 := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(ctx, t1, nil) + if err != nil { + log.Fatal(err) + } + clientSession, err := client.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + + // Now shut down the session by closing the client, and waiting for the + // server session to end. + if err := clientSession.Close(); err != nil { + log.Fatal(err) + } + if err := serverSession.Wait(); err != nil { + log.Fatal(err) + } + // Output: initialized! +} +``` + +## Transports + +A +[transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports) +can be used to send JSON-RPC messages from client to server, or vice-versa. + +In the SDK, this is achieved by implementing the +[`Transport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Transport) +interface, which creates a (logical) bidirectional stream of JSON-RPC messages. +Most transport implementations described below are specific to either the +client or server: a "client transport" is something that can be used to connect +a client to a server, and a "server transport" is something that can be used to +connect a server to a client. However, it's possible for a transport to be both +a client and server transport, such as the `InMemoryTransport` used in the +lifecycle example above. + +Transports should not be reused for multiple connections: if you need to create +multiple connections, use different transports. + +### Stdio Transport + +In the +[`stdio`](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio) +transport clients communicate with an MCP server running in a subprocess using +newline-delimited JSON over its stdin/stdout. + +**Client-side**: the client side of the `stdio` transport is implemented by +[`CommandTransport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CommandTransport), +which starts the a `exec.Cmd` as a subprocess and communicates over its +stdin/stdout. + +**Server-side**: the server side of the `stdio` transport is implemented by +`StdioTransport`, which connects over the current processes `os.Stdin` and +`os.Stdout`. + +### Streamable Transport + +The [streamable +transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) +API is implemented across three types: + +- `StreamableHTTPHandler`: an`http.Handler` that serves streamable MCP + sessions. +- `StreamableServerTransport`: a `Transport` that implements the server side of + the streamable transport. +- `StreamableClientTransport`: a `Transport` that implements the client side of + the streamable transport. + +To create a streamable MCP server, you create a `StreamableHTTPHandler` and +pass it an `mcp.Server`: + +```go +func ExampleStreamableHTTPHandler() { + // Create a new streamable handler, using the same MCP server for every request. + // + // Here, we configure it to serves application/json responses rather than + // text/event-stream, just so the output below doesn't use random event ids. + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.1.0"}, nil) + handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { + return server + }, &mcp.StreamableHTTPOptions{JSONResponse: true}) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + // The SDK is currently permissive of some missing keys in "params". + resp := mustPostMessage(`{"jsonrpc": "2.0", "id": 1, "method":"initialize", "params": {}}`, httpServer.URL) + fmt.Println(resp) + // Output: + // {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-06-18","serverInfo":{"name":"server","version":"v0.1.0"}}} +} +``` + +The `StreamableHTTPHandler` handles the HTTP requests and creates a new +`StreamableServerTransport` for each new session. The transport is then used to +communicate with the client. + +On the client side, you create a `StreamableClientTransport` and use it to +connect to the server: + +```go +transport := &mcp.StreamableClientTransport{ + Endpoint: "http://localhost:8080/mcp", +} +client, err := mcp.Connect(ctx, transport, &mcp.ClientOptions{...}) +``` + +The `StreamableClientTransport` handles the HTTP requests and communicates with +the server using the streamable transport protocol. + +#### Stateless Mode + + + +#### Sessionless mode + + + +### Custom transports + + + +### Concurrency + +In general, MCP offers no guarantees about concurrency semantics: if a client +or server sends a notification, the spec says nothing about when the peer +observes that notification relative to other request. However, the Go SDK +implements the following heuristics: + +- If a notifying method (such as `notifications/progress` or + `notifications/initialized`) returns, then it is guaranteed that the peer + observes that notification before other notifications or calls from the same + client goroutine. +- Calls (such as `tools/call`) are handled asynchronously with respect to + each other. + +See +[modelcontextprotocol/go-sdk#26](https://github.com/modelcontextprotocol/go-sdk/issues/26) +for more background. + +## Authorization + + + +## Security + + + +## Utilities + +### Cancellation + +Cancellation is implemented with context cancellation. Cancelling a context +used in a method on `ClientSession` or `ServerSession` will terminate the RPC +and send a "notifications/cancelled" message to the peer. + +When an RPC exits due to a cancellation error, there's a guarantee that the +cancellation notification has been sent, but there's no guarantee that the +server has observed it (see [concurrency](#concurrency)). + +```go +func Example_cancellation() { + // For this example, we're going to be collecting observations from the + // server and client. + var clientResult, serverResult string + var wg sync.WaitGroup + wg.Add(2) + + // Create a server with a single slow tool. + // When the client cancels its request, the server should observe + // cancellation. + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + started := make(chan struct{}, 1) // signals that the server started handling the tool call + mcp.AddTool(server, &mcp.Tool{Name: "slow"}, func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + started <- struct{}{} + defer wg.Done() + select { + case <-time.After(5 * time.Second): + serverResult = "tool done" + case <-ctx.Done(): + serverResult = "tool canceled" + } + return &mcp.CallToolResult{}, nil, nil + }) + + // Connect a client to the server. + client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + ctx := context.Background() + t1, t2 := mcp.NewInMemoryTransports() + if _, err := server.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + session, err := client.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer session.Close() + + // Make a tool call, asynchronously. + ctx, cancel := context.WithCancel(context.Background()) + go func() { + defer wg.Done() + _, err = session.CallTool(ctx, &mcp.CallToolParams{Name: "slow"}) + clientResult = fmt.Sprintf("%v", err) + }() + + // As soon as the server has started handling the call, cancel it from the + // client side. + <-started + cancel() + wg.Wait() + + fmt.Println(clientResult) + fmt.Println(serverResult) + // Output: + // context canceled + // tool canceled +} +``` + +### Ping + +[Ping](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/ping) +support is symmetrical for client and server. + +To initiate a ping, call +[`ClientSession.Ping`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Ping) +or +[`ServerSession.Ping`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.Ping). + +To have the client or server session automatically ping its peer, and close the +session if the ping fails, set +[`ClientOptions.KeepAlive`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.KeepAlive) +or +[`ServerOptions.KeepAlive`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.KeepAlive). + +### Progress + +[Progress](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress) +reporting is possible by reading the progress token from request metadata and +calling either +[`ClientSession.NotifyProgress`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.NotifyProgress) +or +[`ServerSession.NotifyProgress`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.NotifyProgress). +To listen to progress notifications, set +[`ClientOptions.ProgressNotificationHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ProgressNotificationHandler) +or +[`ServerOptions.ProgressNotificationHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.ProgressNotificationHandler). + +Issue #460 discusses some potential ergonomic improvements to this API. + +```go +func Example_progress() { + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(server, &mcp.Tool{Name: "makeProgress"}, func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + if token := req.Params.GetProgressToken(); token != nil { + for i := range 3 { + params := &mcp.ProgressNotificationParams{ + Message: "frobbing widgets", + ProgressToken: token, + Progress: float64(i), + Total: 2, + } + req.Session.NotifyProgress(ctx, params) // ignore error + } + } + return &mcp.CallToolResult{}, nil, nil + }) + client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + ProgressNotificationHandler: func(_ context.Context, req *mcp.ProgressNotificationClientRequest) { + fmt.Printf("%s %.0f/%.0f\n", req.Params.Message, req.Params.Progress, req.Params.Total) + }, + }) + ctx := context.Background() + t1, t2 := mcp.NewInMemoryTransports() + if _, err := server.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + + session, err := client.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer session.Close() + if _, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "makeProgress", + Meta: mcp.Meta{"progressToken": "abc123"}, + }); err != nil { + log.Fatal(err) + } + // Output: + // frobbing widgets 0/2 + // frobbing widgets 1/2 + // frobbing widgets 2/2 +} +``` diff --git a/docs/server.md b/docs/server.md new file mode 100644 index 0000000..5b17f2e --- /dev/null +++ b/docs/server.md @@ -0,0 +1,38 @@ + +# Support for MCP server features + +1. [Prompts](#prompts) +1. [Resources](#resources) +1. [Tools](#tools) +1. [Utilities](#utilities) + 1. [Completion](#completion) + 1. [Logging](#logging) + 1. [Pagination](#pagination) + +## Prompts + + + +## Resources + + + +## Tools + + + +## Utilities + + + +### Completion + + + +### Logging + + + +### Pagination + + diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..c0f021b --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,90 @@ + +# Troubleshooting + +The Model Context Protocol is a complicated spec that leaves some room for +interpretation. Client and server SDKs can behave differently, or can be more +or less strict about their inputs. And of course, bugs happen. + +When you encounter a problem using the Go SDK, these instructions can help +collect information that will be useful in debugging. Please try to provide +this information in a bug report, so that maintainers can more quickly +understand what's going wrong. + +And most of all, please do [file bugs](https://github.com/modelcontextprotocol/go-sdk/issues/new?template=bug_report.md). + +## Using the MCP inspector + +To debug an MCP server, you can use the [MCP +inspector](https://modelcontextprotocol.io/legacy/tools/inspector). This is +useful for testing your server and verifying that it works with the typescript +SDK, as well as inspecting MCP traffic. + +## Collecting MCP logs + +For [stdio](protocol.md#stdio-transport) transport connections, you can also +inspect MCP traffic using a `LoggingTransport`: + +```go +func ExampleLoggingTransport() { + ctx := context.Background() + t1, t2 := mcp.NewInMemoryTransports() + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + if _, err := server.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + + client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + var b bytes.Buffer + logTransport := &mcp.LoggingTransport{Transport: t2, Writer: &b} + if _, err := client.Connect(ctx, logTransport, nil); err != nil { + log.Fatal(err) + } + fmt.Println(b.String()) + // Output: + // write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{"roots":{"listChanged":true}},"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-06-18"}} + // read: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-06-18","serverInfo":{"name":"server","version":"v0.0.1"}}} + // write: {"jsonrpc":"2.0","method":"notifications/initialized","params":{}} + +} +``` + +That example uses a `bytes.Buffer`, but you can also log to a file, or to +`os.Stderr`. + +## Inspecting HTTP traffic + +There are a couple different ways to investigate traffic to an HTTP transport +([streamable](protocol.md#streamable-transport) or legacy SSE). + +The first is to use an HTTP middleware: + +```go +func ExampleStreamableHTTPHandler_middleware() { + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.1.0"}, nil) + handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { + return server + }, nil) + loggingHandler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + // Example debugging; you could also capture the response. + body, err := io.ReadAll(req.Body) + if err != nil { + log.Fatal(err) + } + req.Body.Close() // ignore error + req.Body = io.NopCloser(bytes.NewBuffer(body)) + fmt.Println(req.Method, string(body)) + handler.ServeHTTP(w, req) + }) + httpServer := httptest.NewServer(loggingHandler) + defer httpServer.Close() + + // The SDK is currently permissive of some missing keys in "params". + mustPostMessage(`{"jsonrpc": "2.0", "id": 1, "method":"initialize", "params": {}}`, httpServer.URL) + // Output: + // POST {"jsonrpc": "2.0", "id": 1, "method":"initialize", "params": {}} +} +``` + +The second is to use a general purpose tool to inspect http traffic, such as +[wireshark](https://www.wireshark.org/) or +[tcpdump](https://linux.die.net/man/8/tcpdump). diff --git a/internal/docs/README.src.md b/internal/docs/README.src.md new file mode 100644 index 0000000..fb600df --- /dev/null +++ b/internal/docs/README.src.md @@ -0,0 +1,39 @@ +These docs are a work-in-progress. + +# Features + +These docs mirror the [official MCP spec](https://modelcontextprotocol.io/specification/2025-06-18). + +## Base Protocol + +1. [Lifecycle (Clients, Servers, and Sessions)](protocol.md#lifecycle). +1. [Transports](protocol.md#transports) + 1. [Stdio transport](protocol.md#stdio-transport) + 1. [Streamable transport](protocol.md#streamable-transport) + 1. [Custom transports](protocol.md#stateless-mode) +1. [Authorization](protocol.md#authorization) +1. [Security](protocol.md#security) +1. [Utilities](protocol.md#utilities) + 1. [Cancellation](utilities.md#cancellation) + 1. [Ping](utilities.md#ping) + 1. [Progress](utilities.md#progress) + +## Client Features + +1. [Roots](client.md#roots) +1. [Sampling](client.md#sampling) +1. [Elicitation](clients.md#elicitation) + +## Server Features + +1. [Prompts](server.md#prompts) +1. [Resources](server.md#resources) +1. [Tools](tools.md) +1. [Utilities](server.md#utilities) + 1. [Completion](server.md#completion) + 1. [Logging](server.md#logging) + 1. [Pagination](server.md#pagination) + +# TroubleShooting + +See [troubleshooting.md](troubleshooting.md) for a troubleshooting guide. diff --git a/internal/docs/client.src.md b/internal/docs/client.src.md new file mode 100644 index 0000000..f342719 --- /dev/null +++ b/internal/docs/client.src.md @@ -0,0 +1,55 @@ +# Support for MCP client features + +%toc + +## Roots + +MCP allows clients to specify a set of filesystem +["roots"](https://modelcontextprotocol.io/specification/2025-06-18/client/roots). +The SDK supports this as follows: + +**Client-side**: The SDK client always has the `roots.listChanged` capability. +To add roots to a client, use the +[`Client.AddRoots`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#AddRoots) +and +[`Client.RemoveRoots`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Client.RemoveRoots) +methods. If any servers are already [connected](protocol.md#lifecycle) to the +client, a call to `AddRoot` or `RemoveRoots` will result in a +`notifications/roots/list_changed` notification to each connected server. + +**Server-side**: To query roots from the server, use the +[`ServerSession.ListRoots`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.ListRoots) +method. To receive notifications about root changes, set +[`ServerOptions.RootsListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.RootsListChangedHandler). + +%include ../../mcp/client_example_test.go roots - + +## Sampling + +[Sampling](https://modelcontextprotocol.io/specification/2025-06-18/client/sampling) +is a way for servers to leverage the client's AI capabilities. It is +implemented in the SDK as follows: + +**Client-side**: To add the `sampling` capability to a client, set +[`ClientOptions.CreateMessageHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.CreateMessageHandler). +This function is invoked whenever the server requests sampling. + +**Server-side**: To use sampling from the server, call +[`ServerSession.CreateMessage`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.CreateMessage). + +%include ../../mcp/client_example_test.go sampling - + +## Elicitation + +[Elicitation](https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation) +allows servers to request user inputs. It is implemented in the SDK as follows: + +**Client-side**: To add the `elicitation` capability to a client, set +[`ClientOptions.ElicitationHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ElicitationHandler). +The elicitation handler must return a result that matches the requested schema; +otherwise, elicitation returns an error. + +**Server-side**: To use elicitation from the server, call +[`ServerSession.Elicit`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.Elicit). + +%include ../../mcp/client_example_test.go elicitation - diff --git a/internal/docs/doc.go b/internal/docs/doc.go new file mode 100644 index 0000000..7b23ad6 --- /dev/null +++ b/internal/docs/doc.go @@ -0,0 +1,15 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +//go:generate -command weave go run golang.org/x/example/internal/cmd/weave@latest +//go:generate weave -o ../../docs/README.md ./README.src.md +//go:generate weave -o ../../docs/protocol.md ./protocol.src.md +//go:generate weave -o ../../docs/client.md ./client.src.md +//go:generate weave -o ../../docs/server.md ./server.src.md +//go:generate weave -o ../../docs/troubleshooting.md ./troubleshooting.src.md + +// The doc package generates the documentation at /doc, via go:generate. +// +// Tests in this package are used for examples. +package docs diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md new file mode 100644 index 0000000..79b7241 --- /dev/null +++ b/internal/docs/protocol.src.md @@ -0,0 +1,198 @@ +# Support for the MCP base protocol + +%toc + +## Lifecycle + +The SDK provides an API for defining both MCP clients and servers, and +connecting them over various transports. When a client and server are +connected, it creates a logical session, which follows the MCP spec's +[lifecycle](https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle). + +In this SDK, both a +[`Client`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Client) +and +[`Server`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server) +can handle multiple peers. Every time a new peer is connected, it creates a new +session. + +- A `Client` is a logical MCP client, configured with various + [`ClientOptions`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions). +- When a client is connected to a server using + [`Client.Connect`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Client.Connect), + it creates a + [`ClientSession`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession). + This session is initialized during the `Connect` method, and provides methods + to communicate with the server peer. +- A `Server` is a logical MCP server, configured with various + [`ServerOptions`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions). +- When a server is connected to a client using + [`Server.Connect`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.Connect), + it creates a + [`ServerSession`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession). + This session is not initialized until the client sends the + `notifications/initialized` message. Use `ServerOptions.InitializedHandler` + to listen for this event, or just use the session through various feature + handlers (such as a `ToolHandler`). Requests to the server are rejected until + the client has initialized the session. + +Both `ClientSession` and `ServerSession` have a `Close` method to terminate the +session, and a `Wait` method to await session termination by the peer. Typically, +it is the client's responsibility to end the session. + +%include ../../mcp/mcp_example_test.go lifecycle - + +## Transports + +A +[transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports) +can be used to send JSON-RPC messages from client to server, or vice-versa. + +In the SDK, this is achieved by implementing the +[`Transport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Transport) +interface, which creates a (logical) bidirectional stream of JSON-RPC messages. +Most transport implementations described below are specific to either the +client or server: a "client transport" is something that can be used to connect +a client to a server, and a "server transport" is something that can be used to +connect a server to a client. However, it's possible for a transport to be both +a client and server transport, such as the `InMemoryTransport` used in the +lifecycle example above. + +Transports should not be reused for multiple connections: if you need to create +multiple connections, use different transports. + +### Stdio Transport + +In the +[`stdio`](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio) +transport clients communicate with an MCP server running in a subprocess using +newline-delimited JSON over its stdin/stdout. + +**Client-side**: the client side of the `stdio` transport is implemented by +[`CommandTransport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CommandTransport), +which starts the a `exec.Cmd` as a subprocess and communicates over its +stdin/stdout. + +**Server-side**: the server side of the `stdio` transport is implemented by +`StdioTransport`, which connects over the current processes `os.Stdin` and +`os.Stdout`. + +### Streamable Transport + +The [streamable +transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) +API is implemented across three types: + +- `StreamableHTTPHandler`: an`http.Handler` that serves streamable MCP + sessions. +- `StreamableServerTransport`: a `Transport` that implements the server side of + the streamable transport. +- `StreamableClientTransport`: a `Transport` that implements the client side of + the streamable transport. + +To create a streamable MCP server, you create a `StreamableHTTPHandler` and +pass it an `mcp.Server`: + +%include ../../mcp/streamable_example_test.go streamablehandler - + +The `StreamableHTTPHandler` handles the HTTP requests and creates a new +`StreamableServerTransport` for each new session. The transport is then used to +communicate with the client. + +On the client side, you create a `StreamableClientTransport` and use it to +connect to the server: + +```go +transport := &mcp.StreamableClientTransport{ + Endpoint: "http://localhost:8080/mcp", +} +client, err := mcp.Connect(ctx, transport, &mcp.ClientOptions{...}) +``` + +The `StreamableClientTransport` handles the HTTP requests and communicates with +the server using the streamable transport protocol. + +#### Stateless Mode + + + +#### Sessionless mode + + + +### Custom transports + + + +### Concurrency + +In general, MCP offers no guarantees about concurrency semantics: if a client +or server sends a notification, the spec says nothing about when the peer +observes that notification relative to other request. However, the Go SDK +implements the following heuristics: + +- If a notifying method (such as `notifications/progress` or + `notifications/initialized`) returns, then it is guaranteed that the peer + observes that notification before other notifications or calls from the same + client goroutine. +- Calls (such as `tools/call`) are handled asynchronously with respect to + each other. + +See +[modelcontextprotocol/go-sdk#26](https://github.com/modelcontextprotocol/go-sdk/issues/26) +for more background. + +## Authorization + + + +## Security + + + +## Utilities + +### Cancellation + +Cancellation is implemented with context cancellation. Cancelling a context +used in a method on `ClientSession` or `ServerSession` will terminate the RPC +and send a "notifications/cancelled" message to the peer. + +When an RPC exits due to a cancellation error, there's a guarantee that the +cancellation notification has been sent, but there's no guarantee that the +server has observed it (see [concurrency](#concurrency)). + +%include ../../mcp/mcp_example_test.go cancellation - + +### Ping + +[Ping](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/ping) +support is symmetrical for client and server. + +To initiate a ping, call +[`ClientSession.Ping`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Ping) +or +[`ServerSession.Ping`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.Ping). + +To have the client or server session automatically ping its peer, and close the +session if the ping fails, set +[`ClientOptions.KeepAlive`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.KeepAlive) +or +[`ServerOptions.KeepAlive`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.KeepAlive). + +### Progress + +[Progress](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress) +reporting is possible by reading the progress token from request metadata and +calling either +[`ClientSession.NotifyProgress`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.NotifyProgress) +or +[`ServerSession.NotifyProgress`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.NotifyProgress). +To listen to progress notifications, set +[`ClientOptions.ProgressNotificationHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ProgressNotificationHandler) +or +[`ServerOptions.ProgressNotificationHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.ProgressNotificationHandler). + +Issue #460 discusses some potential ergonomic improvements to this API. + +%include ../../mcp/mcp_example_test.go progress - diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md new file mode 100644 index 0000000..a131bcd --- /dev/null +++ b/internal/docs/server.src.md @@ -0,0 +1,31 @@ +# Support for MCP server features + +%toc + +## Prompts + + + +## Resources + + + +## Tools + + + +## Utilities + + + +### Completion + + + +### Logging + + + +### Pagination + + diff --git a/internal/docs/troubleshooting.src.md b/internal/docs/troubleshooting.src.md new file mode 100644 index 0000000..8334203 --- /dev/null +++ b/internal/docs/troubleshooting.src.md @@ -0,0 +1,42 @@ +# Troubleshooting + +The Model Context Protocol is a complicated spec that leaves some room for +interpretation. Client and server SDKs can behave differently, or can be more +or less strict about their inputs. And of course, bugs happen. + +When you encounter a problem using the Go SDK, these instructions can help +collect information that will be useful in debugging. Please try to provide +this information in a bug report, so that maintainers can more quickly +understand what's going wrong. + +And most of all, please do [file bugs](https://github.com/modelcontextprotocol/go-sdk/issues/new?template=bug_report.md). + +## Using the MCP inspector + +To debug an MCP server, you can use the [MCP +inspector](https://modelcontextprotocol.io/legacy/tools/inspector). This is +useful for testing your server and verifying that it works with the typescript +SDK, as well as inspecting MCP traffic. + +## Collecting MCP logs + +For [stdio](protocol.md#stdio-transport) transport connections, you can also +inspect MCP traffic using a `LoggingTransport`: + +%include ../../mcp/transport_example_test.go loggingtransport - + +That example uses a `bytes.Buffer`, but you can also log to a file, or to +`os.Stderr`. + +## Inspecting HTTP traffic + +There are a couple different ways to investigate traffic to an HTTP transport +([streamable](protocol.md#streamable-transport) or legacy SSE). + +The first is to use an HTTP middleware: + +%include ../../mcp/streamable_example_test.go httpmiddleware - + +The second is to use a general purpose tool to inspect http traffic, such as +[wireshark](https://www.wireshark.org/) or +[tcpdump](https://linux.die.net/man/8/tcpdump). diff --git a/internal/readme/client/client.go b/internal/readme/client/client.go index e2794f8..9f35796 100644 --- a/internal/readme/client/client.go +++ b/internal/readme/client/client.go @@ -44,4 +44,4 @@ func main() { } } -//!- +// !- diff --git a/mcp/client.go b/mcp/client.go index 1ed3b04..822566d 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -55,11 +55,15 @@ func NewClient(impl *Implementation, opts *ClientOptions) *Client { // ClientOptions configures the behavior of the client. type ClientOptions struct { - // Handler for sampling. - // Called when a server calls CreateMessage. + // CreateMessageHandler handles incoming requests for sampling/createMessage. + // + // Setting CreateMessageHandler to a non-nil value causes the client to + // advertise the sampling capability. CreateMessageHandler func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) - // Handler for elicitation. - // Called when a server requests user input via Elicit. + // ElicitationHandler handles incoming requests for elicitation/create. + // + // Setting ElicitationHandler to a non-nil value causes the client to + // advertise the elicitation capability. ElicitationHandler func(context.Context, *ElicitRequest) (*ElicitResult, error) // Handlers for notifications from the server. ToolListChangedHandler func(context.Context, *ToolListChangedRequest) @@ -123,7 +127,7 @@ func (c *Client) capabilities() *ClientCapabilities { } // Connect begins an MCP session by connecting to a server over the given -// transport, and initializing the session. +// transport. The resulting session is initialized, and ready to use. // // Typically, it is the responsibility of the client to close the connection // when it is no longer needed. However, if the connection is closed by the @@ -302,6 +306,9 @@ func (c *Client) elicit(ctx context.Context, req *ElicitRequest) (*ElicitResult, // Validate elicitation result content against requested schema if req.Params.RequestedSchema != nil && res.Content != nil { + // TODO: is this the correct behavior if validation fails? + // It isn't the *server's* params that are invalid, so why would we return + // this code to the server? resolved, err := req.Params.RequestedSchema.Resolve(nil) if err != nil { return nil, jsonrpc2.NewError(CodeInvalidParams, fmt.Sprintf("failed to resolve requested schema: %v", err)) diff --git a/mcp/client_example_test.go b/mcp/client_example_test.go new file mode 100644 index 0000000..3c3c383 --- /dev/null +++ b/mcp/client_example_test.go @@ -0,0 +1,136 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp_test + +import ( + "context" + "fmt" + "log" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// !+roots + +func Example_roots() { + ctx := context.Background() + + // Create a client with a single root. + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + c.AddRoots(&mcp.Root{URI: "file://a"}) + + // Now create a server with a handler to receive notifications about roots. + rootsChanged := make(chan struct{}) + handleRootsChanged := func(ctx context.Context, req *mcp.RootsListChangedRequest) { + rootList, err := req.Session.ListRoots(ctx, nil) + if err != nil { + log.Fatal(err) + } + var roots []string + for _, root := range rootList.Roots { + roots = append(roots, root.URI) + } + fmt.Println(roots) + close(rootsChanged) + } + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ + RootsListChangedHandler: handleRootsChanged, + }) + + // Connect the server and client... + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + if _, err := c.Connect(ctx, t2, nil); err != nil { + log.Fatal(err) + } + + // ...and add a root. The server is notified about the change. + c.AddRoots(&mcp.Root{URI: "file://b"}) + <-rootsChanged + // Output: [file://a file://b] +} + +// !-roots + +// !+sampling + +func Example_sampling() { + ctx := context.Background() + + // Create a client with a sampling handler. + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + CreateMessageHandler: func(_ context.Context, req *mcp.CreateMessageRequest) (*mcp.CreateMessageResult, error) { + return &mcp.CreateMessageResult{ + Content: &mcp.TextContent{ + Text: "would have created a message", + }, + }, nil + }, + }) + + // Connect the server and client... + ct, st := mcp.NewInMemoryTransports() + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + session, err := s.Connect(ctx, st, nil) + if err != nil { + log.Fatal(err) + } + defer session.Close() + + if _, err := c.Connect(ctx, ct, nil); err != nil { + log.Fatal(err) + } + + msg, err := session.CreateMessage(ctx, &mcp.CreateMessageParams{}) + if err != nil { + log.Fatal(err) + } + fmt.Println(msg.Content.(*mcp.TextContent).Text) + // Output: would have created a message +} + +// !-sampling + +// !+elicitation + +func Example_elicitation() { + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + log.Fatal(err) + } + defer ss.Close() + + c := mcp.NewClient(testImpl, &mcp.ClientOptions{ + ElicitationHandler: func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"test": "value"}}, nil + }, + }) + if _, err := c.Connect(ctx, ct, nil); err != nil { + log.Fatal(err) + } + res, err := ss.Elicit(ctx, &mcp.ElicitParams{ + Message: "This should fail", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "test": {Type: "string"}, + }, + }, + }) + if err != nil { + log.Fatal(err) + } + fmt.Println(res.Content["test"]) + // Output: value +} + +// !-elicitation diff --git a/mcp/mcp_example_test.go b/mcp/mcp_example_test.go new file mode 100644 index 0000000..25f39fb --- /dev/null +++ b/mcp/mcp_example_test.go @@ -0,0 +1,166 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp_test + +import ( + "context" + "fmt" + "log" + "sync" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// !+lifecycle + +func Example_lifecycle() { + ctx := context.Background() + + // Create a client and server. + // Wait for the client to initialize the session. + client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ + InitializedHandler: func(context.Context, *mcp.InitializedRequest) { + fmt.Println("initialized!") + }, + }) + + // Connect the server and client using in-memory transports. + // + // Connect the server first so that it's ready to receive initialization + // messages from the client. + t1, t2 := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(ctx, t1, nil) + if err != nil { + log.Fatal(err) + } + clientSession, err := client.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + + // Now shut down the session by closing the client, and waiting for the + // server session to end. + if err := clientSession.Close(); err != nil { + log.Fatal(err) + } + if err := serverSession.Wait(); err != nil { + log.Fatal(err) + } + // Output: initialized! +} + +// !-lifecycle + +// !+progress + +func Example_progress() { + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(server, &mcp.Tool{Name: "makeProgress"}, func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + if token := req.Params.GetProgressToken(); token != nil { + for i := range 3 { + params := &mcp.ProgressNotificationParams{ + Message: "frobbing widgets", + ProgressToken: token, + Progress: float64(i), + Total: 2, + } + req.Session.NotifyProgress(ctx, params) // ignore error + } + } + return &mcp.CallToolResult{}, nil, nil + }) + client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + ProgressNotificationHandler: func(_ context.Context, req *mcp.ProgressNotificationClientRequest) { + fmt.Printf("%s %.0f/%.0f\n", req.Params.Message, req.Params.Progress, req.Params.Total) + }, + }) + ctx := context.Background() + t1, t2 := mcp.NewInMemoryTransports() + if _, err := server.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + + session, err := client.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer session.Close() + if _, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "makeProgress", + Meta: mcp.Meta{"progressToken": "abc123"}, + }); err != nil { + log.Fatal(err) + } + // Output: + // frobbing widgets 0/2 + // frobbing widgets 1/2 + // frobbing widgets 2/2 +} + +// !-progress + +// !+cancellation + +func Example_cancellation() { + // For this example, we're going to be collecting observations from the + // server and client. + var clientResult, serverResult string + var wg sync.WaitGroup + wg.Add(2) + + // Create a server with a single slow tool. + // When the client cancels its request, the server should observe + // cancellation. + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + started := make(chan struct{}, 1) // signals that the server started handling the tool call + mcp.AddTool(server, &mcp.Tool{Name: "slow"}, func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + started <- struct{}{} + defer wg.Done() + select { + case <-time.After(5 * time.Second): + serverResult = "tool done" + case <-ctx.Done(): + serverResult = "tool canceled" + } + return &mcp.CallToolResult{}, nil, nil + }) + + // Connect a client to the server. + client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + ctx := context.Background() + t1, t2 := mcp.NewInMemoryTransports() + if _, err := server.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + session, err := client.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer session.Close() + + // Make a tool call, asynchronously. + ctx, cancel := context.WithCancel(context.Background()) + go func() { + defer wg.Done() + _, err = session.CallTool(ctx, &mcp.CallToolParams{Name: "slow"}) + clientResult = fmt.Sprintf("%v", err) + }() + + // As soon as the server has started handling the call, cancel it from the + // client side. + <-started + cancel() + wg.Wait() + + fmt.Println(clientResult) + fmt.Println(serverResult) + // Output: + // context canceled + // tool canceled +} + +// !-cancellation diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 6191954..dd542d3 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -705,7 +705,7 @@ func TestCancellation(t *testing.T) { start = make(chan struct{}) cancelled = make(chan struct{}, 1) // don't block the request ) - slowRequest := func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { + slowTool := func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { start <- struct{}{} select { case <-ctx.Done(): @@ -716,7 +716,7 @@ func TestCancellation(t *testing.T) { return nil, nil, nil } cs, _ := basicConnection(t, func(s *Server) { - AddTool(s, &Tool{Name: "slow", InputSchema: &jsonschema.Schema{Type: "object"}}, slowRequest) + AddTool(s, &Tool{Name: "slow", InputSchema: &jsonschema.Schema{Type: "object"}}, slowTool) }) defer cs.Close() @@ -1109,7 +1109,7 @@ func TestElicitationSchemaValidation(t *testing.T) { "low", }, Extra: map[string]any{ - "enumNames": []interface{}{"High Priority", "Medium Priority", "Low Priority"}, + "enumNames": []any{"High Priority", "Medium Priority", "Low Priority"}, }, }, }, @@ -1270,7 +1270,7 @@ func TestElicitationSchemaValidation(t *testing.T) { "low", }, Extra: map[string]any{ - "enumNames": []interface{}{"High Priority", "Medium Priority"}, // Only 2 names for 3 values + "enumNames": []any{"High Priority", "Medium Priority"}, // Only 2 names for 3 values }, }, }, diff --git a/mcp/root.go b/mcp/root.go deleted file mode 100644 index b56ad99..0000000 --- a/mcp/root.go +++ /dev/null @@ -1,5 +0,0 @@ -// Copyright 2025 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -package mcp diff --git a/mcp/streamable.go b/mcp/streamable.go index bfaccae..8ac6f59 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -70,9 +70,12 @@ type StreamableHTTPOptions struct { // documentation for [StreamableServerTransport]. Stateless bool - // TODO: support session retention (?) + // TODO(#148): support session retention (?) - // JSONResponse is forwarded to StreamableServerTransport.jsonResponse. + // JSONResponse causes streamable responses to return application/json rather + // than text/event-stream ([§2.1.5] of the spec). + // + // [§2.1.5]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#sending-messages-to-the-server JSONResponse bool } @@ -181,7 +184,7 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque return } - // Section 2.7 of the spec (2025-06-18) states: + // [§2.7] of the spec (2025-06-18) states: // // "If using HTTP, the client MUST include the MCP-Protocol-Version: // HTTP header on all subsequent requests to the MCP @@ -209,6 +212,8 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque // assume 2025-03-26 if the client doesn't say anything). // // This logic matches the typescript SDK. + // + // [§2.7]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#protocol-version-header protocolVersion := req.Header.Get(protocolVersionHeader) if protocolVersion == "" { protocolVersion = protocolVersion20250326 @@ -370,6 +375,9 @@ type StreamableServerTransport struct { // request contain only a single message. In this case, notifications or // requests made within the context of a server request will be sent to the // hanging GET request, if any. + // + // TODO(rfindley): jsonResponse should be exported, since + // StreamableHTTPOptions.JSONResponse is exported. jsonResponse bool // connection is non-nil if and only if the transport has been connected. @@ -1188,7 +1196,7 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e return fmt.Errorf("%s: %v", requestSummary, err) } - // Section 2.5.3: "The server MAY terminate the session at any time, after + // §2.5.3: "The server MAY terminate the session at any time, after // which it MUST respond to requests containing that session ID with HTTP // 404 Not Found." if resp.StatusCode == http.StatusNotFound { diff --git a/mcp/streamable_example_test.go b/mcp/streamable_example_test.go new file mode 100644 index 0000000..430f274 --- /dev/null +++ b/mcp/streamable_example_test.go @@ -0,0 +1,86 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp_test + +import ( + "bytes" + "fmt" + "io" + "log" + "net/http" + "net/http/httptest" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// !+streamablehandler + +func ExampleStreamableHTTPHandler() { + // Create a new streamable handler, using the same MCP server for every request. + // + // Here, we configure it to serves application/json responses rather than + // text/event-stream, just so the output below doesn't use random event ids. + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.1.0"}, nil) + handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { + return server + }, &mcp.StreamableHTTPOptions{JSONResponse: true}) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + // The SDK is currently permissive of some missing keys in "params". + resp := mustPostMessage(`{"jsonrpc": "2.0", "id": 1, "method":"initialize", "params": {}}`, httpServer.URL) + fmt.Println(resp) + // Output: + // {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-06-18","serverInfo":{"name":"server","version":"v0.1.0"}}} +} + +// !-streamablehandler + +// !+httpmiddleware + +func ExampleStreamableHTTPHandler_middleware() { + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.1.0"}, nil) + handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { + return server + }, nil) + loggingHandler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + // Example debugging; you could also capture the response. + body, err := io.ReadAll(req.Body) + if err != nil { + log.Fatal(err) + } + req.Body.Close() // ignore error + req.Body = io.NopCloser(bytes.NewBuffer(body)) + fmt.Println(req.Method, string(body)) + handler.ServeHTTP(w, req) + }) + httpServer := httptest.NewServer(loggingHandler) + defer httpServer.Close() + + // The SDK is currently permissive of some missing keys in "params". + mustPostMessage(`{"jsonrpc": "2.0", "id": 1, "method":"initialize", "params": {}}`, httpServer.URL) + // Output: + // POST {"jsonrpc": "2.0", "id": 1, "method":"initialize", "params": {}} +} + +// !-httpmiddleware + +func mustPostMessage(msg, url string) string { + req := orFatal(http.NewRequest("POST", url, strings.NewReader(msg))) + req.Header["Content-Type"] = []string{"application/json"} + req.Header["Accept"] = []string{"application/json", "text/event-stream"} + resp := orFatal(http.DefaultClient.Do(req)) + defer resp.Body.Close() + body := orFatal(io.ReadAll(resp.Body)) + return string(body) +} + +func orFatal[T any](t T, err error) T { + if err != nil { + log.Fatal(err) + } + return t +} diff --git a/mcp/transport.go b/mcp/transport.go index 024863d..d2109e7 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -212,12 +212,14 @@ func (t *LoggingTransport) Connect(ctx context.Context) (Connection, error) { if err != nil { return nil, err } - return &loggingConn{delegate, t.Writer}, nil + return &loggingConn{delegate: delegate, w: t.Writer}, nil } type loggingConn struct { delegate Connection - w io.Writer + + mu sync.Mutex + w io.Writer } func (c *loggingConn) SessionID() string { return c.delegate.SessionID() } @@ -225,15 +227,21 @@ func (c *loggingConn) SessionID() string { return c.delegate.SessionID() } // Read is a stream middleware that logs incoming messages. func (s *loggingConn) Read(ctx context.Context) (jsonrpc.Message, error) { msg, err := s.delegate.Read(ctx) + if err != nil { + s.mu.Lock() fmt.Fprintf(s.w, "read error: %v", err) + s.mu.Unlock() } else { data, err := jsonrpc2.EncodeMessage(msg) + s.mu.Lock() if err != nil { fmt.Fprintf(s.w, "LoggingTransport: failed to marshal: %v", err) } fmt.Fprintf(s.w, "read: %s\n", string(data)) + s.mu.Unlock() } + return msg, err } @@ -241,13 +249,17 @@ func (s *loggingConn) Read(ctx context.Context) (jsonrpc.Message, error) { func (s *loggingConn) Write(ctx context.Context, msg jsonrpc.Message) error { err := s.delegate.Write(ctx, msg) if err != nil { + s.mu.Lock() fmt.Fprintf(s.w, "write error: %v", err) + s.mu.Unlock() } else { data, err := jsonrpc2.EncodeMessage(msg) + s.mu.Lock() if err != nil { fmt.Fprintf(s.w, "LoggingTransport: failed to marshal: %v", err) } fmt.Fprintf(s.w, "write: %s\n", string(data)) + s.mu.Unlock() } return err } diff --git a/mcp/transport_example_test.go b/mcp/transport_example_test.go new file mode 100644 index 0000000..dcf1a8b --- /dev/null +++ b/mcp/transport_example_test.go @@ -0,0 +1,40 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp_test + +import ( + "bytes" + "context" + "fmt" + "log" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// !+loggingtransport + +func ExampleLoggingTransport() { + ctx := context.Background() + t1, t2 := mcp.NewInMemoryTransports() + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + if _, err := server.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + + client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + var b bytes.Buffer + logTransport := &mcp.LoggingTransport{Transport: t2, Writer: &b} + if _, err := client.Connect(ctx, logTransport, nil); err != nil { + log.Fatal(err) + } + fmt.Println(b.String()) + // Output: + // write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{"roots":{"listChanged":true}},"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-06-18"}} + // read: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-06-18","serverInfo":{"name":"server","version":"v0.0.1"}}} + // write: {"jsonrpc":"2.0","method":"notifications/initialized","params":{}} + +} + +// !-loggingtransport From 845c29f6b7e3d71537c143b542c5d8c7badd3837 Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Tue, 16 Sep 2025 18:56:28 +0000 Subject: [PATCH 049/418] mcp: add a benchmark for the MemoryEventStore; remove validation Add a new benchmark measuring the append and purge performance of the MemoryEventStore. This benchmark revealed that the store is orders of magnitude slower than it should be due to conservative validation (hugely so: 300KB/s vs 568MB/s). Turn off this validation by default. For #190 --- mcp/event.go | 4 ++-- mcp/event_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/mcp/event.go b/mcp/event.go index 0dd8734..d309c4e 100644 --- a/mcp/event.go +++ b/mcp/event.go @@ -23,8 +23,8 @@ import ( ) // If true, MemoryEventStore will do frequent validation to check invariants, slowing it down. -// Remove when we're confident in the code. -const validateMemoryEventStore = true +// Enable for debugging. +const validateMemoryEventStore = false // An Event is a server-sent event. // See https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#fields. diff --git a/mcp/event_test.go b/mcp/event_test.go index 601e830..20808c7 100644 --- a/mcp/event_test.go +++ b/mcp/event_test.go @@ -10,6 +10,7 @@ import ( "slices" "strings" "testing" + "time" ) func TestScanEvents(t *testing.T) { @@ -252,3 +253,46 @@ func TestMemoryEventStoreAfter(t *testing.T) { }) } } + +func BenchmarkMemoryEventStore(b *testing.B) { + // Benchmark with various settings for event store size, number of session, + // and payload size. + // + // Assume a small number of streams per session, which is probably realistic. + tests := []struct { + name string + limit int + sessions int + datasize int + }{ + {"1KB", 1024, 1, 16}, + {"1MB", 1024 * 1024, 10, 16}, + {"10MB", 10 * 1024 * 1024, 100, 16}, + {"10MB_big", 10 * 1024 * 1024, 1000, 128}, + } + + for _, test := range tests { + b.Run(test.name, func(b *testing.B) { + store := NewMemoryEventStore(nil) + store.SetMaxBytes(test.limit) + ctx := context.Background() + sessionIDs := make([]string, test.sessions) + streamIDs := make([][3]StreamID, test.sessions) + for i := range sessionIDs { + sessionIDs[i] = fmt.Sprint(i) + for j := range 3 { + streamIDs[i][j] = StreamID(randText()) + } + } + payload := make([]byte, test.datasize) + start := time.Now() + b.ResetTimer() + for i := 0; i < b.N; i++ { + sessionID := sessionIDs[i%len(sessionIDs)] + streamID := streamIDs[i%len(sessionIDs)][i%3] + store.Append(ctx, sessionID, streamID, payload) + } + b.ReportMetric(float64(test.datasize)*float64(b.N)/time.Since(start).Seconds(), "bytes/s") + }) + } +} From b3fb83f272a8fba10dd64568e53cb1f201f653ca Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Tue, 16 Sep 2025 21:12:14 -0400 Subject: [PATCH 050/418] internal/testing: fake auth server (#296) This is a fake OAuth authentication server, for use in testing. --- go.mod | 1 + go.sum | 2 + internal/testing/fake_auth_server.go | 151 +++++++++++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 internal/testing/fake_auth_server.go diff --git a/go.mod b/go.mod index 5691089..0689625 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/modelcontextprotocol/go-sdk go 1.23.0 require ( + github.com/golang-jwt/jwt/v5 v5.2.1 github.com/google/go-cmp v0.7.0 github.com/google/jsonschema-go v0.2.3 github.com/yosida95/uritemplate/v3 v3.0.2 diff --git a/go.sum b/go.sum index d30dd34..7ccdc6a 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.2.3-0.20250911201137-bbdc431016d2 h1:IIj7X4SH1HKy0WfPR4nNEj4dhIJWGdXM5YoBAbfpdoo= diff --git a/internal/testing/fake_auth_server.go b/internal/testing/fake_auth_server.go new file mode 100644 index 0000000..225f649 --- /dev/null +++ b/internal/testing/fake_auth_server.go @@ -0,0 +1,151 @@ +package testing + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const ( + authServerPort = ":8080" + issuer = "http://localhost" + authServerPort + tokenExpiry = time.Hour +) + +var jwtSigningKey = []byte("fake-secret-key") + +type authCodeInfo struct { + codeChallenge string + redirectURI string +} + +// // FakeAuthServer is a fake OAuth2 authorization server. +// type FakeAuthServer struct { +// server *http.Server +// authCodes map[string]authCodeInfo +// } + +type state struct { + authCodes map[string]authCodeInfo +} + +// NewFakeAuthMux constructs a ServeMux that implements an OAuth 2.1 authentication +// server. It should be used with [httptest.NewTLSServer]. +func NewFakeAuthMux() *http.ServeMux { + s := &state{authCodes: make(map[string]authCodeInfo)} + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/oauth-authorization-server", s.handleMetadata) + mux.HandleFunc("/authorize", s.handleAuthorize) + mux.HandleFunc("/token", s.handleToken) + return mux +} + +func (s *state) handleMetadata(w http.ResponseWriter, r *http.Request) { + issuer := "https://localhost:" + r.URL.Port() + metadata := map[string]any{ + "issuer": issuer, + "authorization_endpoint": issuer + "/authorize", + "token_endpoint": issuer + "/token", + "jwks_uri": issuer + "/.well-known/jwks.json", + "scopes_supported": []string{"openid", "profile", "email"}, + "response_types_supported": []string{"code"}, + "grant_types_supported": []string{"authorization_code"}, + "token_endpoint_auth_methods_supported": []string{"none"}, + "code_challenge_methods_supported": []string{"S256"}, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(metadata) +} + +func (s *state) handleAuthorize(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + responseType := query.Get("response_type") + redirectURI := query.Get("redirect_uri") + codeChallenge := query.Get("code_challenge") + codeChallengeMethod := query.Get("code_challenge_method") + + if responseType != "code" { + http.Error(w, "unsupported_response_type", http.StatusBadRequest) + return + } + if redirectURI == "" { + http.Error(w, "invalid_request (no redirect_uri)", http.StatusBadRequest) + return + } + if codeChallenge == "" || codeChallengeMethod != "S256" { + http.Error(w, "invalid_request (code challenge is not S256)", http.StatusBadRequest) + return + } + if query.Get("client_id") == "" { + http.Error(w, "invalid_request (missing client_id)", http.StatusBadRequest) + return + } + + authCode := "fake-auth-code-" + fmt.Sprintf("%d", time.Now().UnixNano()) + s.authCodes[authCode] = authCodeInfo{ + codeChallenge: codeChallenge, + redirectURI: redirectURI, + } + + redirectURL := fmt.Sprintf("%s?code=%s&state=%s", redirectURI, authCode, query.Get("state")) + http.Redirect(w, r, redirectURL, http.StatusFound) +} + +func (s *state) handleToken(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + grantType := r.Form.Get("grant_type") + code := r.Form.Get("code") + codeVerifier := r.Form.Get("code_verifier") + // Ignore redirect_uri; it is not required in 2.1. + // https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#redirect-uri-in-token-request + + if grantType != "authorization_code" { + http.Error(w, "unsupported_grant_type", http.StatusBadRequest) + return + } + + authCodeInfo, ok := s.authCodes[code] + if !ok { + http.Error(w, "invalid_grant", http.StatusBadRequest) + return + } + delete(s.authCodes, code) + + // PKCE verification + hasher := sha256.New() + hasher.Write([]byte(codeVerifier)) + calculatedChallenge := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil)) + if calculatedChallenge != authCodeInfo.codeChallenge { + http.Error(w, "invalid_grant", http.StatusBadRequest) + return + } + + // Issue JWT + now := time.Now() + claims := jwt.MapClaims{ + "iss": issuer, + "sub": "fake-user-id", + "aud": "fake-client-id", + "exp": now.Add(tokenExpiry).Unix(), + "iat": now.Unix(), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + accessToken, err := token.SignedString(jwtSigningKey) + if err != nil { + http.Error(w, "server_error", http.StatusInternalServerError) + return + } + + tokenResponse := map[string]any{ + "access_token": accessToken, + "token_type": "Bearer", + "expires_in": int(tokenExpiry.Seconds()), + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(tokenResponse) +} From bd6ffe3b4c17eb027a8fdf6badabcabe03b0a8c5 Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Tue, 16 Sep 2025 17:27:58 +0000 Subject: [PATCH 051/418] mcp: add an example customizing a nested type schema Add an example that demonstrates how to customize a nested type schema, which is a recurring problem for our users (see #467). Fixes #467 Updates #368 --- mcp/tool_example_test.go | 93 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 mcp/tool_example_test.go diff --git a/mcp/tool_example_test.go b/mcp/tool_example_test.go new file mode 100644 index 0000000..29ccd9c --- /dev/null +++ b/mcp/tool_example_test.go @@ -0,0 +1,93 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp_test + +import ( + "context" + "encoding/json" + "fmt" + "log" + "time" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func ExampleAddTool_customTypeSchema() { + // Sometimes when you want to customize the input or output schema for a + // tool, you need to customize the schema of a single helper type that's used + // in several places. + // + // For example, suppose you had a type that marshals/unmarshals like a + // time.Time, and that type was used multiple times in your tool input. + type MyDate struct { + time.Time + } + type Input struct { + Query string `json:"query,omitempty"` + Start MyDate `json:"start,omitempty"` + End MyDate `json:"end,omitempty"` + } + + // In this case, you can use jsonschema.For along with jsonschema.ForOptions + // to customize the schema inference for your custom type. + inputSchema, err := jsonschema.For[Input](&jsonschema.ForOptions{ + TypeSchemas: map[any]*jsonschema.Schema{ + MyDate{}: {Type: "string"}, + }, + }) + if err != nil { + log.Fatal(err) + } + + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + toolHandler := func(context.Context, *mcp.CallToolRequest, Input) (*mcp.CallToolResult, any, error) { + panic("not implemented") + } + mcp.AddTool(server, &mcp.Tool{Name: "my_tool", InputSchema: inputSchema}, toolHandler) + + ctx := context.Background() + session, err := connect(ctx, server) // create an in-memory connection + if err != nil { + log.Fatal(err) + } + defer session.Close() + + for t, err := range session.Tools(ctx, nil) { + if err != nil { + log.Fatal(err) + } + schemaJSON, err := json.MarshalIndent(t.InputSchema, "", "\t") + if err != nil { + log.Fatal(err) + } + fmt.Println(t.Name, string(schemaJSON)) + } + // Output: + // my_tool { + // "type": "object", + // "properties": { + // "end": { + // "type": "string" + // }, + // "query": { + // "type": "string" + // }, + // "start": { + // "type": "string" + // } + // }, + // "additionalProperties": false + // } +} + +func connect(ctx context.Context, server *mcp.Server) (*mcp.ClientSession, error) { + t1, t2 := mcp.NewInMemoryTransports() + if _, err := server.Connect(ctx, t1, nil); err != nil { + return nil, err + } + client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + return client.Connect(ctx, t2, nil) +} From 22f86c4dfdf440e9980a18d9d3a7a89618f6be3f Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Tue, 16 Sep 2025 21:43:18 -0400 Subject: [PATCH 052/418] internal/testing: add copyright header (#477) --- examples/server/auth-middleware/go.mod | 2 +- examples/server/auth-middleware/go.sum | 4 ++-- examples/server/rate-limiting/go.mod | 2 +- examples/server/rate-limiting/go.sum | 4 ++-- internal/testing/fake_auth_server.go | 10 ++++------ 5 files changed, 10 insertions(+), 12 deletions(-) diff --git a/examples/server/auth-middleware/go.mod b/examples/server/auth-middleware/go.mod index f1b65d2..f1ca77f 100644 --- a/examples/server/auth-middleware/go.mod +++ b/examples/server/auth-middleware/go.mod @@ -8,7 +8,7 @@ require ( ) require ( - github.com/google/jsonschema-go v0.2.1 // indirect + github.com/google/jsonschema-go v0.2.3 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect ) diff --git a/examples/server/auth-middleware/go.sum b/examples/server/auth-middleware/go.sum index ada94c0..6a39263 100644 --- a/examples/server/auth-middleware/go.sum +++ b/examples/server/auth-middleware/go.sum @@ -2,8 +2,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeD github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/jsonschema-go v0.2.1 h1:Z3iINWAUmvS4+m9cMP5lWbn6WlX8Hy4rpUS4pULVliQ= -github.com/google/jsonschema-go v0.2.1/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.2.3 h1:dkP3B96OtZKKFvdrUSaDkL+YDx8Uw9uC4Y+eukpCnmM= +github.com/google/jsonschema-go v0.2.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= diff --git a/examples/server/rate-limiting/go.mod b/examples/server/rate-limiting/go.mod index 8dd2f80..91d3d26 100644 --- a/examples/server/rate-limiting/go.mod +++ b/examples/server/rate-limiting/go.mod @@ -8,7 +8,7 @@ require ( ) require ( - github.com/google/jsonschema-go v0.2.1 // indirect + github.com/google/jsonschema-go v0.2.3 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect ) diff --git a/examples/server/rate-limiting/go.sum b/examples/server/rate-limiting/go.sum index 9e6a390..da49fd1 100644 --- a/examples/server/rate-limiting/go.sum +++ b/examples/server/rate-limiting/go.sum @@ -1,7 +1,7 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/jsonschema-go v0.2.1 h1:Z3iINWAUmvS4+m9cMP5lWbn6WlX8Hy4rpUS4pULVliQ= -github.com/google/jsonschema-go v0.2.1/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.2.3 h1:dkP3B96OtZKKFvdrUSaDkL+YDx8Uw9uC4Y+eukpCnmM= +github.com/google/jsonschema-go v0.2.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= diff --git a/internal/testing/fake_auth_server.go b/internal/testing/fake_auth_server.go index 225f649..79fafe4 100644 --- a/internal/testing/fake_auth_server.go +++ b/internal/testing/fake_auth_server.go @@ -1,3 +1,7 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + package testing import ( @@ -24,12 +28,6 @@ type authCodeInfo struct { redirectURI string } -// // FakeAuthServer is a fake OAuth2 authorization server. -// type FakeAuthServer struct { -// server *http.Server -// authCodes map[string]authCodeInfo -// } - type state struct { authCodes map[string]authCodeInfo } From a3e753b8d8b5cb936c19f6110632b0c08377a8a4 Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Wed, 17 Sep 2025 21:05:52 +0000 Subject: [PATCH 053/418] mcp: correct the JSON used for unstructured content Recently, I refactored to fix validation and default application for structured output. However, I used the wrong byte slice for the unstructured output. Fix it to use the same bytes as structured output, with a test. Fixes #475 --- mcp/server.go | 2 +- mcp/server_test.go | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index 69808ac..fd38a89 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -292,7 +292,7 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHan // https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content. if res.Content == nil { res.Content = []Content{&TextContent{ - Text: string(outbytes), + Text: string(outJSON), }} } } diff --git a/mcp/server_test.go b/mcp/server_test.go index 4456495..249ef90 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -512,8 +512,7 @@ func testToolForSchema[In, Out any](t *testing.T, tool *Tool, in string, out Out Arguments: json.RawMessage(in), }, } - _, err = goth(context.Background(), ctr) - + result, err := goth(context.Background(), ctr) if wantErrContaining != "" { if err == nil { t.Errorf("got nil error, want error containing %q", wantErrContaining) @@ -525,8 +524,18 @@ func testToolForSchema[In, Out any](t *testing.T, tool *Tool, in string, out Out } else if err != nil { t.Errorf("got error %v, want no error", err) } + + if gott.OutputSchema != nil && err == nil && !result.IsError { + // Check that structured content matches exactly. + unstructured := result.Content[0].(*TextContent).Text + structured := string(result.StructuredContent.(json.RawMessage)) + if diff := cmp.Diff(unstructured, structured); diff != "" { + t.Errorf("Unstructured content does not match structured content exactly (-unstructured +structured):\n%s", diff) + } + } } +// TODO: move this to tool_test.go func TestToolForSchemas(t *testing.T) { // Validate that toolForErr handles schemas properly. type in struct { @@ -558,4 +567,24 @@ func TestToolForSchemas(t *testing.T) { // Tool sets output schema: that is what's used, and validation happens. testToolForSchema[in, any](t, &Tool{OutputSchema: outSchema2}, `{"p":3}`, out{true}, inSchema, outSchema2, `want "integer"`) + + // Check a slightly more complicated case. + type weatherOutput struct { + Summary string + AsOf time.Time + Source string + } + testToolForSchema[any](t, &Tool{}, `{}`, weatherOutput{}, + &schema{Type: "object"}, + &schema{ + Type: "object", + Required: []string{"Summary", "AsOf", "Source"}, + AdditionalProperties: falseSchema, + Properties: map[string]*schema{ + "Summary": {Type: "string"}, + "AsOf": {Type: "string"}, + "Source": {Type: "string"}, + }, + }, + "") } From b615fa49b1a7b59be11ff7fa5d2bca5cf93ebcda Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Wed, 17 Sep 2025 17:49:31 -0400 Subject: [PATCH 054/418] mcp: add a streamable serving benchmark Add a benchmark using the streamable HTTP handler, and a more realistic input/output signature. Also expose this handler as an example (both a go example and a server in the examples/ directory). --- mcp/event_test.go | 2 +- mcp/streamable_bench_test.go | 66 +++++++++++++++++++ mcp/tool_example_test.go | 120 ++++++++++++++++++++++++++++++++++- 3 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 mcp/streamable_bench_test.go diff --git a/mcp/event_test.go b/mcp/event_test.go index 20808c7..ef4e080 100644 --- a/mcp/event_test.go +++ b/mcp/event_test.go @@ -287,7 +287,7 @@ func BenchmarkMemoryEventStore(b *testing.B) { payload := make([]byte, test.datasize) start := time.Now() b.ResetTimer() - for i := 0; i < b.N; i++ { + for i := range b.N { sessionID := sessionIDs[i%len(sessionIDs)] streamID := streamIDs[i%len(sessionIDs)][i%3] store.Append(ctx, sessionID, streamID, payload) diff --git a/mcp/streamable_bench_test.go b/mcp/streamable_bench_test.go new file mode 100644 index 0000000..07f81da --- /dev/null +++ b/mcp/streamable_bench_test.go @@ -0,0 +1,66 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func BenchmarkStreamableServing(b *testing.B) { + // This benchmark measures how fast we can handle a single tool on a + // streamable server, including tool validation and stream management. + customSchemas := map[any]*jsonschema.Schema{ + Probability(0): {Type: "number", Minimum: jsonschema.Ptr(0.0), Maximum: jsonschema.Ptr(1.0)}, + WeatherType(""): {Type: "string", Enum: []any{Sunny, PartlyCloudy, Cloudy, Rainy, Snowy}}, + } + opts := &jsonschema.ForOptions{TypeSchemas: customSchemas} + in, err := jsonschema.For[WeatherInput](opts) + if err != nil { + b.Fatal(err) + } + out, err := jsonschema.For[WeatherOutput](opts) + if err != nil { + b.Fatal(err) + } + + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(server, &mcp.Tool{ + Name: "weather", + InputSchema: in, + OutputSchema: out, + }, WeatherTool) + + handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { + return server + }, &mcp.StreamableHTTPOptions{JSONResponse: true}) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + session, err := mcp.NewClient(testImpl, nil).Connect(ctx, &mcp.StreamableClientTransport{Endpoint: httpServer.URL}, nil) + if err != nil { + b.Fatal(err) + } + defer session.Close() + b.ResetTimer() + for range b.N { + if _, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "weather", + Arguments: WeatherInput{ + Location: Location{Name: "somewhere"}, + Days: 7, + }, + }); err != nil { + b.Errorf("CallTool failed: %v", err) + } + } +} diff --git a/mcp/tool_example_test.go b/mcp/tool_example_test.go index 29ccd9c..888309b 100644 --- a/mcp/tool_example_test.go +++ b/mcp/tool_example_test.go @@ -15,7 +15,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) -func ExampleAddTool_customTypeSchema() { +func ExampleAddTool_customMarshalling() { // Sometimes when you want to customize the input or output schema for a // tool, you need to customize the schema of a single helper type that's used // in several places. @@ -83,6 +83,124 @@ func ExampleAddTool_customTypeSchema() { // } } +type WeatherInput struct { + Location Location `json:"location" jsonschema:"user location"` + Days int `json:"days" jsonschema:"number of days to forecast"` +} + +type Location struct { + Name string `json:"name"` + Latitude *float64 `json:"latitude,omitempty"` + Longitude *float64 `json:"longitude,omitempty"` +} + +type Forecast struct { + Forecast string `json:"forecast" jsonschema:"description of the day's weather"` + Type WeatherType `json:"type" jsonschema:"type of weather"` + Rain float64 `json:"rain" jsonschema:"probability of rain, between 0 and 1"` + High float64 `json:"high" jsonschema:"high temperature"` + Low float64 `json:"low" jsonschema:"low temperature"` +} + +type WeatherType string + +const ( + Sunny WeatherType = "sun" + PartlyCloudy WeatherType = "partly_cloudy" + Cloudy WeatherType = "clouds" + Rainy WeatherType = "rain" + Snowy WeatherType = "snow" +) + +type Probability float64 + +type WeatherOutput struct { + Summary string `json:"summary" jsonschema:"a summary of the weather forecast"` + Confidence Probability `json:"confidence" jsonschema:"confidence, between 0 and 1"` + AsOf time.Time `json:"asOf" jsonschema:"the time the weather was computed"` + DailyForecast []Forecast `json:"dailyForecast" jsonschema:"the daily forecast"` + Source string `json:"source,omitempty" jsonschema:"the organization providing the weather forecast"` +} + +func WeatherTool(ctx context.Context, req *mcp.CallToolRequest, in WeatherInput) (*mcp.CallToolResult, WeatherOutput, error) { + perfectWeather := WeatherOutput{ + Summary: "perfect", + Confidence: 1.0, + AsOf: time.Now(), + } + for range in.Days { + perfectWeather.DailyForecast = append(perfectWeather.DailyForecast, Forecast{ + Forecast: "another perfect day", + Type: Sunny, + Rain: 0.0, + High: 72.0, + Low: 72.0, + }) + } + return nil, perfectWeather, nil +} + +func ExampleAddTool_complexSchema() { + // This example demonstrates a tool with a more 'realistic' input and output + // schema. We use a combination of techniques to tune our input and output + // schemas. + + // Distinguished Go types allow custom schemas to be reused during inference. + customSchemas := map[any]*jsonschema.Schema{ + Probability(0): {Type: "number", Minimum: jsonschema.Ptr(0.0), Maximum: jsonschema.Ptr(1.0)}, + WeatherType(""): {Type: "string", Enum: []any{Sunny, PartlyCloudy, Cloudy, Rainy, Snowy}}, + } + opts := &jsonschema.ForOptions{TypeSchemas: customSchemas} + in, err := jsonschema.For[WeatherInput](opts) + if err != nil { + log.Fatal(err) + } + + // Furthermore, we can tweak the inferred schema, in this case limiting + // forecasts to 0-10 days. + daysSchema := in.Properties["days"] + daysSchema.Minimum = jsonschema.Ptr(0.0) + daysSchema.Maximum = jsonschema.Ptr(10.0) + + // Output schema inference can reuse our custom schemas from input inference. + out, err := jsonschema.For[WeatherOutput](opts) + if err != nil { + log.Fatal(err) + } + + // Now add our tool to a server. Since we've customized the schemas, we need + // to override the default schema inference. + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(server, &mcp.Tool{ + Name: "weather", + InputSchema: in, + OutputSchema: out, + }, WeatherTool) + + ctx := context.Background() + session, err := connect(ctx, server) // create an in-memory connection + if err != nil { + log.Fatal(err) + } + defer session.Close() + + // Check that the client observes the correct schemas. + for t, err := range session.Tools(ctx, nil) { + if err != nil { + log.Fatal(err) + } + // Formatting the entire schemas would be too much output. + // Just check that our customizations were effective. + fmt.Println("max days:", *t.InputSchema.Properties["days"].Maximum) + fmt.Println("max confidence:", *t.OutputSchema.Properties["confidence"].Maximum) + fmt.Println("weather types:", t.OutputSchema.Properties["dailyForecast"].Items.Properties["type"].Enum) + } + // Output: + // max days: 10 + // max confidence: 1 + // weather types: [sun partly_cloudy clouds rain snow] +} + func connect(ctx context.Context, server *mcp.Server) (*mcp.ClientSession, error) { t1, t2 := mcp.NewInMemoryTransports() if _, err := server.Connect(ctx, t1, nil); err != nil { From 140b93980c512d06a46732ed843f2beaa3bb81b8 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Thu, 18 Sep 2025 08:08:23 -0400 Subject: [PATCH 055/418] mcp: add CallToolResult.getError (#481) Provide a way for middleware to get the error from a tool call, demonstrating that we can add this functionality without making a breaking change. Leave getError unexported for now; we can export it (and setError) at any time. For #64. --- mcp/cmd_test.go | 2 +- mcp/content_nil_test.go | 4 ++- mcp/mcp_test.go | 75 ++++++++++++++++++++++++++++++++++++++--- mcp/protocol.go | 13 +++++++ mcp/protocol_test.go | 2 +- mcp/sse_test.go | 2 +- mcp/streamable_test.go | 4 +-- 7 files changed, 91 insertions(+), 11 deletions(-) diff --git a/mcp/cmd_test.go b/mcp/cmd_test.go index 0df4570..cbaadcb 100644 --- a/mcp/cmd_test.go +++ b/mcp/cmd_test.go @@ -226,7 +226,7 @@ func TestCmdTransport(t *testing.T) { &mcp.TextContent{Text: "Hi user"}, }, } - if diff := cmp.Diff(want, got); diff != "" { + if diff := cmp.Diff(want, got, ctrCmpOpts...); diff != "" { t.Errorf("greet returned unexpected content (-want +got):\n%s", diff) } if err := session.Close(); err != nil { diff --git a/mcp/content_nil_test.go b/mcp/content_nil_test.go index 70cabfd..8cc7bdb 100644 --- a/mcp/content_nil_test.go +++ b/mcp/content_nil_test.go @@ -72,7 +72,7 @@ func TestContentUnmarshalNil(t *testing.T) { } // Verify that the Content field was properly populated - if cmp.Diff(tt.want, tt.content) != "" { + if cmp.Diff(tt.want, tt.content, ctrCmpOpts...) != "" { t.Errorf("Content is not equal: %v", cmp.Diff(tt.content, tt.content)) } }) @@ -222,3 +222,5 @@ func TestContentUnmarshalNilWithInvalidContent(t *testing.T) { }) } } + +var ctrCmpOpts = []cmp.Option{cmp.AllowUnexported(mcp.CallToolResult{})} diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index dd542d3..fa941bf 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -234,7 +234,7 @@ func TestEndToEnd(t *testing.T) { &TextContent{Text: "hi user"}, }, } - if diff := cmp.Diff(wantHi, gotHi); diff != "" { + if diff := cmp.Diff(wantHi, gotHi, ctrCmpOpts...); diff != "" { t.Errorf("tools/call 'greet' mismatch (-want +got):\n%s", diff) } @@ -253,7 +253,7 @@ func TestEndToEnd(t *testing.T) { &TextContent{Text: errTestFailure.Error()}, }, } - if diff := cmp.Diff(wantFail, gotFail); diff != "" { + if diff := cmp.Diff(wantFail, gotFail, ctrCmpOpts...); diff != "" { t.Errorf("tools/call 'fail' mismatch (-want +got):\n%s", diff) } @@ -1717,7 +1717,7 @@ func TestPointerArgEquivalence(t *testing.T) { if err != nil { t.Fatal(err) } - if diff := cmp.Diff(r0, r1); diff != "" { + if diff := cmp.Diff(r0, r1, ctrCmpOpts...); diff != "" { t.Errorf("CallTool(%v) with no arguments mismatch (-%s +%s):\n%s", args, t0.Name, t1.Name, diff) } } @@ -1733,7 +1733,7 @@ func TestPointerArgEquivalence(t *testing.T) { if err != nil { t.Fatal(err) } - if diff := cmp.Diff(r0, r1); diff != "" { + if diff := cmp.Diff(r0, r1, ctrCmpOpts...); diff != "" { t.Errorf("CallTool({\"In\": %q}) mismatch (-%s +%s):\n%s", in, t0.Name, t1.Name, diff) } }) @@ -1837,3 +1837,70 @@ func TestEmbeddedStructResponse(t *testing.T) { t.Errorf("CallTool() failed: %v", err) } } + +func TestToolErrorMiddleware(t *testing.T) { + ctx := context.Background() + ct, st := NewInMemoryTransports() + + s := NewServer(testImpl, nil) + AddTool(s, &Tool{ + Name: "greet", + Description: "say hi", + }, sayHi) + AddTool(s, &Tool{Name: "fail", InputSchema: &jsonschema.Schema{Type: "object"}}, + func(context.Context, *CallToolRequest, map[string]any) (*CallToolResult, any, error) { + return nil, nil, errTestFailure + }) + + var middleErr error + s.AddReceivingMiddleware(func(h MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + res, err := h(ctx, method, req) + if err == nil { + if ctr, ok := res.(*CallToolResult); ok { + middleErr = ctr.getError() + } + } + return res, err + } + }) + _, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + client := NewClient(&Implementation{Name: "test-client"}, nil) + clientSession, err := client.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer clientSession.Close() + + _, err = clientSession.CallTool(ctx, &CallToolParams{ + Name: "greet", + Arguments: map[string]any{"Name": "al"}, + }) + if err != nil { + t.Errorf("CallTool() failed: %v", err) + } + if middleErr != nil { + t.Errorf("middleware got error %v, want nil", middleErr) + } + res, err := clientSession.CallTool(ctx, &CallToolParams{ + Name: "fail", + }) + if err != nil { + t.Errorf("CallTool() failed: %v", err) + } + if !res.IsError { + t.Fatal("want error, got none") + } + // Clients can't see the error, because it isn't marshaled. + if err := res.getError(); err != nil { + t.Fatalf("got %v, want nil", err) + } + if middleErr != errTestFailure { + t.Errorf("middleware got err %v, want errTestFailure", middleErr) + } +} + +var ctrCmpOpts = []cmp.Option{cmp.AllowUnexported(CallToolResult{})} diff --git a/mcp/protocol.go b/mcp/protocol.go index 7be8ea1..3e3c544 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -103,6 +103,12 @@ type CallToolResult struct { // tool handler returns an error, and the error string is included as text in // the Content field. IsError bool `json:"isError,omitempty"` + + // The error passed to setError, if any. + // It is not marshaled, and therefore it is only visible on the server. + // Its only use is in server sending middleware, where it can be accessed + // with getError. + err error } // TODO(#64): consider exposing setError (and getError), by adding an error @@ -110,6 +116,13 @@ type CallToolResult struct { func (r *CallToolResult) setError(err error) { r.Content = []Content{&TextContent{Text: err.Error()}} r.IsError = true + r.err = err +} + +// getError returns the error set with setError, or nil if none. +// This function always returns nil on clients. +func (r *CallToolResult) getError() error { + return r.err } func (*CallToolResult) isResult() {} diff --git a/mcp/protocol_test.go b/mcp/protocol_test.go index 28e9751..67d021d 100644 --- a/mcp/protocol_test.go +++ b/mcp/protocol_test.go @@ -499,7 +499,7 @@ func TestContentUnmarshal(t *testing.T) { if err := json.Unmarshal(data, out); err != nil { t.Fatal(err) } - if diff := cmp.Diff(in, out); diff != "" { + if diff := cmp.Diff(in, out, ctrCmpOpts...); diff != "" { t.Errorf("mismatch (-want, +got):\n%s", diff) } } diff --git a/mcp/sse_test.go b/mcp/sse_test.go index 408e92e..32a20bf 100644 --- a/mcp/sse_test.go +++ b/mcp/sse_test.go @@ -70,7 +70,7 @@ func TestSSEServer(t *testing.T) { &TextContent{Text: "hi user"}, }, } - if diff := cmp.Diff(wantHi, gotHi); diff != "" { + if diff := cmp.Diff(wantHi, gotHi, ctrCmpOpts...); diff != "" { t.Errorf("tools/call 'greet' mismatch (-want +got):\n%s", diff) } diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index e077308..79e9645 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -159,7 +159,7 @@ func TestStreamableTransports(t *testing.T) { want := &CallToolResult{ Content: []Content{&TextContent{Text: "hi foo"}}, } - if diff := cmp.Diff(want, got); diff != "" { + if diff := cmp.Diff(want, got, ctrCmpOpts...); diff != "" { t.Errorf("CallTool() returned unexpected content (-want +got):\n%s", diff) } @@ -550,8 +550,6 @@ func resp(id int64, result any, err error) *jsonrpc.Response { } } -var () - func TestStreamableServerTransport(t *testing.T) { // This test checks detailed behavior of the streamable server transport, by // faking the behavior of a streamable client using a sequence of HTTP From 208bfe25fd305457f4731e82519e16b93d952047 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Thu, 18 Sep 2025 08:25:24 -0400 Subject: [PATCH 056/418] oauthex: new package for oauth extensions (#429) This package will hold externally visible parts of internal/oauthex. For now, just add an alias to the ProtectedResourceMetadata struct. This lets one write MCP-compliant servers. --- README.md | 6 +++++- internal/readme/README.src.md | 6 +++++- oauthex/oauthex.go | 10 ++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 oauthex/oauthex.go diff --git a/README.md b/README.md index 2888b74..ab98902 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ software development kit (SDK) for the Model Context Protocol (MCP). ## Package documentation -The SDK consists of three importable packages: +The SDK consists of several importable packages: - The [`github.com/modelcontextprotocol/go-sdk/mcp`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp) @@ -34,6 +34,10 @@ The SDK consists of three importable packages: [`github.com/modelcontextprotocol/go-sdk/auth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth) package provides some primitives for supporting oauth. +- The + [`github.com/modelcontextprotocol/go-sdk/oauthex`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/oauthex) + package provides extensions to the OAuth protocol, such as ProtectedResourceMetadata. + ## Getting started To get started creating an MCP server, create an `mcp.Server` instance, add diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md index 45a57f6..acd08e4 100644 --- a/internal/readme/README.src.md +++ b/internal/readme/README.src.md @@ -20,7 +20,7 @@ software development kit (SDK) for the Model Context Protocol (MCP). ## Package documentation -The SDK consists of three importable packages: +The SDK consists of several importable packages: - The [`github.com/modelcontextprotocol/go-sdk/mcp`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp) @@ -33,6 +33,10 @@ The SDK consists of three importable packages: [`github.com/modelcontextprotocol/go-sdk/auth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth) package provides some primitives for supporting oauth. +- The + [`github.com/modelcontextprotocol/go-sdk/oauthex`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/oauthex) + package provides extensions to the OAuth protocol, such as ProtectedResourceMetadata. + ## Getting started To get started creating an MCP server, create an `mcp.Server` instance, add diff --git a/oauthex/oauthex.go b/oauthex/oauthex.go new file mode 100644 index 0000000..eb3a3e7 --- /dev/null +++ b/oauthex/oauthex.go @@ -0,0 +1,10 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// Package oauthex implements extensions to OAuth2. +package oauthex + +import "github.com/modelcontextprotocol/go-sdk/internal/oauthex" + +type ProtectedResourceMetadata = oauthex.ProtectedResourceMetadata From b636b1633b2c167ff4e2209ae1953a1c0843c303 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Thu, 18 Sep 2025 08:30:53 -0400 Subject: [PATCH 057/418] mcp: fix flaky ExampleLoggingTransport (#483) Reads are technically racy with writes. Sort for stability. --- docs/troubleshooting.md | 13 ++++++++----- mcp/transport_example_test.go | 18 +++++++++++++----- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c0f021b..0f990ed 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -39,12 +39,15 @@ func ExampleLoggingTransport() { if _, err := client.Connect(ctx, logTransport, nil); err != nil { log.Fatal(err) } - fmt.Println(b.String()) - // Output: - // write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{"roots":{"listChanged":true}},"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-06-18"}} - // read: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-06-18","serverInfo":{"name":"server","version":"v0.0.1"}}} - // write: {"jsonrpc":"2.0","method":"notifications/initialized","params":{}} + // Sort for stability: reads are concurrent to writes. + for _, line := range slices.Sorted(strings.SplitSeq(b.String(), "\n")) { + fmt.Println(line) + } + // Output: + // read: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-06-18","serverInfo":{"name":"server","version":"v0.0.1"}}} + // write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{"roots":{"listChanged":true}},"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-06-18"}} + // write: {"jsonrpc":"2.0","method":"notifications/initialized","params":{}} } ``` diff --git a/mcp/transport_example_test.go b/mcp/transport_example_test.go index dcf1a8b..064ab0f 100644 --- a/mcp/transport_example_test.go +++ b/mcp/transport_example_test.go @@ -2,6 +2,9 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. +// Uses strings.SplitSeq. +//go:build go1.24 + package mcp_test import ( @@ -9,6 +12,8 @@ import ( "context" "fmt" "log" + "slices" + "strings" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -29,12 +34,15 @@ func ExampleLoggingTransport() { if _, err := client.Connect(ctx, logTransport, nil); err != nil { log.Fatal(err) } - fmt.Println(b.String()) - // Output: - // write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{"roots":{"listChanged":true}},"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-06-18"}} - // read: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-06-18","serverInfo":{"name":"server","version":"v0.0.1"}}} - // write: {"jsonrpc":"2.0","method":"notifications/initialized","params":{}} + // Sort for stability: reads are concurrent to writes. + for _, line := range slices.Sorted(strings.SplitSeq(b.String(), "\n")) { + fmt.Println(line) + } + // Output: + // read: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-06-18","serverInfo":{"name":"server","version":"v0.0.1"}}} + // write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{"roots":{"listChanged":true}},"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-06-18"}} + // write: {"jsonrpc":"2.0","method":"notifications/initialized","params":{}} } // !-loggingtransport From d870f5e63813a9bd473255902def98d2d2a1b350 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 18 Sep 2025 10:22:57 -0400 Subject: [PATCH 058/418] build(deps): bump github.com/golang-jwt/jwt/v5 from 5.2.1 to 5.2.2 in the go_modules group across 1 directory (#476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the go_modules group with 1 update in the / directory: [github.com/golang-jwt/jwt/v5](https://github.com/golang-jwt/jwt). Updates `github.com/golang-jwt/jwt/v5` from 5.2.1 to 5.2.2
Release notes

Sourced from github.com/golang-jwt/jwt/v5's releases.

v5.2.2

What's Changed

New Contributors

Full Changelog: https://github.com/golang-jwt/jwt/compare/v5.2.1...v5.2.2

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/golang-jwt/jwt/v5&package-manager=go_modules&previous-version=5.2.1&new-version=5.2.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/modelcontextprotocol/go-sdk/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 0689625..f5c578c 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/modelcontextprotocol/go-sdk go 1.23.0 require ( - github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/go-cmp v0.7.0 github.com/google/jsonschema-go v0.2.3 github.com/yosida95/uritemplate/v3 v3.0.2 diff --git a/go.sum b/go.sum index 7ccdc6a..6a39263 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,7 @@ -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/jsonschema-go v0.2.3-0.20250911201137-bbdc431016d2 h1:IIj7X4SH1HKy0WfPR4nNEj4dhIJWGdXM5YoBAbfpdoo= -github.com/google/jsonschema-go v0.2.3-0.20250911201137-bbdc431016d2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/jsonschema-go v0.2.3 h1:dkP3B96OtZKKFvdrUSaDkL+YDx8Uw9uC4Y+eukpCnmM= github.com/google/jsonschema-go v0.2.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= From f5cee0aa32d6aa906530e53361494aa21ffcd636 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Thu, 18 Sep 2025 11:22:40 -0400 Subject: [PATCH 059/418] docs: document stateless mode and custom transports (#485) --- docs/protocol.md | 28 ++++++++++++++++++++++++---- internal/docs/protocol.src.md | 28 ++++++++++++++++++++++++---- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index dbc4c1c..75c5d12 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -182,15 +182,35 @@ the server using the streamable transport protocol. #### Stateless Mode - +The streamable server supports a _stateless mode_ by setting +[`StreamableHTTPOptions.Stateless`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#StreamableHTTPOptions.Stateless), +which is where the server does not perform any validation of the session id, +and uses a temporary session to handle requests. In this mode, it is impossible +for the server to make client requests, as there is no way for the client's +response to reach the session. -#### Sessionless mode +However, it is still possible for the server to access the `ServerSession.ID` +to see the logical session - +> [!WARNING] +> Stateless mode is not directly discussed in the spec, and is still being +> defined. See modelcontextprotocol/modelcontextprotocol#1364, +> modelcontextprotocol/modelcontextprotocol#1372, or +> modelcontextprotocol/modelcontextprotocol#11442 for potential refinements. + +_See [examples/server/distributed](../examples/server/distributed/main.go) for +an example using statless mode to implement a server distributed across +multiple processes._ ### Custom transports - +The SDK supports [custom +transports](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#custom-transports) +by implementing the +[`Transport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Transport) +interface: a logical bidirectional stream of JSON-RPC messages. + +_Full example: [examples/server/custom-transport](../examples/server/custom-transport/main.go)._ ### Concurrency diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index 79b7241..2bb954b 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -114,15 +114,35 @@ the server using the streamable transport protocol. #### Stateless Mode - +The streamable server supports a _stateless mode_ by setting +[`StreamableHTTPOptions.Stateless`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#StreamableHTTPOptions.Stateless), +which is where the server does not perform any validation of the session id, +and uses a temporary session to handle requests. In this mode, it is impossible +for the server to make client requests, as there is no way for the client's +response to reach the session. -#### Sessionless mode +However, it is still possible for the server to access the `ServerSession.ID` +to see the logical session - +> [!WARNING] +> Stateless mode is not directly discussed in the spec, and is still being +> defined. See modelcontextprotocol/modelcontextprotocol#1364, +> modelcontextprotocol/modelcontextprotocol#1372, or +> modelcontextprotocol/modelcontextprotocol#11442 for potential refinements. + +_See [examples/server/distributed](../examples/server/distributed/main.go) for +an example using statless mode to implement a server distributed across +multiple processes._ ### Custom transports - +The SDK supports [custom +transports](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#custom-transports) +by implementing the +[`Transport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Transport) +interface: a logical bidirectional stream of JSON-RPC messages. + +_Full example: [examples/server/custom-transport](../examples/server/custom-transport/main.go)._ ### Concurrency From 1cfd489d722c9b9ae45d54141c5e83d0772ba816 Mon Sep 17 00:00:00 2001 From: Frenchie Date: Fri, 19 Sep 2025 01:25:19 +1000 Subject: [PATCH 060/418] chore(deps): add dependabot and SHA-pin workflow deps (#456) Introduces Dependabot & SHA-pins workflow deps, as per the [Discord thread](https://discord.com/channels/1358869848138059966/1399986970851282954/1415896798144368762). A note on the commit message: I used convention commit format out of habit, but can rewrite the it to adhere to the [styleguide](https://go.dev/wiki/CommitMessage) if critical. --- .github/workflows/docs-check.yml | 39 ++++++++++++++++++++++++++++++ .github/workflows/readme-check.yml | 39 ------------------------------ .github/workflows/test.yml | 14 +++++------ 3 files changed, 46 insertions(+), 46 deletions(-) create mode 100644 .github/workflows/docs-check.yml delete mode 100644 .github/workflows/readme-check.yml diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml new file mode 100644 index 0000000..cb6b841 --- /dev/null +++ b/.github/workflows/docs-check.yml @@ -0,0 +1,39 @@ +name: Docs Check +on: + workflow_dispatch: + pull_request: + paths: + - 'internal/readme/**' + - 'README.md' + - 'internal/docs/**' + - 'docs/**' + +permissions: + contents: read + +jobs: + docs-check: + runs-on: ubuntu-latest + steps: + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 + - name: Check out code + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + - name: Check docs are up-to-date + run: | + go generate ./... + if [ -n "$(git status --porcelain)" ]; then + echo "ERROR: docs are not up-to-date!" + echo "" + echo "The docs differ from what would be generated by `go generate ./...`." + echo "Please update internal/**/*.src.md instead of directly editing README.md or docs/ files," + echo "then run `go generate ./...` to regenerate docs." + echo "" + echo "Changes:" + git status --porcelain + echo "" + echo "Diff:" + git diff + exit 1 + fi + echo "Docs are up-to-date." diff --git a/.github/workflows/readme-check.yml b/.github/workflows/readme-check.yml deleted file mode 100644 index 8709be1..0000000 --- a/.github/workflows/readme-check.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: README Check -on: - workflow_dispatch: - pull_request: - paths: - - 'internal/readme/**' - - 'README.md' - - 'internal/docs/**' - - 'docs/**' - -permissions: - contents: read - -jobs: - readme-check: - runs-on: ubuntu-latest - steps: - - name: Set up Go - uses: actions/setup-go@v5 - - name: Check out code - uses: actions/checkout@v4 - - name: Check docs is up-to-date - run: | - go generate ./... - if [ -n "$(git status --porcelain)" ]; then - echo "ERROR: docs are not up-to-date!" - echo "" - echo "The docs differ from what would be generated by `go generate ./...`." - echo "Please update internal/**/*.src.md instead of directly editing README.md or docs/ files," - echo "then run `go generate ./...` to regenerate docs." - echo "" - echo "Changes:" - git status --porcelain - echo "" - echo "Diff:" - git diff - exit 1 - fi - echo "Docs are up-to-date." diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9ad50c1..0366246 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,9 +14,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 with: go-version: "^1.23" - name: Check formatting @@ -31,7 +31,7 @@ jobs: - name: Run Go vet run: go vet ./... - name: Run staticcheck - uses: dominikh/staticcheck-action@v1 + uses: dominikh/staticcheck-action@024238d2898c874f26d723e7d0ff4308c35589a2 # v1 with: version: "latest" @@ -42,9 +42,9 @@ jobs: go: ["1.23", "1.24", "1.25"] steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 with: go-version: ${{ matrix.go }} - name: Test @@ -54,9 +54,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 with: go-version: "1.24" - name: Test with -race From 353d46ff86f842a0d322778384b3cea6bc802edc Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Thu, 18 Sep 2025 12:01:07 -0400 Subject: [PATCH 061/418] mcp: remove the StreamID type (#490) The streamID type did not carry its weight. Remove it. Fixes #484. --- mcp/event.go | 26 ++++++++++++++------------ mcp/event_test.go | 10 +++++----- mcp/streamable.go | 30 +++++++++++++----------------- mcp/streamable_test.go | 2 +- 4 files changed, 33 insertions(+), 35 deletions(-) diff --git a/mcp/event.go b/mcp/event.go index d309c4e..bd78cde 100644 --- a/mcp/event.go +++ b/mcp/event.go @@ -156,11 +156,13 @@ type EventStore interface { // Open prepares the event store for a given stream. It ensures that the // underlying data structure for the stream is initialized, making it // ready to store event streams. - Open(_ context.Context, sessionID string, streamID StreamID) error + // + // streamIDs must be globally unique. + Open(_ context.Context, sessionID, streamID string) error // Append appends data for an outgoing event to given stream, which is part of the // given session. - Append(_ context.Context, sessionID string, _ StreamID, data []byte) error + Append(_ context.Context, sessionID, streamID string, data []byte) error // After returns an iterator over the data for the given session and stream, beginning // just after the given index. @@ -168,7 +170,7 @@ type EventStore interface { // After's iterator must return an error immediately if any data after index was // dropped; it must not return partial results. // The stream must have been opened previously (see [EventStore.Open]). - After(_ context.Context, sessionID string, _ StreamID, index int) iter.Seq2[[]byte, error] + After(_ context.Context, sessionID, streamID string, index int) iter.Seq2[[]byte, error] // SessionClosed informs the store that the given session is finished, along // with all of its streams. @@ -217,9 +219,9 @@ func (dl *dataList) removeFirst() int { // A MemoryEventStore is an [EventStore] backed by memory. type MemoryEventStore struct { mu sync.Mutex - maxBytes int // max total size of all data - nBytes int // current total size of all data - store map[string]map[StreamID]*dataList // session ID -> stream ID -> *dataList + maxBytes int // max total size of all data + nBytes int // current total size of all data + store map[string]map[string]*dataList // session ID -> stream ID -> *dataList } // MemoryEventStoreOptions are options for a [MemoryEventStore]. @@ -258,13 +260,13 @@ const defaultMaxBytes = 10 << 20 // 10 MiB func NewMemoryEventStore(opts *MemoryEventStoreOptions) *MemoryEventStore { return &MemoryEventStore{ maxBytes: defaultMaxBytes, - store: make(map[string]map[StreamID]*dataList), + store: make(map[string]map[string]*dataList), } } // Open implements [EventStore.Open]. It ensures that the underlying data // structures for the given session are initialized and ready for use. -func (s *MemoryEventStore) Open(_ context.Context, sessionID string, streamID StreamID) error { +func (s *MemoryEventStore) Open(_ context.Context, sessionID, streamID string) error { s.mu.Lock() defer s.mu.Unlock() s.init(sessionID, streamID) @@ -275,10 +277,10 @@ func (s *MemoryEventStore) Open(_ context.Context, sessionID string, streamID St // given sessionID and streamID exists, creating it if necessary. It returns the // dataList associated with the specified IDs. // Requires s.mu. -func (s *MemoryEventStore) init(sessionID string, streamID StreamID) *dataList { +func (s *MemoryEventStore) init(sessionID, streamID string) *dataList { streamMap, ok := s.store[sessionID] if !ok { - streamMap = make(map[StreamID]*dataList) + streamMap = make(map[string]*dataList) s.store[sessionID] = streamMap } dl, ok := streamMap[streamID] @@ -290,7 +292,7 @@ func (s *MemoryEventStore) init(sessionID string, streamID StreamID) *dataList { } // Append implements [EventStore.Append] by recording data in memory. -func (s *MemoryEventStore) Append(_ context.Context, sessionID string, streamID StreamID, data []byte) error { +func (s *MemoryEventStore) Append(_ context.Context, sessionID, streamID string, data []byte) error { s.mu.Lock() defer s.mu.Unlock() dl := s.init(sessionID, streamID) @@ -307,7 +309,7 @@ func (s *MemoryEventStore) Append(_ context.Context, sessionID string, streamID var ErrEventsPurged = errors.New("data purged") // After implements [EventStore.After]. -func (s *MemoryEventStore) After(_ context.Context, sessionID string, streamID StreamID, index int) iter.Seq2[[]byte, error] { +func (s *MemoryEventStore) After(_ context.Context, sessionID, streamID string, index int) iter.Seq2[[]byte, error] { // Return the data items to yield. // We must copy, because dataList.removeFirst nils out slice elements. copyData := func() ([][]byte, error) { diff --git a/mcp/event_test.go b/mcp/event_test.go index ef4e080..dacf30e 100644 --- a/mcp/event_test.go +++ b/mcp/event_test.go @@ -105,8 +105,8 @@ func TestScanEvents(t *testing.T) { func TestMemoryEventStoreState(t *testing.T) { ctx := context.Background() - appendEvent := func(s *MemoryEventStore, sess string, str StreamID, data string) { - if err := s.Append(ctx, sess, str, []byte(data)); err != nil { + appendEvent := func(s *MemoryEventStore, sess, stream string, data string) { + if err := s.Append(ctx, sess, stream, []byte(data)); err != nil { t.Fatal(err) } } @@ -218,7 +218,7 @@ func TestMemoryEventStoreAfter(t *testing.T) { for _, tt := range []struct { sessionID string - streamID StreamID + streamID string index int want []string wantErr string // if non-empty, error should contain this string @@ -277,11 +277,11 @@ func BenchmarkMemoryEventStore(b *testing.B) { store.SetMaxBytes(test.limit) ctx := context.Background() sessionIDs := make([]string, test.sessions) - streamIDs := make([][3]StreamID, test.sessions) + streamIDs := make([][3]string, test.sessions) for i := range sessionIDs { sessionIDs[i] = fmt.Sprint(i) for j := range 3 { - streamIDs[i][j] = StreamID(randText()) + streamIDs[i][j] = randText() } } payload := make([]byte, test.datasize) diff --git a/mcp/streamable.go b/mcp/streamable.go index 8ac6f59..67f187e 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -396,8 +396,8 @@ func (t *StreamableServerTransport) Connect(ctx context.Context) (Connection, er jsonResponse: t.jsonResponse, incoming: make(chan jsonrpc.Message, 10), done: make(chan struct{}), - streams: make(map[StreamID]*stream), - requestStreams: make(map[jsonrpc.ID]StreamID), + streams: make(map[string]*stream), + requestStreams: make(map[jsonrpc.ID]string), } if t.connection.eventStore == nil { t.connection.eventStore = NewMemoryEventStore(nil) @@ -442,14 +442,14 @@ type streamableServerConn struct { // bound. If we deleted a stream when the response is sent, we would lose the ability // to replay if there was a cut just before the response was transmitted. // Perhaps we could have a TTL for streams that starts just after the response. - streams map[StreamID]*stream + streams map[string]*stream // requestStreams maps incoming requests to their logical stream ID. // // Lifecycle: requestStreams persist for the duration of the session. // // TODO: clean up once requests are handled. See the TODO for streams above. - requestStreams map[jsonrpc.ID]StreamID + requestStreams map[jsonrpc.ID]string } func (c *streamableServerConn) SessionID() string { @@ -466,7 +466,7 @@ func (c *streamableServerConn) SessionID() string { type stream struct { // id is the logical ID for the stream, unique within a session. // an empty string is used for messages that don't correlate with an incoming request. - id StreamID + id string // If isInitialize is set, the stream is in response to an initialize request, // and therefore should include the session ID header. @@ -500,7 +500,7 @@ type stream struct { requests map[jsonrpc.ID]struct{} } -func (c *streamableServerConn) newStream(ctx context.Context, id StreamID, isInitialize, jsonResponse bool) (*stream, error) { +func (c *streamableServerConn) newStream(ctx context.Context, id string, isInitialize, jsonResponse bool) (*stream, error) { if err := c.eventStore.Open(ctx, c.sessionID, id); err != nil { return nil, err } @@ -517,10 +517,6 @@ func signalChanPtr() *chan struct{} { return &c } -// A StreamID identifies a stream of SSE events. It is globally unique. -// [ServerSession]. -type StreamID string - // We track the incoming request ID inside the handler context using // idContextValue, so that notifications and server->client calls that occur in // the course of handling incoming requests are correlated with the incoming @@ -569,7 +565,7 @@ func (t *StreamableServerTransport) ServeHTTP(w http.ResponseWriter, req *http.R // It returns an HTTP status code and error message. func (c *streamableServerConn) serveGET(w http.ResponseWriter, req *http.Request) { // connID 0 corresponds to the default GET request. - id := StreamID("") + id := "" // By default, we haven't seen a last index. Since indices start at 0, we represent // that by -1. This is incremented just before each event is written, in streamResponse // around L407. @@ -669,7 +665,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // notifications or server->client requests made in the course of handling. // Update accounting for this incoming payload. if len(requests) > 0 { - stream, err = c.newStream(req.Context(), StreamID(randText()), isInitialize, c.jsonResponse) + stream, err = c.newStream(req.Context(), randText(), isInitialize, c.jsonResponse) if err != nil { http.Error(w, fmt.Sprintf("storing stream: %v", err), http.StatusInternalServerError) return @@ -860,7 +856,7 @@ func (c *streamableServerConn) messages(ctx context.Context, stream *stream, per // streamID and message index idx. // // See also [parseEventID]. -func formatEventID(sid StreamID, idx int) string { +func formatEventID(sid string, idx int) string { return fmt.Sprintf("%s_%d", sid, idx) } @@ -868,17 +864,17 @@ func formatEventID(sid StreamID, idx int) string { // index. // // See also [formatEventID]. -func parseEventID(eventID string) (sid StreamID, idx int, ok bool) { +func parseEventID(eventID string) (streamID string, idx int, ok bool) { parts := strings.Split(eventID, "_") if len(parts) != 2 { return "", 0, false } - stream := StreamID(parts[0]) + streamID = parts[0] idx, err := strconv.Atoi(parts[1]) if err != nil || idx < 0 { return "", 0, false } - return StreamID(stream), idx, true + return streamID, idx, true } // Read implements the [Connection] interface. @@ -922,7 +918,7 @@ func (c *streamableServerConn) Write(ctx context.Context, msg jsonrpc.Message) e // // For messages sent outside of a request context, this is the default // connection "". - var forStream StreamID + var forStream string if forRequest.IsValid() { c.mu.Lock() forStream = c.requestStreams[forRequest] diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 79e9645..e24478b 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1139,7 +1139,7 @@ func mustMarshal(v any) json.RawMessage { func TestEventID(t *testing.T) { tests := []struct { - sid StreamID + sid string idx int }{ {"0", 0}, From 02f0b2528642b41fa9be80f7f4773cbd3eb28994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Friedrich=20Gro=C3=9Fe?= Date: Thu, 18 Sep 2025 18:21:02 +0200 Subject: [PATCH 062/418] Move GetSessionID closure into ServerOptions (#488) Fixes: #478. --- mcp/server.go | 15 +++++++++++++++ mcp/streamable.go | 15 +-------------- mcp/streamable_test.go | 27 ++++++++++++++++++--------- 3 files changed, 34 insertions(+), 23 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index fd38a89..27de09a 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -83,6 +83,16 @@ type ServerOptions struct { // If true, advertises the tools capability during initialization, // even if no tools have been registered. HasTools bool + + // GetSessionID provides the next session ID to use for an incoming request. + // If nil, a default randomly generated ID will be used. + // + // Session IDs should be globally unique across the scope of the server, + // which may span multiple processes in the case of distributed servers. + // + // As a special case, if GetSessionID returns the empty string, the + // Mcp-Session-Id header will not be set. + GetSessionID func() string } // NewServer creates a new MCP server. The resulting server has no features: @@ -114,6 +124,11 @@ func NewServer(impl *Implementation, options *ServerOptions) *Server { if opts.UnsubscribeHandler != nil && opts.SubscribeHandler == nil { panic("UnsubscribeHandler requires SubscribeHandler") } + + if opts.GetSessionID == nil { + opts.GetSessionID = randText + } + return &Server{ impl: impl, opts: opts, diff --git a/mcp/streamable.go b/mcp/streamable.go index 67f187e..4ab343b 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -50,16 +50,6 @@ type StreamableHTTPHandler struct { // StreamableHTTPOptions configures the StreamableHTTPHandler. type StreamableHTTPOptions struct { - // GetSessionID provides the next session ID to use for an incoming request. - // If nil, a default randomly generated ID will be used. - // - // Session IDs should be globally unique across the scope of the server, - // which may span multiple processes in the case of distributed servers. - // - // As a special case, if GetSessionID returns the empty string, the - // Mcp-Session-Id header will not be set. - GetSessionID func() string - // Stateless controls whether the session is 'stateless'. // // A stateless server does not validate the Mcp-Session-Id header, and uses a @@ -92,9 +82,6 @@ func NewStreamableHTTPHandler(getServer func(*http.Request) *Server, opts *Strea if opts != nil { h.opts = *opts } - if h.opts.GetSessionID == nil { - h.opts.GetSessionID = randText - } return h } @@ -233,7 +220,7 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque if sessionID == "" { // In stateless mode, sessionID may be nonempty even if there's no // existing transport. - sessionID = h.opts.GetSessionID() + sessionID = server.opts.GetSessionID() } transport = &StreamableServerTransport{ SessionID: sessionID, diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index e24478b..3b967f8 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -409,15 +409,14 @@ func testClientReplay(t *testing.T, test clientReplayTest) { } func TestServerTransportCleanup(t *testing.T) { - server := NewServer(testImpl, &ServerOptions{KeepAlive: 10 * time.Millisecond}) - nClient := 3 var mu sync.Mutex var id int = -1 // session id starting from "0", "1", "2"... chans := make(map[string]chan struct{}, nClient) - handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{ + server := NewServer(testImpl, &ServerOptions{ + KeepAlive: 10 * time.Millisecond, GetSessionID: func() string { mu.Lock() defer mu.Unlock() @@ -430,6 +429,7 @@ func TestServerTransportCleanup(t *testing.T) { }, }) + handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil) handler.onTransportDeletion = func(sessionID string) { chans[sessionID] <- struct{}{} } @@ -1199,8 +1199,6 @@ func TestStreamableStateless(t *testing.T) { } return &CallToolResult{Content: []Content{&TextContent{Text: "hi " + args.Name}}}, nil, nil } - server := NewServer(testImpl, nil) - AddTool(server, &Tool{Name: "greet", Description: "say hi"}, sayHi) requests := []streamableRequest{ { @@ -1263,9 +1261,15 @@ func TestStreamableStateless(t *testing.T) { } } - sessionlessHandler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{ - GetSessionID: func() string { return "" }, - Stateless: true, + sessionlessHandler := NewStreamableHTTPHandler(func(*http.Request) *Server { + // Return a stateless server which never assigns a session ID. + server := NewServer(testImpl, &ServerOptions{ + GetSessionID: func() string { return "" }, + }) + AddTool(server, &Tool{Name: "greet", Description: "say hi"}, sayHi) + return server + }, &StreamableHTTPOptions{ + Stateless: true, }) // First, test the "sessionless" stateless mode, where there is no session ID. @@ -1279,7 +1283,12 @@ func TestStreamableStateless(t *testing.T) { // This can be used by tools to look up application state preserved across // subsequent requests. requests[0].wantSessionID = true // now expect a session ID for initialize - statelessHandler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{ + statelessHandler := NewStreamableHTTPHandler(func(*http.Request) *Server { + // Return a server with default options which should assign a random session ID. + server := NewServer(testImpl, nil) + AddTool(server, &Tool{Name: "greet", Description: "say hi"}, sayHi) + return server + }, &StreamableHTTPOptions{ Stateless: true, }) t.Run("stateless", func(t *testing.T) { From fed510663181dc66464f9f324ac694332dbdd8ef Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Thu, 18 Sep 2025 12:27:02 -0400 Subject: [PATCH 063/418] mcp: add prompt feature doc (#487) --- docs/client.md | 2 +- docs/server.md | 88 ++++++++++++++++++++++++++++++++++++- internal/docs/client.src.md | 2 +- internal/docs/server.src.md | 22 +++++++++- mcp/server_example_test.go | 83 ++++++++++++++++++++++++++++++++++ 5 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 mcp/server_example_test.go diff --git a/docs/client.md b/docs/client.md index 13c12d5..cbc4db8 100644 --- a/docs/client.md +++ b/docs/client.md @@ -13,7 +13,7 @@ The SDK supports this as follows: **Client-side**: The SDK client always has the `roots.listChanged` capability. To add roots to a client, use the -[`Client.AddRoots`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#AddRoots) +[`Client.AddRoots`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Client.AddRoots) and [`Client.RemoveRoots`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Client.RemoveRoots) methods. If any servers are already [connected](protocol.md#lifecycle) to the diff --git a/docs/server.md b/docs/server.md index 5b17f2e..e6af5d9 100644 --- a/docs/server.md +++ b/docs/server.md @@ -11,7 +11,93 @@ ## Prompts - +**Server-side**: +MCP servers can provide LLM prompt templates (called simply _prompts_) to clients. +Every prompt has a required name which identifies it, and a set of named arguments, which are strings. +Construct a prompt with a name and descriptions of its arguments. +Associated with each prompt is a handler that expands the template given values for its arguments. +Use [`Server.AddPrompt`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.AddPrompt) +to add a prompt along with its handler. +If `AddPrompt` is called before a server is connected, the server will have the `prompts` capability. +If all prompts are to be added after connection, set [`ServerOptions.HasPrompts`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.HasPrompts) +to advertise the capability. + +**Client-side**: +To list the server's prompts, call +Call [`ClientSession.Prompts`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Prompts) to get an iterator. +If needed, you can use the lower-level +[`ClientSession.ListPrompts`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.ListPrompts) to list the server's prompts. +Call [`ClientSession.GetPrompt`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.GetPrompt) to retrieve a prompt by name, providing +arguments for expansion. +Set [`ClientOptions.PromptListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.PromptListChangedHandler) to be notified of changes in the list of prompts. + +```go +func Example_prompts() { + ctx := context.Background() + + promptHandler := func(ctx context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return &mcp.GetPromptResult{ + Description: "Hi prompt", + Messages: []*mcp.PromptMessage{ + { + Role: "user", + Content: &mcp.TextContent{Text: "Say hi to " + req.Params.Arguments["name"]}, + }, + }, + }, nil + } + + // Create a server with a single prompt. + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + prompt := &mcp.Prompt{ + Name: "greet", + Arguments: []*mcp.PromptArgument{ + { + Name: "name", + Description: "the name of the person to greet", + Required: true, + }, + }, + } + s.AddPrompt(prompt, promptHandler) + + // Create a client. + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + + // Connect the server and client. + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + + // List the prompts. + for p, err := range cs.Prompts(ctx, nil) { + if err != nil { + log.Fatal(err) + } + fmt.Println(p.Name) + } + + // Get the prompt. + res, err := cs.GetPrompt(ctx, &mcp.GetPromptParams{ + Name: "greet", + Arguments: map[string]string{"name": "Pat"}, + }) + if err != nil { + log.Fatal(err) + } + for _, msg := range res.Messages { + fmt.Println(msg.Role, msg.Content.(*mcp.TextContent).Text) + } + // Output: + // greet + // user Say hi to Pat +} +``` ## Resources diff --git a/internal/docs/client.src.md b/internal/docs/client.src.md index f342719..fc37d45 100644 --- a/internal/docs/client.src.md +++ b/internal/docs/client.src.md @@ -10,7 +10,7 @@ The SDK supports this as follows: **Client-side**: The SDK client always has the `roots.listChanged` capability. To add roots to a client, use the -[`Client.AddRoots`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#AddRoots) +[`Client.AddRoots`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Client.AddRoots) and [`Client.RemoveRoots`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Client.RemoveRoots) methods. If any servers are already [connected](protocol.md#lifecycle) to the diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index a131bcd..07ed6cb 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -4,7 +4,27 @@ ## Prompts - +**Server-side**: +MCP servers can provide LLM prompt templates (called simply _prompts_) to clients. +Every prompt has a required name which identifies it, and a set of named arguments, which are strings. +Construct a prompt with a name and descriptions of its arguments. +Associated with each prompt is a handler that expands the template given values for its arguments. +Use [`Server.AddPrompt`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.AddPrompt) +to add a prompt along with its handler. +If `AddPrompt` is called before a server is connected, the server will have the `prompts` capability. +If all prompts are to be added after connection, set [`ServerOptions.HasPrompts`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.HasPrompts) +to advertise the capability. + +**Client-side**: +To list the server's prompts, call +Call [`ClientSession.Prompts`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Prompts) to get an iterator. +If needed, you can use the lower-level +[`ClientSession.ListPrompts`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.ListPrompts) to list the server's prompts. +Call [`ClientSession.GetPrompt`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.GetPrompt) to retrieve a prompt by name, providing +arguments for expansion. +Set [`ClientOptions.PromptListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.PromptListChangedHandler) to be notified of changes in the list of prompts. + +%include ../../mcp/server_example_test.go prompts - ## Resources diff --git a/mcp/server_example_test.go b/mcp/server_example_test.go new file mode 100644 index 0000000..d6b4d7e --- /dev/null +++ b/mcp/server_example_test.go @@ -0,0 +1,83 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp_test + +import ( + "context" + "fmt" + "log" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// !+prompts + +func Example_prompts() { + ctx := context.Background() + + promptHandler := func(ctx context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return &mcp.GetPromptResult{ + Description: "Hi prompt", + Messages: []*mcp.PromptMessage{ + { + Role: "user", + Content: &mcp.TextContent{Text: "Say hi to " + req.Params.Arguments["name"]}, + }, + }, + }, nil + } + + // Create a server with a single prompt. + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + prompt := &mcp.Prompt{ + Name: "greet", + Arguments: []*mcp.PromptArgument{ + { + Name: "name", + Description: "the name of the person to greet", + Required: true, + }, + }, + } + s.AddPrompt(prompt, promptHandler) + + // Create a client. + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + + // Connect the server and client. + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + + // List the prompts. + for p, err := range cs.Prompts(ctx, nil) { + if err != nil { + log.Fatal(err) + } + fmt.Println(p.Name) + } + + // Get the prompt. + res, err := cs.GetPrompt(ctx, &mcp.GetPromptParams{ + Name: "greet", + Arguments: map[string]string{"name": "Pat"}, + }) + if err != nil { + log.Fatal(err) + } + for _, msg := range res.Messages { + fmt.Println(msg.Role, msg.Content.(*mcp.TextContent).Text) + } + // Output: + // greet + // user Say hi to Pat +} + +// !-prompts From 49d45a809707cbf19808614e5208012bed849da7 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Thu, 18 Sep 2025 13:31:54 -0400 Subject: [PATCH 064/418] docs: document pagination (#491) For #442 --- docs/server.md | 26 +++++++++++++++++++++++++- internal/docs/server.src.md | 26 +++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/docs/server.md b/docs/server.md index e6af5d9..4d6192f 100644 --- a/docs/server.md +++ b/docs/server.md @@ -121,4 +121,28 @@ func Example_prompts() { ### Pagination - +Server-side feature lists may be +[paginated](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/pagination), +using cursors. The SDK supports this by default. + +**Client-side**: The `ClientSession` provides methods returning +[iterators](https://go.dev/blog/range-functions) for each feature type. +These iterators are an `iter.Seq2[Feature, error]`, where the error value +indicates whether page retrieval failed. + +- [`ClientSession.Prompts`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Prompts) + iterates prompts. +- [`ClientSession.Resource`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Resource) + iterates resources. +- [`ClientSession.ResourceTemplates`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.ResourceTemplates) + iterates resource templates. +- [`ClientSession.Tools`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Tools) + iterates tools. + +The `ClientSession` also exposes `ListXXX` methods for fine-grained control +over pagination. + +**Server-side**: pagination is on by default, so in general nothing is required +server-side. However, you may use +[`ServerOptions.PageSize`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.PageSize) +to customize the page size. diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index 07ed6cb..32f80e9 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -48,4 +48,28 @@ Set [`ClientOptions.PromptListChangedHandler`](https://pkg.go.dev/github.com/mod ### Pagination - +Server-side feature lists may be +[paginated](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/pagination), +using cursors. The SDK supports this by default. + +**Client-side**: The `ClientSession` provides methods returning +[iterators](https://go.dev/blog/range-functions) for each feature type. +These iterators are an `iter.Seq2[Feature, error]`, where the error value +indicates whether page retrieval failed. + +- [`ClientSession.Prompts`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Prompts) + iterates prompts. +- [`ClientSession.Resource`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Resource) + iterates resources. +- [`ClientSession.ResourceTemplates`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.ResourceTemplates) + iterates resource templates. +- [`ClientSession.Tools`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Tools) + iterates tools. + +The `ClientSession` also exposes `ListXXX` methods for fine-grained control +over pagination. + +**Server-side**: pagination is on by default, so in general nothing is required +server-side. However, you may use +[`ServerOptions.PageSize`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.PageSize) +to customize the page size. From 1696b595c647035e01081254a5a44896b03c0cb4 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Thu, 18 Sep 2025 14:24:45 -0400 Subject: [PATCH 065/418] mcp: fix several goroutine leaks in tests (#494) Fix some (but not all) goroutine leaks in tests. Others were nontrivial, because they relate to streamable http. For #489. --- mcp/client_example_test.go | 5 +++- mcp/mcp_test.go | 49 +++++++++++++++++++++-------------- mcp/server_example_test.go | 1 + mcp/transport_example_test.go | 9 +++++-- mcp/transport_test.go | 1 + 5 files changed, 43 insertions(+), 22 deletions(-) diff --git a/mcp/client_example_test.go b/mcp/client_example_test.go index 3c3c383..bba3da4 100644 --- a/mcp/client_example_test.go +++ b/mcp/client_example_test.go @@ -45,9 +45,12 @@ func Example_roots() { if _, err := s.Connect(ctx, t1, nil); err != nil { log.Fatal(err) } - if _, err := c.Connect(ctx, t2, nil); err != nil { + + clientSession, err := c.Connect(ctx, t2, nil) + if err != nil { log.Fatal(err) } + defer clientSession.Close() // ...and add a root. The server is notified about the change. c.AddRoots(&mcp.Root{URI: "file://b"}) diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index fa941bf..9cc105c 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -592,7 +592,10 @@ func errorCode(err error) int64 { // // The caller should cancel either the client connection or server connection // when the connections are no longer needed. -func basicConnection(t *testing.T, config func(*Server)) (*ClientSession, *ServerSession) { +// +// The returned func cleans up by closing the client and waiting for the server +// to shut down. +func basicConnection(t *testing.T, config func(*Server)) (*ClientSession, *ServerSession, func()) { return basicClientServerConnection(t, nil, nil, config) } @@ -604,7 +607,10 @@ func basicConnection(t *testing.T, config func(*Server)) (*ClientSession, *Serve // // The caller should cancel either the client connection or server connection // when the connections are no longer needed. -func basicClientServerConnection(t *testing.T, client *Client, server *Server, config func(*Server)) (*ClientSession, *ServerSession) { +// +// The returned func cleans up by closing the client and waiting for the server +// to shut down. +func basicClientServerConnection(t *testing.T, client *Client, server *Server, config func(*Server)) (*ClientSession, *ServerSession, func()) { t.Helper() ctx := context.Background() @@ -628,14 +634,17 @@ func basicClientServerConnection(t *testing.T, client *Client, server *Server, c if err != nil { t.Fatal(err) } - return cs, ss + return cs, ss, func() { + cs.Close() + ss.Wait() + } } func TestServerClosing(t *testing.T) { - cs, ss := basicConnection(t, func(s *Server) { + cs, ss, cleanup := basicConnection(t, func(s *Server) { AddTool(s, greetTool(), sayHi) }) - defer cs.Close() + defer cleanup() ctx := context.Background() var wg sync.WaitGroup @@ -715,10 +724,10 @@ func TestCancellation(t *testing.T) { } return nil, nil, nil } - cs, _ := basicConnection(t, func(s *Server) { + cs, _, cleanup := basicConnection(t, func(s *Server) { AddTool(s, &Tool{Name: "slow", InputSchema: &jsonschema.Schema{Type: "object"}}, slowTool) }) - defer cs.Close() + defer cleanup() ctx, cancel := context.WithCancel(context.Background()) go cs.CallTool(ctx, &CallToolParams{Name: "slow"}) @@ -741,13 +750,10 @@ func TestMiddleware(t *testing.T) { t.Fatal(err) } // Wait for the server to exit after the client closes its connection. - var clientWG sync.WaitGroup - clientWG.Add(1) - go func() { + defer func() { if err := ss.Wait(); err != nil { t.Errorf("server failed: %v", err) } - clientWG.Done() }() var sbuf, cbuf bytes.Buffer @@ -767,6 +773,8 @@ func TestMiddleware(t *testing.T) { if err != nil { t.Fatal(err) } + defer cs.Close() + if _, err := cs.ListTools(ctx, nil); err != nil { t.Fatal(err) } @@ -1511,7 +1519,7 @@ func TestKeepAliveFailure(t *testing.T) { func TestAddTool_DuplicateNoPanicAndNoDuplicate(t *testing.T) { // Adding the same tool pointer twice should not panic and should not // produce duplicates in the server's tool list. - cs, _ := basicConnection(t, func(s *Server) { + cs, _, cleanup := basicConnection(t, func(s *Server) { // Use two distinct Tool instances with the same name but different // descriptions to ensure the second replaces the first // This case was written specifically to reproduce a bug where duplicate tools where causing jsonschema errors @@ -1520,7 +1528,7 @@ func TestAddTool_DuplicateNoPanicAndNoDuplicate(t *testing.T) { s.AddTool(t1, nopHandler) s.AddTool(t2, nopHandler) }) - defer cs.Close() + defer cleanup() ctx := context.Background() res, err := cs.ListTools(ctx, nil) @@ -1568,7 +1576,7 @@ func TestSynchronousNotifications(t *testing.T) { }, } server := NewServer(testImpl, serverOpts) - cs, ss := basicClientServerConnection(t, client, server, func(s *Server) { + cs, ss, cleanup := basicClientServerConnection(t, client, server, func(s *Server) { AddTool(s, &Tool{Name: "tool"}, func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { if !rootsChanged.Load() { return nil, nil, fmt.Errorf("didn't get root change notification") @@ -1576,6 +1584,7 @@ func TestSynchronousNotifications(t *testing.T) { return new(CallToolResult), nil, nil }) }) + defer cleanup() t.Run("from client", func(t *testing.T) { client.AddRoots(&Root{Name: "myroot", URI: "file://foo"}) @@ -1617,7 +1626,7 @@ func TestNoDistributedDeadlock(t *testing.T) { }, } client := NewClient(testImpl, clientOpts) - cs, _ := basicClientServerConnection(t, client, nil, func(s *Server) { + cs, _, cleanup := basicClientServerConnection(t, client, nil, func(s *Server) { AddTool(s, &Tool{Name: "tool1"}, func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { req.Session.CreateMessage(ctx, new(CreateMessageParams)) return new(CallToolResult), nil, nil @@ -1627,7 +1636,7 @@ func TestNoDistributedDeadlock(t *testing.T) { return new(CallToolResult), nil, nil }) }) - defer cs.Close() + defer cleanup() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -1651,7 +1660,7 @@ func TestPointerArgEquivalence(t *testing.T) { type output struct { Out string } - cs, _ := basicConnection(t, func(s *Server) { + cs, _, cleanup := basicConnection(t, func(s *Server) { // Add two equivalent tools, one of which operates in the 'pointer' realm, // the other of which does not. // @@ -1686,7 +1695,7 @@ func TestPointerArgEquivalence(t *testing.T) { } }) }) - defer cs.Close() + defer cleanup() ctx := context.Background() tools, err := cs.ListTools(ctx, nil) @@ -1758,7 +1767,9 @@ func TestComplete(t *testing.T) { }, } server := NewServer(testImpl, serverOpts) - cs, _ := basicClientServerConnection(t, nil, server, func(s *Server) {}) + cs, _, cleanup := basicClientServerConnection(t, nil, server, func(s *Server) {}) + defer cleanup() + result, err := cs.Complete(context.Background(), &CompleteParams{ Argument: CompleteParamsArgument{ Name: "language", diff --git a/mcp/server_example_test.go b/mcp/server_example_test.go index d6b4d7e..16f19e2 100644 --- a/mcp/server_example_test.go +++ b/mcp/server_example_test.go @@ -55,6 +55,7 @@ func Example_prompts() { if err != nil { log.Fatal(err) } + defer cs.Close() // List the prompts. for p, err := range cs.Prompts(ctx, nil) { diff --git a/mcp/transport_example_test.go b/mcp/transport_example_test.go index 064ab0f..ab54a42 100644 --- a/mcp/transport_example_test.go +++ b/mcp/transport_example_test.go @@ -24,16 +24,21 @@ func ExampleLoggingTransport() { ctx := context.Background() t1, t2 := mcp.NewInMemoryTransports() server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) - if _, err := server.Connect(ctx, t1, nil); err != nil { + serverSession, err := server.Connect(ctx, t1, nil) + if err != nil { log.Fatal(err) } + defer serverSession.Wait() client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) var b bytes.Buffer logTransport := &mcp.LoggingTransport{Transport: t2, Writer: &b} - if _, err := client.Connect(ctx, logTransport, nil); err != nil { + clientSession, err := client.Connect(ctx, logTransport, nil) + if err != nil { log.Fatal(err) } + defer clientSession.Close() + // Sort for stability: reads are concurrent to writes. for _, line := range slices.Sorted(strings.SplitSeq(b.String(), "\n")) { fmt.Println(line) diff --git a/mcp/transport_test.go b/mcp/transport_test.go index d40ce10..10804a8 100644 --- a/mcp/transport_test.go +++ b/mcp/transport_test.go @@ -25,6 +25,7 @@ func TestBatchFraming(t *testing.T) { r, w := io.Pipe() tport := newIOConn(rwc{r, w}) tport.outgoingBatch = make([]jsonrpc.Message, 0, 2) + defer tport.Close() // Read the two messages into a channel, for easy testing later. read := make(chan jsonrpc.Message) From ff81f2f35a5873adcabf8b8aafda706c038a0534 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Thu, 18 Sep 2025 14:25:35 -0400 Subject: [PATCH 066/418] docs: add auth and security sections (#492) For #442. --- docs/protocol.md | 65 +++++++++++++++++++++++++++++++++-- internal/docs/protocol.src.md | 60 ++++++++++++++++++++++++++++++-- 2 files changed, 121 insertions(+), 4 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index 75c5d12..e3b9e8c 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -8,7 +8,12 @@ 1. [Custom transports](#custom-transports) 1. [Concurrency](#concurrency) 1. [Authorization](#authorization) + 1. [Server](#server) + 1. [Client](#client) 1. [Security](#security) + 1. [Confused Deputy](#confused-deputy) + 1. [Token Passthrough](#token-passthrough) + 1. [Session Hijacking](#session-hijacking) 1. [Utilities](#utilities) 1. [Cancellation](#cancellation) 1. [Ping](#ping) @@ -232,11 +237,67 @@ for more background. ## Authorization - +### Server + +To write an MCP server that performs authorization, +use [`RequireBearerToken`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#RequireBearerToken). +This function is middleware that wraps an HTTP handler, such as the one returned +by [`NewStreamableHTTPHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#NewStreamableHTTPHandler), to provide support for verifying bearer tokens. +The middleware function checks every request for an Authorization header with a bearer token, +and invokes the +[`TokenVerifier`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenVerifier) + passed to `RequireBearerToken` to parse the token and perform validation. +The middleware function checks expiration and scopes (if they are provided in +[`RequireBearerTokenOptions.Scopes`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#RequireBearerTokenOptions.Scopes)), so the +`TokenVerifer` doesn't have to. +If [`RequireBearerTokenOptions.ResourceMetadataURL`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#RequireBearerTokenOptions.ResourceMetadataURL) is set and verification fails, +the middleware function sets the WWW-Authenticate header as required by the [Protected Resource +Metadata spec](https://datatracker.ietf.org/doc/html/rfc9728). + +The [_auth middleware example_](https://github.com/modelcontextprotocol/go-sdk/tree/main/examples/server/auth-middleware) shows how to implement authorization for both JWT tokens and API keys. + +### Client + +Client-side OAuth is implemented by setting +[`StreamableClientTransport.HTTPClient`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk@v0.5.0/mcp#StreamableClientTransport.HTTPClient) to a custom [`http.Client`](https://pkg.go.dev/net/http#Client) +Additional support is forthcoming; see #493. ## Security - +Here we discuss the mitigations described under +the MCP spec's [Security Best Practices](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices) section, and how we handle them. + +### Confused Deputy + +The [mitigation](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#mitigation), obtaining user consent for dynamically registered clients, +happens on the MCP client. At present we don't provide client-side OAuth support. + + +### Token Passthrough + +The [mitigation](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#mitigation-2), accepting only tokens that were issued for the server, depends on the structure +of tokens and is the responsibility of the +[`TokenVerifier`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenVerifier) +provided to +[`RequireBearerToken`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#RequireBearerToken). + +### Session Hijacking + +The [mitigations](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#mitigation-3) are as follows: + +- _Verify all inbound requests_. The [`RequireBearerToken`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#RequireBearerToken) +middleware function will verify all HTTP requests that it receives. It is the +user's responsibility to wrap that function around all handlers in their server. + +- _Secure session IDs_. This SDK generates cryptographically secure session IDs by default. +If you create your own with +[`ServerOptions.GetSessionID`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.GetSessionID), it is your responsibility to ensure they are secure. +If you are using Go 1.24 or above, +we recommend using [`crypto/rand.Text`](https://pkg.go.dev/crypto/rand#Text) + +- _Binding session IDs to user information_. This is an application requirement, out of scope +for the SDK. You can create your own session IDs by setting +[`ServerOptions.GetSessionID`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.GetSessionID). ## Utilities diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index 2bb954b..0abd5aa 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -164,11 +164,67 @@ for more background. ## Authorization - +### Server + +To write an MCP server that performs authorization, +use [`RequireBearerToken`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#RequireBearerToken). +This function is middleware that wraps an HTTP handler, such as the one returned +by [`NewStreamableHTTPHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#NewStreamableHTTPHandler), to provide support for verifying bearer tokens. +The middleware function checks every request for an Authorization header with a bearer token, +and invokes the +[`TokenVerifier`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenVerifier) + passed to `RequireBearerToken` to parse the token and perform validation. +The middleware function checks expiration and scopes (if they are provided in +[`RequireBearerTokenOptions.Scopes`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#RequireBearerTokenOptions.Scopes)), so the +`TokenVerifer` doesn't have to. +If [`RequireBearerTokenOptions.ResourceMetadataURL`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#RequireBearerTokenOptions.ResourceMetadataURL) is set and verification fails, +the middleware function sets the WWW-Authenticate header as required by the [Protected Resource +Metadata spec](https://datatracker.ietf.org/doc/html/rfc9728). + +The [_auth middleware example_](https://github.com/modelcontextprotocol/go-sdk/tree/main/examples/server/auth-middleware) shows how to implement authorization for both JWT tokens and API keys. + +### Client + +Client-side OAuth is implemented by setting +[`StreamableClientTransport.HTTPClient`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk@v0.5.0/mcp#StreamableClientTransport.HTTPClient) to a custom [`http.Client`](https://pkg.go.dev/net/http#Client) +Additional support is forthcoming; see #493. ## Security - +Here we discuss the mitigations described under +the MCP spec's [Security Best Practices](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices) section, and how we handle them. + +### Confused Deputy + +The [mitigation](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#mitigation), obtaining user consent for dynamically registered clients, +happens on the MCP client. At present we don't provide client-side OAuth support. + + +### Token Passthrough + +The [mitigation](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#mitigation-2), accepting only tokens that were issued for the server, depends on the structure +of tokens and is the responsibility of the +[`TokenVerifier`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenVerifier) +provided to +[`RequireBearerToken`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#RequireBearerToken). + +### Session Hijacking + +The [mitigations](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#mitigation-3) are as follows: + +- _Verify all inbound requests_. The [`RequireBearerToken`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#RequireBearerToken) +middleware function will verify all HTTP requests that it receives. It is the +user's responsibility to wrap that function around all handlers in their server. + +- _Secure session IDs_. This SDK generates cryptographically secure session IDs by default. +If you create your own with +[`ServerOptions.GetSessionID`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.GetSessionID), it is your responsibility to ensure they are secure. +If you are using Go 1.24 or above, +we recommend using [`crypto/rand.Text`](https://pkg.go.dev/crypto/rand#Text) + +- _Binding session IDs to user information_. This is an application requirement, out of scope +for the SDK. You can create your own session IDs by setting +[`ServerOptions.GetSessionID`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.GetSessionID). ## Utilities From 28753c9f1227c253f12eabb74a3e20d2a6e91016 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Thu, 18 Sep 2025 14:27:35 -0400 Subject: [PATCH 067/418] docs: document completion support (#495) --- docs/server.md | 46 ++++++++++++++++++++++++++++-- examples/server/completion/main.go | 2 ++ internal/docs/server.src.md | 18 ++++++++++-- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/docs/server.md b/docs/server.md index 4d6192f..f59e2c7 100644 --- a/docs/server.md +++ b/docs/server.md @@ -109,11 +109,51 @@ func Example_prompts() { ## Utilities - - ### Completion - +To support the +[completion](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/completion) +capability, the server needs a completion handler. + +**Client-side**: completion is called using the +[`ClientSession.Complete`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Complete) +method. + +**Server-side**: completion is enabled by setting +[`ServerOptions.CompletionHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.CompletionHandler). +If this field is set to a non-nil value, the server will advertise the +`completions` server capability, and use this handler to respond to completion +requests. + +```go +myCompletionHandler := func(_ context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResult, error) { + // In a real application, you'd implement actual completion logic here. + // For this example, we return a fixed set of suggestions. + var suggestions []string + switch req.Params.Ref.Type { + case "ref/prompt": + suggestions = []string{"suggestion1", "suggestion2", "suggestion3"} + case "ref/resource": + suggestions = []string{"suggestion4", "suggestion5", "suggestion6"} + default: + return nil, fmt.Errorf("unrecognized content type %s", req.Params.Ref.Type) + } + + return &mcp.CompleteResult{ + Completion: mcp.CompletionResultDetails{ + HasMore: false, + Total: len(suggestions), + Values: suggestions, + }, + }, nil +} + +// Create the MCP Server instance and assign the handler. +// No server running, just showing the configuration. +_ = mcp.NewServer(&mcp.Implementation{Name: "server"}, &mcp.ServerOptions{ + CompletionHandler: myCompletionHandler, +}) +``` ### Logging diff --git a/examples/server/completion/main.go b/examples/server/completion/main.go index b0a991f..5220b0e 100644 --- a/examples/server/completion/main.go +++ b/examples/server/completion/main.go @@ -16,6 +16,7 @@ import ( // a CompletionHandler to an MCP Server's options. func main() { // Define your custom CompletionHandler logic. + // !+completionhandler myCompletionHandler := func(_ context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResult, error) { // In a real application, you'd implement actual completion logic here. // For this example, we return a fixed set of suggestions. @@ -43,6 +44,7 @@ func main() { _ = mcp.NewServer(&mcp.Implementation{Name: "server"}, &mcp.ServerOptions{ CompletionHandler: myCompletionHandler, }) + // !-completionhandler log.Println("MCP Server instance created with a CompletionHandler assigned (but not running).") log.Println("This example demonstrates configuration, not live interaction.") diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index 32f80e9..50619c6 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -36,11 +36,23 @@ Set [`ClientOptions.PromptListChangedHandler`](https://pkg.go.dev/github.com/mod ## Utilities - - ### Completion - +To support the +[completion](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/completion) +capability, the server needs a completion handler. + +**Client-side**: completion is called using the +[`ClientSession.Complete`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Complete) +method. + +**Server-side**: completion is enabled by setting +[`ServerOptions.CompletionHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.CompletionHandler). +If this field is set to a non-nil value, the server will advertise the +`completions` server capability, and use this handler to respond to completion +requests. + +%include ../../examples/server/completion/main.go completionhandler - ### Logging From 5985a7cbba537e4039224d85114d547608e99ade Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Fri, 19 Sep 2025 10:48:59 -0400 Subject: [PATCH 068/418] docs: document tool support (#498) Document tool support, and tweak the wording for prompts. For #442 --- docs/client.md | 5 +- docs/server.md | 209 ++++++++++++++++++++++++++++++++---- docs/troubleshooting.md | 9 +- internal/docs/server.src.md | 145 +++++++++++++++++++++---- mcp/client.go | 5 +- mcp/protocol.go | 19 ++-- mcp/tool_example_test.go | 18 +++- 7 files changed, 356 insertions(+), 54 deletions(-) diff --git a/docs/client.md b/docs/client.md index cbc4db8..2cbe082 100644 --- a/docs/client.md +++ b/docs/client.md @@ -56,9 +56,12 @@ func Example_roots() { if _, err := s.Connect(ctx, t1, nil); err != nil { log.Fatal(err) } - if _, err := c.Connect(ctx, t2, nil); err != nil { + + clientSession, err := c.Connect(ctx, t2, nil) + if err != nil { log.Fatal(err) } + defer clientSession.Close() // ...and add a root. The server is notified about the change. c.AddRoots(&mcp.Root{URI: "file://b"}) diff --git a/docs/server.md b/docs/server.md index f59e2c7..3de5c12 100644 --- a/docs/server.md +++ b/docs/server.md @@ -11,25 +11,32 @@ ## Prompts -**Server-side**: -MCP servers can provide LLM prompt templates (called simply _prompts_) to clients. -Every prompt has a required name which identifies it, and a set of named arguments, which are strings. -Construct a prompt with a name and descriptions of its arguments. -Associated with each prompt is a handler that expands the template given values for its arguments. -Use [`Server.AddPrompt`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.AddPrompt) -to add a prompt along with its handler. -If `AddPrompt` is called before a server is connected, the server will have the `prompts` capability. -If all prompts are to be added after connection, set [`ServerOptions.HasPrompts`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.HasPrompts) -to advertise the capability. +MCP servers can provide LLM prompt templates (called simply +[_prompts_](https://modelcontextprotocol.io/specification/2025-06-18/server/prompts)) +to clients. Every prompt has a required name which identifies it, and a set of +named arguments, which are strings. -**Client-side**: -To list the server's prompts, call -Call [`ClientSession.Prompts`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Prompts) to get an iterator. -If needed, you can use the lower-level -[`ClientSession.ListPrompts`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.ListPrompts) to list the server's prompts. -Call [`ClientSession.GetPrompt`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.GetPrompt) to retrieve a prompt by name, providing -arguments for expansion. -Set [`ClientOptions.PromptListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.PromptListChangedHandler) to be notified of changes in the list of prompts. +**Client-side**: To list the server's prompts, use the +[`ClientSession.Prompts`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Prompts) +iterator, or the lower-level +[`ClientSession.ListPrompts`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.ListPrompts) +(see [pagination](#pagination) below). Set +[`ClientOptions.PromptListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.PromptListChangedHandler) +to be notified of changes in the list of prompts. + +Call +[`ClientSession.GetPrompt`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.GetPrompt) +to retrieve a prompt by name, providing arguments for expansion. + +**Server-side**: Use +[`Server.AddPrompt`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.AddPrompt) +to add a prompt to the server along with its handler. +The server will have the `prompts` capability if any prompt is added before the +server is connected to a client, or if +[`ServerOptions.HasPrompts`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.HasPrompts) +is explicitly set. When a prompt is added, any clients already connected to the +server will be notified via a `notifications/prompts/list_changed` +notification. ```go func Example_prompts() { @@ -73,6 +80,7 @@ func Example_prompts() { if err != nil { log.Fatal(err) } + defer cs.Close() // List the prompts. for p, err := range cs.Prompts(ctx, nil) { @@ -105,7 +113,170 @@ func Example_prompts() { ## Tools - +MCP servers can provide +[tools](https://modelcontextprotocol.io/specification/2025-06-18/server/tools) +to allow clients to interact with external systems or functionality. Tools are +effectively remote function calls, and the Go SDK provides mechanisms to bind +them to ordinary Go functions. + +**Client-side**: To list the server's tools, use the +[`ClientSession.Tools`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Tools) +iterator, or the lower-level +[`ClientSession.ListTools`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.ListTools) +(see [pagination](#pagination)). Set +[`ClientOptions.ToolListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ToolListChangedHandler) +to be notified of changes in the list of tools. + +To call a tool, use +[`ClientSession.CallTool`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.CallTool) +with `CallToolParams` holding the name and arguments of the tool to call. + +```go +res, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "my_tool", + Arguments: map[string]any{"name": "user"}, +}) +``` + +Arguments may be any value that can be marshaled to JSON. + +**Server-side**: the basic API for adding a tool is symmetrical with the API +for prompts or resources: +[`Server.AddTool`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.AddTool) +adds a +[`Tool`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Tool) to +the server along with its +[`ToolHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ToolHandler) +to handle it. The server will have the `tools` capability if any tool is added +before the server is connected to a client, or if +[`ServerOptions.HasTools`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.HasPrompts) +is explicitly set. When a tool is added, any clients already connected to the +server will be notified via a `notifications/tools/list_changed` notification. + +However, the `Server.AddTool` API leaves it to the user to implement the tool +handler correctly according to the spec, providing very little out of the box. +In order to implement a tool, the user must do all of the following: + +- Provide a tool input and output schema. +- Validate the tool arguments against its input schema. +- Unmarshal the input schema into a Go value +- Execute the tool logic. +- Marshal the tool's structured output (if any) to JSON, and store it in the + result's `StructuredOutput` field as well as the unstructured `Content` field. +- Validate that output JSON against the tool's output schema. +- If any tool errors occurred, pack them into the unstructured content and set + `IsError` to `true.` + +For this reason, the SDK provides a generic +[`AddTool`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#AddTool) +function that handles this for you. It can bind a tool to any function with the +following shape: + +```go +func(_ context.Context, request *CallToolRequest, input In) (result *CallToolResult, output Out, _ error) +``` + +This is like a `ToolHandler`, but with an extra arbitrary `In` input parameter, +and `Out` output parameter. + +Such a function can then be bound to the server using `AddTool`: + +```go +mcp.AddTool(server, &mcp.Tool{Name: "my_tool"}, handler) +``` + +This does the following automatically: + +- If `Tool.InputSchema` or `Tool.OutputSchema` are unset, the input and output + schemas are inferred from the `In` type, which must be a struct or map. + Optional `jsonschema` struct tags provide argument descriptions. +- Tool arguments are validated against the input schema. +- Tool arguments are marshaled into the `In` value. +- Tool output (the `Out` value) is marshaled into the result's + `StructuredOutput`, as well as the unstructured `Content`. +- Output is validated against the tool's output schema. +- If an ordinary error is returned, it is stored int the `CallToolResult` and + `IsError` is set to `true`. + +In fact, under ordinary circumstances, the user can ignore `CallToolRequest` +and `CallToolResult`. + +For a more realistic example, consider a tool that retrieves the weather: + +```go +type WeatherInput struct { + Location Location `json:"location" jsonschema:"user location"` + Days int `json:"days" jsonschema:"number of days to forecast"` +} + +type WeatherOutput struct { + Summary string `json:"summary" jsonschema:"a summary of the weather forecast"` + Confidence Probability `json:"confidence" jsonschema:"confidence, between 0 and 1"` + AsOf time.Time `json:"asOf" jsonschema:"the time the weather was computed"` + DailyForecast []Forecast `json:"dailyForecast" jsonschema:"the daily forecast"` + Source string `json:"source,omitempty" jsonschema:"the organization providing the weather forecast"` +} + +func WeatherTool(ctx context.Context, req *mcp.CallToolRequest, in WeatherInput) (*mcp.CallToolResult, WeatherOutput, error) { + perfectWeather := WeatherOutput{ + Summary: "perfect", + Confidence: 1.0, + AsOf: time.Now(), + } + for range in.Days { + perfectWeather.DailyForecast = append(perfectWeather.DailyForecast, Forecast{ + Forecast: "another perfect day", + Type: Sunny, + Rain: 0.0, + High: 72.0, + Low: 72.0, + }) + } + return nil, perfectWeather, nil +} +``` + +In this case, we want to customize part of the inferred schema, though we can +still infer the rest. Since we want to control the inference ourselves, we set +the `Tool.InputSchema` explicitly: + +```go +// Distinguished Go types allow custom schemas to be reused during inference. +customSchemas := map[any]*jsonschema.Schema{ + Probability(0): {Type: "number", Minimum: jsonschema.Ptr(0.0), Maximum: jsonschema.Ptr(1.0)}, + WeatherType(""): {Type: "string", Enum: []any{Sunny, PartlyCloudy, Cloudy, Rainy, Snowy}}, +} +opts := &jsonschema.ForOptions{TypeSchemas: customSchemas} +in, err := jsonschema.For[WeatherInput](opts) +if err != nil { + log.Fatal(err) +} + +// Furthermore, we can tweak the inferred schema, in this case limiting +// forecasts to 0-10 days. +daysSchema := in.Properties["days"] +daysSchema.Minimum = jsonschema.Ptr(0.0) +daysSchema.Maximum = jsonschema.Ptr(10.0) + +// Output schema inference can reuse our custom schemas from input inference. +out, err := jsonschema.For[WeatherOutput](opts) +if err != nil { + log.Fatal(err) +} + +// Now add our tool to a server. Since we've customized the schemas, we need +// to override the default schema inference. +server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) +mcp.AddTool(server, &mcp.Tool{ + Name: "weather", + InputSchema: in, + OutputSchema: out, +}, WeatherTool) +``` + +_See [mcp/tool_example_test.go](../mcp/tool_example_test.go) for the full +example, or [examples/server/toolschemas](examples/server/toolschemas/main.go) +for more examples of customizing tool schemas._ ## Utilities diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 0f990ed..38410ad 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -29,16 +29,21 @@ func ExampleLoggingTransport() { ctx := context.Background() t1, t2 := mcp.NewInMemoryTransports() server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) - if _, err := server.Connect(ctx, t1, nil); err != nil { + serverSession, err := server.Connect(ctx, t1, nil) + if err != nil { log.Fatal(err) } + defer serverSession.Wait() client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) var b bytes.Buffer logTransport := &mcp.LoggingTransport{Transport: t2, Writer: &b} - if _, err := client.Connect(ctx, logTransport, nil); err != nil { + clientSession, err := client.Connect(ctx, logTransport, nil) + if err != nil { log.Fatal(err) } + defer clientSession.Close() + // Sort for stability: reads are concurrent to writes. for _, line := range slices.Sorted(strings.SplitSeq(b.String(), "\n")) { fmt.Println(line) diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index 50619c6..0ef476c 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -4,25 +4,32 @@ ## Prompts -**Server-side**: -MCP servers can provide LLM prompt templates (called simply _prompts_) to clients. -Every prompt has a required name which identifies it, and a set of named arguments, which are strings. -Construct a prompt with a name and descriptions of its arguments. -Associated with each prompt is a handler that expands the template given values for its arguments. -Use [`Server.AddPrompt`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.AddPrompt) -to add a prompt along with its handler. -If `AddPrompt` is called before a server is connected, the server will have the `prompts` capability. -If all prompts are to be added after connection, set [`ServerOptions.HasPrompts`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.HasPrompts) -to advertise the capability. +MCP servers can provide LLM prompt templates (called simply +[_prompts_](https://modelcontextprotocol.io/specification/2025-06-18/server/prompts)) +to clients. Every prompt has a required name which identifies it, and a set of +named arguments, which are strings. -**Client-side**: -To list the server's prompts, call -Call [`ClientSession.Prompts`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Prompts) to get an iterator. -If needed, you can use the lower-level -[`ClientSession.ListPrompts`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.ListPrompts) to list the server's prompts. -Call [`ClientSession.GetPrompt`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.GetPrompt) to retrieve a prompt by name, providing -arguments for expansion. -Set [`ClientOptions.PromptListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.PromptListChangedHandler) to be notified of changes in the list of prompts. +**Client-side**: To list the server's prompts, use the +[`ClientSession.Prompts`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Prompts) +iterator, or the lower-level +[`ClientSession.ListPrompts`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.ListPrompts) +(see [pagination](#pagination) below). Set +[`ClientOptions.PromptListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.PromptListChangedHandler) +to be notified of changes in the list of prompts. + +Call +[`ClientSession.GetPrompt`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.GetPrompt) +to retrieve a prompt by name, providing arguments for expansion. + +**Server-side**: Use +[`Server.AddPrompt`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.AddPrompt) +to add a prompt to the server along with its handler. +The server will have the `prompts` capability if any prompt is added before the +server is connected to a client, or if +[`ServerOptions.HasPrompts`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.HasPrompts) +is explicitly set. When a prompt is added, any clients already connected to the +server will be notified via a `notifications/prompts/list_changed` +notification. %include ../../mcp/server_example_test.go prompts - @@ -32,7 +39,107 @@ Set [`ClientOptions.PromptListChangedHandler`](https://pkg.go.dev/github.com/mod ## Tools - +MCP servers can provide +[tools](https://modelcontextprotocol.io/specification/2025-06-18/server/tools) +to allow clients to interact with external systems or functionality. Tools are +effectively remote function calls, and the Go SDK provides mechanisms to bind +them to ordinary Go functions. + +**Client-side**: To list the server's tools, use the +[`ClientSession.Tools`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Tools) +iterator, or the lower-level +[`ClientSession.ListTools`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.ListTools) +(see [pagination](#pagination)). Set +[`ClientOptions.ToolListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ToolListChangedHandler) +to be notified of changes in the list of tools. + +To call a tool, use +[`ClientSession.CallTool`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.CallTool) +with `CallToolParams` holding the name and arguments of the tool to call. + +```go +res, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "my_tool", + Arguments: map[string]any{"name": "user"}, +}) +``` + +Arguments may be any value that can be marshaled to JSON. + +**Server-side**: the basic API for adding a tool is symmetrical with the API +for prompts or resources: +[`Server.AddTool`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.AddTool) +adds a +[`Tool`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Tool) to +the server along with its +[`ToolHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ToolHandler) +to handle it. The server will have the `tools` capability if any tool is added +before the server is connected to a client, or if +[`ServerOptions.HasTools`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.HasPrompts) +is explicitly set. When a tool is added, any clients already connected to the +server will be notified via a `notifications/tools/list_changed` notification. + +However, the `Server.AddTool` API leaves it to the user to implement the tool +handler correctly according to the spec, providing very little out of the box. +In order to implement a tool, the user must do all of the following: + +- Provide a tool input and output schema. +- Validate the tool arguments against its input schema. +- Unmarshal the input schema into a Go value +- Execute the tool logic. +- Marshal the tool's structured output (if any) to JSON, and store it in the + result's `StructuredOutput` field as well as the unstructured `Content` field. +- Validate that output JSON against the tool's output schema. +- If any tool errors occurred, pack them into the unstructured content and set + `IsError` to `true.` + +For this reason, the SDK provides a generic +[`AddTool`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#AddTool) +function that handles this for you. It can bind a tool to any function with the +following shape: + +```go +func(_ context.Context, request *CallToolRequest, input In) (result *CallToolResult, output Out, _ error) +``` + +This is like a `ToolHandler`, but with an extra arbitrary `In` input parameter, +and `Out` output parameter. + +Such a function can then be bound to the server using `AddTool`: + +```go +mcp.AddTool(server, &mcp.Tool{Name: "my_tool"}, handler) +``` + +This does the following automatically: + +- If `Tool.InputSchema` or `Tool.OutputSchema` are unset, the input and output + schemas are inferred from the `In` type, which must be a struct or map. + Optional `jsonschema` struct tags provide argument descriptions. +- Tool arguments are validated against the input schema. +- Tool arguments are marshaled into the `In` value. +- Tool output (the `Out` value) is marshaled into the result's + `StructuredOutput`, as well as the unstructured `Content`. +- Output is validated against the tool's output schema. +- If an ordinary error is returned, it is stored int the `CallToolResult` and + `IsError` is set to `true`. + +In fact, under ordinary circumstances, the user can ignore `CallToolRequest` +and `CallToolResult`. + +For a more realistic example, consider a tool that retrieves the weather: + +%include ../../mcp/tool_example_test.go weathertool - + +In this case, we want to customize part of the inferred schema, though we can +still infer the rest. Since we want to control the inference ourselves, we set +the `Tool.InputSchema` explicitly: + +%include ../../mcp/tool_example_test.go customschemas - + +_See [mcp/tool_example_test.go](../mcp/tool_example_test.go) for the full +example, or [examples/server/toolschemas](examples/server/toolschemas/main.go) +for more examples of customizing tool schemas._ ## Utilities diff --git a/mcp/client.go b/mcp/client.go index 822566d..dea3e85 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -571,8 +571,9 @@ func (cs *ClientSession) ListTools(ctx context.Context, params *ListToolsParams) return handleSend[*ListToolsResult](ctx, methodListTools, newClientRequest(cs, orZero[Params](params))) } -// CallTool calls the tool with the given name and arguments. -// The arguments can be any value that marshals into a JSON object. +// CallTool calls the tool with the given parameters. +// +// The params.Arguments can be any value that marshals into a JSON object. func (cs *ClientSession) CallTool(ctx context.Context, params *CallToolParams) (*CallToolResult, error) { if params == nil { params = new(CallToolParams) diff --git a/mcp/protocol.go b/mcp/protocol.go index 3e3c544..f3f23f5 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -42,11 +42,14 @@ type Annotations struct { // CallToolParams is used by clients to call a tool. type CallToolParams struct { - // This property is reserved by the protocol to allow clients and servers to + // Meta is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. - Meta `json:"_meta,omitempty"` - Name string `json:"name"` - Arguments any `json:"arguments,omitempty"` + Meta `json:"_meta,omitempty"` + // Name is the name of the tool to call. + Name string `json:"name"` + // Arguments holds the tool arguments. It can hold any value that can be + // marshaled to JSON. + Arguments any `json:"arguments,omitempty"` } // CallToolParamsRaw is passed to tool handlers on the server. Its arguments @@ -55,8 +58,12 @@ type CallToolParams struct { type CallToolParamsRaw struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. - Meta `json:"_meta,omitempty"` - Name string `json:"name"` + Meta `json:"_meta,omitempty"` + // Name is the name of the tool being called. + Name string `json:"name"` + // Arguments is the raw arguments received over the wire from the client. It + // is the responsibility of the tool handler to unmarshal and validate the + // Arguments (see [AddTool]). Arguments json.RawMessage `json:"arguments,omitempty"` } diff --git a/mcp/tool_example_test.go b/mcp/tool_example_test.go index 888309b..8f3fbbe 100644 --- a/mcp/tool_example_test.go +++ b/mcp/tool_example_test.go @@ -83,11 +83,6 @@ func ExampleAddTool_customMarshalling() { // } } -type WeatherInput struct { - Location Location `json:"location" jsonschema:"user location"` - Days int `json:"days" jsonschema:"number of days to forecast"` -} - type Location struct { Name string `json:"name"` Latitude *float64 `json:"latitude,omitempty"` @@ -114,6 +109,13 @@ const ( type Probability float64 +// !+weathertool + +type WeatherInput struct { + Location Location `json:"location" jsonschema:"user location"` + Days int `json:"days" jsonschema:"number of days to forecast"` +} + type WeatherOutput struct { Summary string `json:"summary" jsonschema:"a summary of the weather forecast"` Confidence Probability `json:"confidence" jsonschema:"confidence, between 0 and 1"` @@ -140,11 +142,15 @@ func WeatherTool(ctx context.Context, req *mcp.CallToolRequest, in WeatherInput) return nil, perfectWeather, nil } +// !-weathertool + func ExampleAddTool_complexSchema() { // This example demonstrates a tool with a more 'realistic' input and output // schema. We use a combination of techniques to tune our input and output // schemas. + // !+customschemas + // Distinguished Go types allow custom schemas to be reused during inference. customSchemas := map[any]*jsonschema.Schema{ Probability(0): {Type: "number", Minimum: jsonschema.Ptr(0.0), Maximum: jsonschema.Ptr(1.0)}, @@ -177,6 +183,8 @@ func ExampleAddTool_complexSchema() { OutputSchema: out, }, WeatherTool) + // !-customschemas + ctx := context.Background() session, err := connect(ctx, server) // create an in-memory connection if err != nil { From 4fa725857ca5374be0459bb03805c58f10d6eaca Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Fri, 19 Sep 2025 11:46:50 -0400 Subject: [PATCH 069/418] docs: document resources (#505) --- docs/server.md | 124 +++++++++++++++++++++++++++++++++++- internal/docs/server.src.md | 54 +++++++++++++++- mcp/server_example_test.go | 73 +++++++++++++++++++++ 3 files changed, 249 insertions(+), 2 deletions(-) diff --git a/docs/server.md b/docs/server.md index 3de5c12..e16f2fc 100644 --- a/docs/server.md +++ b/docs/server.md @@ -109,7 +109,129 @@ func Example_prompts() { ## Resources - +In MCP terms, a _resource_ is some data referenced by a URI. +MCP servers can serve resources to clients. +They can register resources individually, or register a _resource template_ +that uses a URI pattern to describe a collection of resources. + + +**Client-side**: +Call [`ClientSession.ReadResource`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.ReadResource) +to read a resource. +The SDK ensures that a read succeeds only if the URI matches a registered resource exactly, +or matches the URI pattern of a resource template. + +To list a server's resources and resource templates, use the +[`ClientSession.Resources`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Resources) +and +[`ClientSession.ResourceTemplates`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.ResourceTemplates) +iterators, or the lower-level `ListXXX` calls (see [pagination](#pagination)). +Set +[`ClientOptions.ResourceListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ResourceListChangedHandler) +to be notified of changes in the lists of resources or resource templates. + +Clients can be notified when the contents of a resource changes by subscribing to the resource's URI. +Call +[`ClientSession.Subscribe`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Subscribe) +to subscribe to a resource +and +[`ClientSession.Unsubscribe`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Unsubscribe) +to unsubscribe. +Set +[`ClientOptions.ResourceUpdatedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ResourceUpdatedHandler) +to be notified of changes to subscribed resources. + +**Server-side**: +Use +[`Server.AddResource`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.AddResource) +or +[`Server.AddResourceTemplate`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.AddResourceTemplate) +to add a resource or resource template to the server along with its handler. +A +[`ResourceHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ResourceHandler) +maps a URI to the contents of a resource, which can include text, binary data, +or both. +If `AddResource` or `AddResourceTemplate` is called before a server is connected, the server will have the +`resources` capability. +The server will have the `resources` capability if any resource or resource template is added before the +server is connected to a client, or if +[`ServerOptions.HasResources`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.HasResources) +is explicitly set. When a prompt is added, any clients already connected to the +server will be notified via a `notifications/resources/list_changed` +notification. + + +```go +func Example_resources() { + ctx := context.Background() + + resources := map[string]string{ + "file:///a": "a", + "file:///dir/x": "x", + "file:///dir/y": "y", + } + + handler := func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + uri := req.Params.URI + c, ok := resources[uri] + if !ok { + return nil, mcp.ResourceNotFoundError(uri) + } + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{URI: uri, Text: c}}, + }, nil + } + + // Create a server with a single resource. + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + s.AddResource(&mcp.Resource{URI: "file:///a"}, handler) + s.AddResourceTemplate(&mcp.ResourceTemplate{URITemplate: "file:///dir/{f}"}, handler) + + // Create a client. + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + + // Connect the server and client. + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + // List resources and resource templates. + for r, err := range cs.Resources(ctx, nil) { + if err != nil { + log.Fatal(err) + } + fmt.Println(r.URI) + } + for r, err := range cs.ResourceTemplates(ctx, nil) { + if err != nil { + log.Fatal(err) + } + fmt.Println(r.URITemplate) + } + + // Read resources. + for _, path := range []string{"a", "dir/x", "b"} { + res, err := cs.ReadResource(ctx, &mcp.ReadResourceParams{URI: "file:///" + path}) + if err != nil { + fmt.Println(err) + } else { + fmt.Println(res.Contents[0].Text) + } + } + // Output: + // file:///a + // file:///dir/{f} + // a + // x + // calling "resources/read": Resource not found +} +``` ## Tools diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index 0ef476c..345036b 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -35,7 +35,59 @@ notification. ## Resources - +In MCP terms, a _resource_ is some data referenced by a URI. +MCP servers can serve resources to clients. +They can register resources individually, or register a _resource template_ +that uses a URI pattern to describe a collection of resources. + + +**Client-side**: +Call [`ClientSession.ReadResource`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.ReadResource) +to read a resource. +The SDK ensures that a read succeeds only if the URI matches a registered resource exactly, +or matches the URI pattern of a resource template. + +To list a server's resources and resource templates, use the +[`ClientSession.Resources`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Resources) +and +[`ClientSession.ResourceTemplates`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.ResourceTemplates) +iterators, or the lower-level `ListXXX` calls (see [pagination](#pagination)). +Set +[`ClientOptions.ResourceListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ResourceListChangedHandler) +to be notified of changes in the lists of resources or resource templates. + +Clients can be notified when the contents of a resource changes by subscribing to the resource's URI. +Call +[`ClientSession.Subscribe`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Subscribe) +to subscribe to a resource +and +[`ClientSession.Unsubscribe`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Unsubscribe) +to unsubscribe. +Set +[`ClientOptions.ResourceUpdatedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ResourceUpdatedHandler) +to be notified of changes to subscribed resources. + +**Server-side**: +Use +[`Server.AddResource`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.AddResource) +or +[`Server.AddResourceTemplate`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.AddResourceTemplate) +to add a resource or resource template to the server along with its handler. +A +[`ResourceHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ResourceHandler) +maps a URI to the contents of a resource, which can include text, binary data, +or both. +If `AddResource` or `AddResourceTemplate` is called before a server is connected, the server will have the +`resources` capability. +The server will have the `resources` capability if any resource or resource template is added before the +server is connected to a client, or if +[`ServerOptions.HasResources`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.HasResources) +is explicitly set. When a prompt is added, any clients already connected to the +server will be notified via a `notifications/resources/list_changed` +notification. + + +%include ../../mcp/server_example_test.go resources - ## Tools diff --git a/mcp/server_example_test.go b/mcp/server_example_test.go index 16f19e2..d9dd168 100644 --- a/mcp/server_example_test.go +++ b/mcp/server_example_test.go @@ -82,3 +82,76 @@ func Example_prompts() { } // !-prompts + +// !+resources +func Example_resources() { + ctx := context.Background() + + resources := map[string]string{ + "file:///a": "a", + "file:///dir/x": "x", + "file:///dir/y": "y", + } + + handler := func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + uri := req.Params.URI + c, ok := resources[uri] + if !ok { + return nil, mcp.ResourceNotFoundError(uri) + } + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{URI: uri, Text: c}}, + }, nil + } + + // Create a server with a single resource. + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + s.AddResource(&mcp.Resource{URI: "file:///a"}, handler) + s.AddResourceTemplate(&mcp.ResourceTemplate{URITemplate: "file:///dir/{f}"}, handler) + + // Create a client. + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + + // Connect the server and client. + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + // List resources and resource templates. + for r, err := range cs.Resources(ctx, nil) { + if err != nil { + log.Fatal(err) + } + fmt.Println(r.URI) + } + for r, err := range cs.ResourceTemplates(ctx, nil) { + if err != nil { + log.Fatal(err) + } + fmt.Println(r.URITemplate) + } + + // Read resources. + for _, path := range []string{"a", "dir/x", "b"} { + res, err := cs.ReadResource(ctx, &mcp.ReadResourceParams{URI: "file:///" + path}) + if err != nil { + fmt.Println(err) + } else { + fmt.Println(res.Contents[0].Text) + } + } + // Output: + // file:///a + // file:///dir/{f} + // a + // x + // calling "resources/read": Resource not found +} + +// !-resources From 8b6391b17301a26f59bfa1750ea1b46613cf3f2c Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Fri, 19 Sep 2025 11:50:42 -0400 Subject: [PATCH 070/418] docs: explain how to get TokenInfo (#502) --- docs/protocol.md | 7 +++++++ internal/docs/protocol.src.md | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/docs/protocol.md b/docs/protocol.md index e3b9e8c..c87e522 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -254,6 +254,13 @@ If [`RequireBearerTokenOptions.ResourceMetadataURL`](https://pkg.go.dev/github.c the middleware function sets the WWW-Authenticate header as required by the [Protected Resource Metadata spec](https://datatracker.ietf.org/doc/html/rfc9728). +Server handlers, such as tool handlers, can obtain the `TokenInfo` returned by the `TokenVerifier` +from `req.Extra.TokenInfo`, where `req` is the handler's request. (For example, a +[`CallToolRequest`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CallToolRequest).) +HTTP handlers wrapped by the `RequireBearerToken` middleware can obtain the `TokenInfo` from the context +with [`auth.TokenInfoFromContext`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenInfoFromContext). + + The [_auth middleware example_](https://github.com/modelcontextprotocol/go-sdk/tree/main/examples/server/auth-middleware) shows how to implement authorization for both JWT tokens and API keys. ### Client diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index 0abd5aa..88c023c 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -181,6 +181,13 @@ If [`RequireBearerTokenOptions.ResourceMetadataURL`](https://pkg.go.dev/github.c the middleware function sets the WWW-Authenticate header as required by the [Protected Resource Metadata spec](https://datatracker.ietf.org/doc/html/rfc9728). +Server handlers, such as tool handlers, can obtain the `TokenInfo` returned by the `TokenVerifier` +from `req.Extra.TokenInfo`, where `req` is the handler's request. (For example, a +[`CallToolRequest`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CallToolRequest).) +HTTP handlers wrapped by the `RequireBearerToken` middleware can obtain the `TokenInfo` from the context +with [`auth.TokenInfoFromContext`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenInfoFromContext). + + The [_auth middleware example_](https://github.com/modelcontextprotocol/go-sdk/tree/main/examples/server/auth-middleware) shows how to implement authorization for both JWT tokens and API keys. ### Client From 84a568bf895e3e962818638acfe3b7d99d2c1de6 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Fri, 19 Sep 2025 11:54:10 -0400 Subject: [PATCH 071/418] readme: update README for v0.6.0 (#506) Also significantly lessen our warnings, and note that this is a release candidate, rewrite Acknowledgements to be Acknowledgements / Alternatives, since other MCP SDKs continue to be actively developed. --- README.md | 55 +++++++++++++++++------------------ docs/README.md | 4 +-- internal/docs/README.src.md | 4 +-- internal/readme/README.src.md | 55 +++++++++++++++++------------------ 4 files changed, 56 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index ab98902..9263e46 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,26 @@ -# MCP Go SDK v0.5.0 +# MCP Go SDK v0.6.0 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/modelcontextprotocol/go-sdk) ***BREAKING CHANGES*** -This version contains breaking changes. +This version contains minor breaking changes. See the [release notes]( -https://github.com/modelcontextprotocol/go-sdk/releases/tag/v0.5.0) for details. +https://github.com/modelcontextprotocol/go-sdk/releases/tag/v0.6.0) for details. [![PkgGoDev](https://pkg.go.dev/badge/github.com/modelcontextprotocol/go-sdk)](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) -This repository contains an unreleased implementation of the official Go -software development kit (SDK) for the Model Context Protocol (MCP). +This repository contains an implementation of the official Go software +development kit (SDK) for the Model Context Protocol (MCP). -> [!WARNING] -> The SDK is not yet at v1.0.0 and may still be subject to incompatible API -> changes. We aim to tag v1.0.0 in September, 2025. See -> https://github.com/modelcontextprotocol/go-sdk/issues/328 for details. +> [!IMPORTANT] +> The SDK is in release-candidate state, and is going to be tagged v1.0.0 +> soon (see https://github.com/modelcontextprotocol/go-sdk/issues/328). +> We do not anticipate significant API changes or instability. Please use it +> and [file issues](https://github.com/modelcontextprotocol/go-sdk/issues/new/choose). -## Package documentation +## Package / Feature documentation The SDK consists of several importable packages: @@ -33,11 +34,13 @@ The SDK consists of several importable packages: - The [`github.com/modelcontextprotocol/go-sdk/auth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth) package provides some primitives for supporting oauth. - - The [`github.com/modelcontextprotocol/go-sdk/oauthex`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/oauthex) package provides extensions to the OAuth protocol, such as ProtectedResourceMetadata. +The SDK endeavors to implement the full MCP spec. The [`docs/`](/docs/) directory +contains feature documentation, mapping the MCP spec to the packages above. + ## Getting started To get started creating an MCP server, create an `mcp.Server` instance, add @@ -78,9 +81,9 @@ func main() { } ``` -To communicate with that server, we can similarly create an `mcp.Client` and -connect it to the corresponding server, by running the server command and -communicating over its stdin/stdout: +To communicate with that server, create an `mcp.Client` and connect it to the +corresponding server, by running the server command and communicating over its +stdin/stdout: ```go package main @@ -128,24 +131,18 @@ func main() { The [`examples/`](/examples/) directory contains more example clients and servers. -## Design +## Contributing -The design doc for this SDK is at [design.md](./design/design.md), which was -initially reviewed at -[modelcontextprotocol/discussions/364](https://github.com/orgs/modelcontextprotocol/discussions/364). +We welcome contributions to the SDK! Please see See +[CONTRIBUTING.md](/CONTRIBUTING.md) for details of how to contribute. -Further design discussion should occur in -[issues](https://github.com/modelcontextprotocol/go-sdk/issues) (for concrete -proposals) or -[discussions](https://github.com/modelcontextprotocol/go-sdk/discussions) for -open-ended discussion. See [CONTRIBUTING.md](/CONTRIBUTING.md) for details. +## Acknowledgements / Alternatives -## Acknowledgements - -Several existing Go MCP SDKs inspired the development and design of this -official SDK, notably [mcp-go](https://github.com/mark3labs/mcp-go), authored -by Ed Zynda. We are grateful to Ed as well as the other contributors to mcp-go, -and to authors and contributors of other SDKs such as +Several third party Go MCP SDKs inspired the development and design of this +official SDK, and continue to be viable alternatives, notably +[mcp-go](https://github.com/mark3labs/mcp-go), originally authored by Ed Zynda. +We are grateful to Ed as well as the other contributors to mcp-go, and to +authors and contributors of other SDKs such as [mcp-golang](https://github.com/metoro-io/mcp-golang) and [go-mcp](https://github.com/ThinkInAIXYZ/go-mcp). Thanks to their work, there is a thriving ecosystem of Go MCP clients and servers. diff --git a/docs/README.md b/docs/README.md index 81e7f5f..b4268c8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,9 +1,9 @@ -These docs are a work-in-progress. - # Features These docs mirror the [official MCP spec](https://modelcontextprotocol.io/specification/2025-06-18). +Use the index below to learn how the SDK implements a particular aspect of the +protocol. ## Base Protocol diff --git a/internal/docs/README.src.md b/internal/docs/README.src.md index fb600df..b252f94 100644 --- a/internal/docs/README.src.md +++ b/internal/docs/README.src.md @@ -1,8 +1,8 @@ -These docs are a work-in-progress. - # Features These docs mirror the [official MCP spec](https://modelcontextprotocol.io/specification/2025-06-18). +Use the index below to learn how the SDK implements a particular aspect of the +protocol. ## Base Protocol diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md index acd08e4..10ce9b6 100644 --- a/internal/readme/README.src.md +++ b/internal/readme/README.src.md @@ -1,24 +1,25 @@ -# MCP Go SDK v0.5.0 +# MCP Go SDK v0.6.0 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/modelcontextprotocol/go-sdk) ***BREAKING CHANGES*** -This version contains breaking changes. +This version contains minor breaking changes. See the [release notes]( -https://github.com/modelcontextprotocol/go-sdk/releases/tag/v0.5.0) for details. +https://github.com/modelcontextprotocol/go-sdk/releases/tag/v0.6.0) for details. [![PkgGoDev](https://pkg.go.dev/badge/github.com/modelcontextprotocol/go-sdk)](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) -This repository contains an unreleased implementation of the official Go -software development kit (SDK) for the Model Context Protocol (MCP). +This repository contains an implementation of the official Go software +development kit (SDK) for the Model Context Protocol (MCP). -> [!WARNING] -> The SDK is not yet at v1.0.0 and may still be subject to incompatible API -> changes. We aim to tag v1.0.0 in September, 2025. See -> https://github.com/modelcontextprotocol/go-sdk/issues/328 for details. +> [!IMPORTANT] +> The SDK is in release-candidate state, and is going to be tagged v1.0.0 +> soon (see https://github.com/modelcontextprotocol/go-sdk/issues/328). +> We do not anticipate significant API changes or instability. Please use it +> and [file issues](https://github.com/modelcontextprotocol/go-sdk/issues/new/choose). -## Package documentation +## Package / Feature documentation The SDK consists of several importable packages: @@ -32,11 +33,13 @@ The SDK consists of several importable packages: - The [`github.com/modelcontextprotocol/go-sdk/auth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth) package provides some primitives for supporting oauth. - - The [`github.com/modelcontextprotocol/go-sdk/oauthex`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/oauthex) package provides extensions to the OAuth protocol, such as ProtectedResourceMetadata. +The SDK endeavors to implement the full MCP spec. The [`docs/`](/docs/) directory +contains feature documentation, mapping the MCP spec to the packages above. + ## Getting started To get started creating an MCP server, create an `mcp.Server` instance, add @@ -46,33 +49,27 @@ stdin/stdout: %include server/server.go - -To communicate with that server, we can similarly create an `mcp.Client` and -connect it to the corresponding server, by running the server command and -communicating over its stdin/stdout: +To communicate with that server, create an `mcp.Client` and connect it to the +corresponding server, by running the server command and communicating over its +stdin/stdout: %include client/client.go - The [`examples/`](/examples/) directory contains more example clients and servers. -## Design +## Contributing -The design doc for this SDK is at [design.md](./design/design.md), which was -initially reviewed at -[modelcontextprotocol/discussions/364](https://github.com/orgs/modelcontextprotocol/discussions/364). +We welcome contributions to the SDK! Please see See +[CONTRIBUTING.md](/CONTRIBUTING.md) for details of how to contribute. -Further design discussion should occur in -[issues](https://github.com/modelcontextprotocol/go-sdk/issues) (for concrete -proposals) or -[discussions](https://github.com/modelcontextprotocol/go-sdk/discussions) for -open-ended discussion. See [CONTRIBUTING.md](/CONTRIBUTING.md) for details. +## Acknowledgements / Alternatives -## Acknowledgements - -Several existing Go MCP SDKs inspired the development and design of this -official SDK, notably [mcp-go](https://github.com/mark3labs/mcp-go), authored -by Ed Zynda. We are grateful to Ed as well as the other contributors to mcp-go, -and to authors and contributors of other SDKs such as +Several third party Go MCP SDKs inspired the development and design of this +official SDK, and continue to be viable alternatives, notably +[mcp-go](https://github.com/mark3labs/mcp-go), originally authored by Ed Zynda. +We are grateful to Ed as well as the other contributors to mcp-go, and to +authors and contributors of other SDKs such as [mcp-golang](https://github.com/metoro-io/mcp-golang) and [go-mcp](https://github.com/ThinkInAIXYZ/go-mcp). Thanks to their work, there is a thriving ecosystem of Go MCP clients and servers. From 4e9a387d046f77bf44c5c055599b8d313e3b88c3 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Fri, 19 Sep 2025 11:58:58 -0400 Subject: [PATCH 072/418] doc: document logging (#497) For #442. --- docs/server.md | 85 ++++++++++++++++++++++++++++++++++++- internal/docs/server.src.md | 28 +++++++++++- mcp/logging.go | 1 + mcp/server_example_test.go | 63 +++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 2 deletions(-) diff --git a/docs/server.md b/docs/server.md index e16f2fc..d6656ee 100644 --- a/docs/server.md +++ b/docs/server.md @@ -450,7 +450,90 @@ _ = mcp.NewServer(&mcp.Implementation{Name: "server"}, &mcp.ServerOptions{ ### Logging - +MCP servers can send logging messages to MCP clients. +(This form of logging is distinct from server-side logging, where the +server produces logs that remain server-side, for use by server maintainers.) + +**Server-side**: +The minimum log level is part of the server state. +For stateful sessions, there is no default log level: no log messages will be sent +until the client calls `SetLevel` (see below). +For stateful sessions, the level defaults to "info". + +[`ServerSession.Log`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.Log) is the low-level way for servers to log to clients. +It sends a logging notification to the client if the level of the message +is at least the minimum log level. + +For a simpler API, use [`NewLoggingHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#NewLoggingHandler) to obtain a [`slog.Handler`](https://pkg.go.dev/log/slog#Handler). +By setting [`LoggingHandlerOptions.MinInterval`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#LoggingHandlerOptions.MinInterval), the handler can be rate-limited +to avoid spamming clients with too many messages. + +Servers always report the logging capability. + + +**Client-side**: +Set [`ClientOptions.LoggingMessageHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.LoggingMessageHandler) to receive log messages. + +Call [`ClientSession.SetLevel`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.SetLevel) to change the log level for a session. + +```go +func Example_logging() { + ctx := context.Background() + + // Create a server. + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + + // Create a client that displays log messages. + done := make(chan struct{}) // solely for the example + var nmsgs atomic.Int32 + c := mcp.NewClient( + &mcp.Implementation{Name: "client", Version: "v0.0.1"}, + &mcp.ClientOptions{ + LoggingMessageHandler: func(_ context.Context, r *mcp.LoggingMessageRequest) { + m := r.Params.Data.(map[string]any) + fmt.Println(m["msg"], m["value"]) + if nmsgs.Add(1) == 2 { // number depends on logger calls below + close(done) + } + }, + }) + + // Connect the server and client. + t1, t2 := mcp.NewInMemoryTransports() + ss, err := s.Connect(ctx, t1, nil) + if err != nil { + log.Fatal(err) + } + defer ss.Close() + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + // Set the minimum log level to "info". + if err := cs.SetLoggingLevel(ctx, &mcp.SetLoggingLevelParams{Level: "info"}); err != nil { + log.Fatal(err) + } + + // Get a slog.Logger for the server session. + logger := slog.New(mcp.NewLoggingHandler(ss, nil)) + + // Log some things. + logger.Info("info shows up", "value", 1) + logger.Debug("debug doesn't show up", "value", 2) + logger.Warn("warn shows up", "value", 3) + + // Wait for them to arrive on the client. + // In a real application, the log messages would appear asynchronously + // while other work was happening. + <-done + + // Output: + // info shows up 1 + // warn shows up 3 +} +``` ### Pagination diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index 345036b..c09ba63 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -215,7 +215,33 @@ requests. ### Logging - +MCP servers can send logging messages to MCP clients. +(This form of logging is distinct from server-side logging, where the +server produces logs that remain server-side, for use by server maintainers.) + +**Server-side**: +The minimum log level is part of the server state. +For stateful sessions, there is no default log level: no log messages will be sent +until the client calls `SetLevel` (see below). +For stateful sessions, the level defaults to "info". + +[`ServerSession.Log`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.Log) is the low-level way for servers to log to clients. +It sends a logging notification to the client if the level of the message +is at least the minimum log level. + +For a simpler API, use [`NewLoggingHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#NewLoggingHandler) to obtain a [`slog.Handler`](https://pkg.go.dev/log/slog#Handler). +By setting [`LoggingHandlerOptions.MinInterval`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#LoggingHandlerOptions.MinInterval), the handler can be rate-limited +to avoid spamming clients with too many messages. + +Servers always report the logging capability. + + +**Client-side**: +Set [`ClientOptions.LoggingMessageHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.LoggingMessageHandler) to receive log messages. + +Call [`ClientSession.SetLevel`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.SetLevel) to change the log level for a session. + +%include ../../mcp/server_example_test.go logging - ### Pagination diff --git a/mcp/logging.go b/mcp/logging.go index 4d33097..b3186a9 100644 --- a/mcp/logging.go +++ b/mcp/logging.go @@ -70,6 +70,7 @@ type LoggingHandlerOptions struct { // The value for the "logger" field of logging notifications. LoggerName string // Limits the rate at which log messages are sent. + // Excess messages are dropped. // If zero, there is no rate limiting. MinInterval time.Duration } diff --git a/mcp/server_example_test.go b/mcp/server_example_test.go index d9dd168..db04920 100644 --- a/mcp/server_example_test.go +++ b/mcp/server_example_test.go @@ -8,6 +8,8 @@ import ( "context" "fmt" "log" + "log/slog" + "sync/atomic" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -83,6 +85,67 @@ func Example_prompts() { // !-prompts +// !+logging + +func Example_logging() { + ctx := context.Background() + + // Create a server. + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + + // Create a client that displays log messages. + done := make(chan struct{}) // solely for the example + var nmsgs atomic.Int32 + c := mcp.NewClient( + &mcp.Implementation{Name: "client", Version: "v0.0.1"}, + &mcp.ClientOptions{ + LoggingMessageHandler: func(_ context.Context, r *mcp.LoggingMessageRequest) { + m := r.Params.Data.(map[string]any) + fmt.Println(m["msg"], m["value"]) + if nmsgs.Add(1) == 2 { // number depends on logger calls below + close(done) + } + }, + }) + + // Connect the server and client. + t1, t2 := mcp.NewInMemoryTransports() + ss, err := s.Connect(ctx, t1, nil) + if err != nil { + log.Fatal(err) + } + defer ss.Close() + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + // Set the minimum log level to "info". + if err := cs.SetLoggingLevel(ctx, &mcp.SetLoggingLevelParams{Level: "info"}); err != nil { + log.Fatal(err) + } + + // Get a slog.Logger for the server session. + logger := slog.New(mcp.NewLoggingHandler(ss, nil)) + + // Log some things. + logger.Info("info shows up", "value", 1) + logger.Debug("debug doesn't show up", "value", 2) + logger.Warn("warn shows up", "value", 3) + + // Wait for them to arrive on the client. + // In a real application, the log messages would appear asynchronously + // while other work was happening. + <-done + + // Output: + // info shows up 1 + // warn shows up 3 +} + +// !-logging + // !+resources func Example_resources() { ctx := context.Background() From 8c42b69817fcdc11f1249282fc5c43ff7fa8feaa Mon Sep 17 00:00:00 2001 From: Suraj Bobade Date: Fri, 19 Sep 2025 22:31:57 +0530 Subject: [PATCH 073/418] SSE: Add support for SSE handler options (#508) mcp/sse: add support to provide for options for SSE transport - Add SSEOptions struct to define SSE handler options. - Update Signature of NewSSEHandler to accept SSEOptions [braking change] - Update unit test - Update example test Fixes https://github.com/modelcontextprotocol/go-sdk/issues/507, https://github.com/modelcontextprotocol/go-sdk/issues/503 --- examples/server/sse/main.go | 2 +- mcp/sse.go | 18 ++++++++++++++---- mcp/sse_example_test.go | 2 +- mcp/sse_test.go | 2 +- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/examples/server/sse/main.go b/examples/server/sse/main.go index 27f9cae..0507dd6 100644 --- a/examples/server/sse/main.go +++ b/examples/server/sse/main.go @@ -65,6 +65,6 @@ func main() { default: return nil } - }) + }, nil) log.Fatal(http.ListenAndServe(addr, handler)) } diff --git a/mcp/sse.go b/mcp/sse.go index f39a039..7f64491 100644 --- a/mcp/sse.go +++ b/mcp/sse.go @@ -43,12 +43,18 @@ import ( // [2024-11-05 version]: https://modelcontextprotocol.io/specification/2024-11-05/basic/transports type SSEHandler struct { getServer func(request *http.Request) *Server + opts SSEOptions onConnection func(*ServerSession) // for testing; must not block mu sync.Mutex sessions map[string]*SSEServerTransport } +// SSEOptions specifies options for an [SSEHandler]. +// for now, it is empty, but may be extended in future. +// https://github.com/modelcontextprotocol/go-sdk/issues/507 +type SSEOptions struct{} + // NewSSEHandler returns a new [SSEHandler] that creates and manages MCP // sessions created via incoming HTTP requests. // @@ -62,13 +68,17 @@ type SSEHandler struct { // The getServer function may return a distinct [Server] for each new // request, or reuse an existing server. If it returns nil, the handler // will return a 400 Bad Request. -// -// TODO(rfindley): add options. -func NewSSEHandler(getServer func(request *http.Request) *Server) *SSEHandler { - return &SSEHandler{ +func NewSSEHandler(getServer func(request *http.Request) *Server, opts *SSEOptions) *SSEHandler { + s := &SSEHandler{ getServer: getServer, sessions: make(map[string]*SSEServerTransport), } + + if opts != nil { + s.opts = *opts + } + + return s } // A SSEServerTransport is a logical SSE session created through a hanging GET diff --git a/mcp/sse_example_test.go b/mcp/sse_example_test.go index d06ea62..6132d31 100644 --- a/mcp/sse_example_test.go +++ b/mcp/sse_example_test.go @@ -31,7 +31,7 @@ func ExampleSSEHandler() { server := mcp.NewServer(&mcp.Implementation{Name: "adder", Version: "v0.0.1"}, nil) mcp.AddTool(server, &mcp.Tool{Name: "add", Description: "add two numbers"}, Add) - handler := mcp.NewSSEHandler(func(*http.Request) *mcp.Server { return server }) + handler := mcp.NewSSEHandler(func(*http.Request) *mcp.Server { return server }, nil) httpServer := httptest.NewServer(handler) defer httpServer.Close() diff --git a/mcp/sse_test.go b/mcp/sse_test.go index 32a20bf..25435ff 100644 --- a/mcp/sse_test.go +++ b/mcp/sse_test.go @@ -24,7 +24,7 @@ func TestSSEServer(t *testing.T) { server := NewServer(testImpl, nil) AddTool(server, &Tool{Name: "greet"}, sayHi) - sseHandler := NewSSEHandler(func(*http.Request) *Server { return server }) + sseHandler := NewSSEHandler(func(*http.Request) *Server { return server }, nil) serverSessions := make(chan *ServerSession, 1) sseHandler.onConnection = func(ss *ServerSession) { From ef2fc60b5cd7d73ddfdb7c9b07047798640a3295 Mon Sep 17 00:00:00 2001 From: Griffin Date: Mon, 22 Sep 2025 21:07:17 +0800 Subject: [PATCH 074/418] docs: adjust example code to remove horizontal scroll bar (#509) This change adjusts the code example in readme so if you view it in github it won't show a horizontal scroll bar in code example. It's trivial change, but I believe it's still an improvement. --- README.md | 6 +++++- internal/readme/server/server.go | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9263e46..a12a026 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,11 @@ type Output struct { Greeting string `json:"greeting" jsonschema:"the greeting to tell to the user"` } -func SayHi(ctx context.Context, req *mcp.CallToolRequest, input Input) (*mcp.CallToolResult, Output, error) { +func SayHi(ctx context.Context, req *mcp.CallToolRequest, input Input) ( + *mcp.CallToolResult, + Output, + error, +) { return nil, Output{Greeting: "Hi " + input.Name}, nil } diff --git a/internal/readme/server/server.go b/internal/readme/server/server.go index e999602..bfa0825 100644 --- a/internal/readme/server/server.go +++ b/internal/readme/server/server.go @@ -20,7 +20,11 @@ type Output struct { Greeting string `json:"greeting" jsonschema:"the greeting to tell to the user"` } -func SayHi(ctx context.Context, req *mcp.CallToolRequest, input Input) (*mcp.CallToolResult, Output, error) { +func SayHi(ctx context.Context, req *mcp.CallToolRequest, input Input) ( + *mcp.CallToolResult, + Output, + error, +) { return nil, Output{Greeting: "Hi " + input.Name}, nil } From 33ff851ef384dd2f9e8a7e1bdd926e350245d507 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Mon, 22 Sep 2025 15:28:04 -0400 Subject: [PATCH 075/418] mcp: update to jsonschema that does not validate structs (#511) Update to the latest jsonschema commit, which removes the ability to validate structs and changes the type of ForOptions.TypeSchemas. --- docs/server.md | 6 +- examples/server/auth-middleware/go.mod | 2 +- examples/server/auth-middleware/go.sum | 4 +- examples/server/rate-limiting/go.mod | 2 +- examples/server/rate-limiting/go.sum | 4 +- examples/server/toolschemas/main.go | 79 ++++++++++++++++-------- examples/server/toolschemas/main_test.go | 31 ++++++++++ go.mod | 2 +- go.sum | 4 ++ mcp/streamable_bench_test.go | 7 ++- mcp/tool_example_test.go | 11 ++-- 11 files changed, 107 insertions(+), 45 deletions(-) create mode 100644 examples/server/toolschemas/main_test.go diff --git a/docs/server.md b/docs/server.md index d6656ee..f986c68 100644 --- a/docs/server.md +++ b/docs/server.md @@ -364,9 +364,9 @@ the `Tool.InputSchema` explicitly: ```go // Distinguished Go types allow custom schemas to be reused during inference. -customSchemas := map[any]*jsonschema.Schema{ - Probability(0): {Type: "number", Minimum: jsonschema.Ptr(0.0), Maximum: jsonschema.Ptr(1.0)}, - WeatherType(""): {Type: "string", Enum: []any{Sunny, PartlyCloudy, Cloudy, Rainy, Snowy}}, +customSchemas := map[reflect.Type]*jsonschema.Schema{ + reflect.TypeFor[Probability](): {Type: "number", Minimum: jsonschema.Ptr(0.0), Maximum: jsonschema.Ptr(1.0)}, + reflect.TypeFor[WeatherType](): {Type: "string", Enum: []any{Sunny, PartlyCloudy, Cloudy, Rainy, Snowy}}, } opts := &jsonschema.ForOptions{TypeSchemas: customSchemas} in, err := jsonschema.For[WeatherInput](opts) diff --git a/examples/server/auth-middleware/go.mod b/examples/server/auth-middleware/go.mod index f1ca77f..cbc89c7 100644 --- a/examples/server/auth-middleware/go.mod +++ b/examples/server/auth-middleware/go.mod @@ -8,7 +8,7 @@ require ( ) require ( - github.com/google/jsonschema-go v0.2.3 // indirect + github.com/google/jsonschema-go v0.3.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect ) diff --git a/examples/server/auth-middleware/go.sum b/examples/server/auth-middleware/go.sum index 6a39263..7b7a8e5 100644 --- a/examples/server/auth-middleware/go.sum +++ b/examples/server/auth-middleware/go.sum @@ -2,8 +2,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeD github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/jsonschema-go v0.2.3 h1:dkP3B96OtZKKFvdrUSaDkL+YDx8Uw9uC4Y+eukpCnmM= -github.com/google/jsonschema-go v0.2.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= +github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= diff --git a/examples/server/rate-limiting/go.mod b/examples/server/rate-limiting/go.mod index 91d3d26..adf535b 100644 --- a/examples/server/rate-limiting/go.mod +++ b/examples/server/rate-limiting/go.mod @@ -8,7 +8,7 @@ require ( ) require ( - github.com/google/jsonschema-go v0.2.3 // indirect + github.com/google/jsonschema-go v0.3.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect ) diff --git a/examples/server/rate-limiting/go.sum b/examples/server/rate-limiting/go.sum index da49fd1..92c2739 100644 --- a/examples/server/rate-limiting/go.sum +++ b/examples/server/rate-limiting/go.sum @@ -1,7 +1,7 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/jsonschema-go v0.2.3 h1:dkP3B96OtZKKFvdrUSaDkL+YDx8Uw9uC4Y+eukpCnmM= -github.com/google/jsonschema-go v0.2.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= +github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= diff --git a/examples/server/toolschemas/main.go b/examples/server/toolschemas/main.go index 36ee049..a53acd4 100644 --- a/examples/server/toolschemas/main.go +++ b/examples/server/toolschemas/main.go @@ -54,27 +54,72 @@ func (t *manualGreeter) greet(_ context.Context, req *mcp.CallToolRequest) (*mcp // Handle the parsing and validation of input and output. // // Note that errors here are treated as tool errors, not protocol errors. + + // First, unmarshal to a map[string]any and validate. + if err := unmarshalAndValidate(req.Params.Arguments, t.inputSchema); err != nil { + return errf("invalid input: %v", err), nil + } + + // Now unmarshal again to input. var input Input if err := json.Unmarshal(req.Params.Arguments, &input); err != nil { return errf("failed to unmarshal arguments: %v", err), nil } - if err := t.inputSchema.Validate(input); err != nil { - return errf("invalid input: %v", err), nil - } output := Output{Greeting: "Hi " + input.Name} - if err := t.outputSchema.Validate(output); err != nil { - return errf("tool produced invalid output: %v", err), nil - } outputJSON, err := json.Marshal(output) if err != nil { return errf("output failed to marshal: %v", err), nil } + // + if err := unmarshalAndValidate(outputJSON, t.outputSchema); err != nil { + return errf("invalid output: %v", err), nil + } + return &mcp.CallToolResult{ Content: []mcp.Content{&mcp.TextContent{Text: string(outputJSON)}}, StructuredContent: output, }, nil } +// unmarshalAndValidate unmarshals data to a map[string]any, then validates that against res. +func unmarshalAndValidate(data []byte, res *jsonschema.Resolved) error { + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + return err + } + return res.Validate(m) +} + +var ( + inputSchema = &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": {Type: "string", MaxLength: jsonschema.Ptr(10)}, + }, + } + outputSchema = &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "greeting": {Type: "string"}, + }, + } +) + +func newManualGreeter() (*manualGreeter, error) { + resIn, err := inputSchema.Resolve(nil) + if err != nil { + return nil, err + } + resOut, err := outputSchema.Resolve(nil) + if err != nil { + return nil, err + } + return &manualGreeter{ + inputSchema: resIn, + outputSchema: resOut, + }, nil +} + func main() { server := mcp.NewServer(&mcp.Implementation{Name: "greeter"}, nil) @@ -90,27 +135,7 @@ func main() { // // We don't need to do all this work: below, we use jsonschema.For to start // from the default schema. - var ( - manual manualGreeter - err error - ) - inputSchema := &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "name": {Type: "string", MaxLength: jsonschema.Ptr(10)}, - }, - } - manual.inputSchema, err = inputSchema.Resolve(nil) - if err != nil { - log.Fatal(err) - } - outputSchema := &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "greeting": {Type: "string"}, - }, - } - manual.outputSchema, err = outputSchema.Resolve(nil) + manual, err := newManualGreeter() if err != nil { log.Fatal(err) } diff --git a/examples/server/toolschemas/main_test.go b/examples/server/toolschemas/main_test.go new file mode 100644 index 0000000..9173881 --- /dev/null +++ b/examples/server/toolschemas/main_test.go @@ -0,0 +1,31 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package main + +import ( + "context" + "encoding/json" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestGreet(t *testing.T) { + manual, err := newManualGreeter() + if err != nil { + t.Fatal(err) + } + res, err := manual.greet(context.Background(), &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{ + Arguments: json.RawMessage(`{"name": "Bob"}`), + }, + }) + if err != nil { + t.Fatal(err) + } + if res.IsError { + t.Fatalf("tool error: %q", res.Content[0].(*mcp.TextContent).Text) + } +} diff --git a/go.mod b/go.mod index f5c578c..b78c25e 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.23.0 require ( github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/go-cmp v0.7.0 - github.com/google/jsonschema-go v0.2.3 + github.com/google/jsonschema-go v0.3.0 github.com/yosida95/uritemplate/v3 v3.0.2 golang.org/x/tools v0.34.0 ) diff --git a/go.sum b/go.sum index 6a39263..2006a67 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,10 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.2.3 h1:dkP3B96OtZKKFvdrUSaDkL+YDx8Uw9uC4Y+eukpCnmM= github.com/google/jsonschema-go v0.2.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.2.4-0.20250922144851-e08864c65371 h1:e1VCqWtKpTYBOBhPcgGV5whTlMFpTbH5Ghm56wpxBsk= +github.com/google/jsonschema-go v0.2.4-0.20250922144851-e08864c65371/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= +github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= diff --git a/mcp/streamable_bench_test.go b/mcp/streamable_bench_test.go index 07f81da..bf82ee5 100644 --- a/mcp/streamable_bench_test.go +++ b/mcp/streamable_bench_test.go @@ -8,6 +8,7 @@ import ( "context" "net/http" "net/http/httptest" + "reflect" "testing" "github.com/google/jsonschema-go/jsonschema" @@ -17,9 +18,9 @@ import ( func BenchmarkStreamableServing(b *testing.B) { // This benchmark measures how fast we can handle a single tool on a // streamable server, including tool validation and stream management. - customSchemas := map[any]*jsonschema.Schema{ - Probability(0): {Type: "number", Minimum: jsonschema.Ptr(0.0), Maximum: jsonschema.Ptr(1.0)}, - WeatherType(""): {Type: "string", Enum: []any{Sunny, PartlyCloudy, Cloudy, Rainy, Snowy}}, + customSchemas := map[reflect.Type]*jsonschema.Schema{ + reflect.TypeFor[Probability](): {Type: "number", Minimum: jsonschema.Ptr(0.0), Maximum: jsonschema.Ptr(1.0)}, + reflect.TypeFor[WeatherType](): {Type: "string", Enum: []any{Sunny, PartlyCloudy, Cloudy, Rainy, Snowy}}, } opts := &jsonschema.ForOptions{TypeSchemas: customSchemas} in, err := jsonschema.For[WeatherInput](opts) diff --git a/mcp/tool_example_test.go b/mcp/tool_example_test.go index 8f3fbbe..e41250d 100644 --- a/mcp/tool_example_test.go +++ b/mcp/tool_example_test.go @@ -9,6 +9,7 @@ import ( "encoding/json" "fmt" "log" + "reflect" "time" "github.com/google/jsonschema-go/jsonschema" @@ -34,8 +35,8 @@ func ExampleAddTool_customMarshalling() { // In this case, you can use jsonschema.For along with jsonschema.ForOptions // to customize the schema inference for your custom type. inputSchema, err := jsonschema.For[Input](&jsonschema.ForOptions{ - TypeSchemas: map[any]*jsonschema.Schema{ - MyDate{}: {Type: "string"}, + TypeSchemas: map[reflect.Type]*jsonschema.Schema{ + reflect.TypeFor[MyDate](): {Type: "string"}, }, }) if err != nil { @@ -152,9 +153,9 @@ func ExampleAddTool_complexSchema() { // !+customschemas // Distinguished Go types allow custom schemas to be reused during inference. - customSchemas := map[any]*jsonschema.Schema{ - Probability(0): {Type: "number", Minimum: jsonschema.Ptr(0.0), Maximum: jsonschema.Ptr(1.0)}, - WeatherType(""): {Type: "string", Enum: []any{Sunny, PartlyCloudy, Cloudy, Rainy, Snowy}}, + customSchemas := map[reflect.Type]*jsonschema.Schema{ + reflect.TypeFor[Probability](): {Type: "number", Minimum: jsonschema.Ptr(0.0), Maximum: jsonschema.Ptr(1.0)}, + reflect.TypeFor[WeatherType](): {Type: "string", Enum: []any{Sunny, PartlyCloudy, Cloudy, Rainy, Snowy}}, } opts := &jsonschema.ForOptions{TypeSchemas: customSchemas} in, err := jsonschema.For[WeatherInput](opts) From 5d64d61974982512270b554afd45d053c6dc2fb7 Mon Sep 17 00:00:00 2001 From: Manuel Ibar Date: Tue, 23 Sep 2025 13:27:39 -0300 Subject: [PATCH 076/418] mcp: fix context propagation in StreamableClientTransport (#514) ## Problem Context propagation was broken in `StreamableClientTransport` because: 1. `Connect()` used `context.Background()` instead of the parent context 2. `Close()` created a race condition where DELETE requests were cancelled before completion ## Solution - Use parent context when creating the connection context in `Connect()` - Reorder `Close()` operations to perform cleanup DELETE before cancelling context ## Impact - Request-scoped values (auth tokens, trace IDs) now propagate correctly to background HTTP operations - Eliminates race condition in cleanup operations - Maintains proper Go context semantics Fixes #513 --- mcp/streamable.go | 16 ++++++------ mcp/streamable_test.go | 59 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 4ab343b..8072a63 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1018,7 +1018,7 @@ func (t *StreamableClientTransport) Connect(ctx context.Context) (Connection, er // Create a new cancellable context that will manage the connection's lifecycle. // This is crucial for cleanly shutting down the background SSE listener by // cancelling its blocking network operations, which prevents hangs on exit. - connCtx, cancel := context.WithCancel(context.Background()) + connCtx, cancel := context.WithCancel(ctx) conn := &streamableClientConn{ url: t.Endpoint, client: client, @@ -1230,7 +1230,7 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e // testAuth controls whether a fake Authorization header is added to outgoing requests. // TODO: replace with a better mechanism when client-side auth is in place. -var testAuth = false +var testAuth atomic.Bool func (c *streamableClientConn) setMCPHeaders(req *http.Request) { c.mu.Lock() @@ -1242,7 +1242,7 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request) { if c.sessionID != "" { req.Header.Set(sessionIDHeader, c.sessionID) } - if testAuth { + if testAuth.Load() { req.Header.Set("Authorization", "Bearer foo") } } @@ -1394,14 +1394,10 @@ func (c *streamableClientConn) reconnect(lastEventID string) (*http.Response, er // Close implements the [Connection] interface. func (c *streamableClientConn) Close() error { c.closeOnce.Do(func() { - // Cancel any hanging network requests. - c.cancel() - close(c.done) - if errors.Is(c.failure(), errSessionMissing) { // If the session is missing, no need to delete it. } else { - req, err := http.NewRequest(http.MethodDelete, c.url, nil) + req, err := http.NewRequestWithContext(c.ctx, http.MethodDelete, c.url, nil) if err != nil { c.closeErr = err } else { @@ -1411,6 +1407,10 @@ func (c *streamableClientConn) Close() error { } } } + + // Cancel any hanging network requests after cleanup. + c.cancel() + close(c.done) }) return c.closeErr } diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 3b967f8..3576d2b 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1310,8 +1310,9 @@ func textContent(t *testing.T, res *CallToolResult) string { } func TestTokenInfo(t *testing.T) { - defer func(b bool) { testAuth = b }(testAuth) - testAuth = true + oldAuth := testAuth.Load() + defer testAuth.Store(oldAuth) + testAuth.Store(true) ctx := context.Background() // Create a server with a tool that returns TokenInfo. @@ -1430,3 +1431,57 @@ func TestStreamableGET(t *testing.T) { t.Errorf("GET with session ID: got status %d, want %d", got, want) } } + +func TestStreamableClientContextPropagation(t *testing.T) { + type contextKey string + const testKey = contextKey("test-key") + const testValue = "test-value" + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ctx2 := context.WithValue(ctx, testKey, testValue) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + switch req.Method { + case "POST": + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Mcp-Session-Id", "test-session") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-03-26","capabilities":{},"serverInfo":{"name":"test","version":"1.0"}}}`)) + case "GET": + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + case "DELETE": + w.WriteHeader(http.StatusNoContent) + } + })) + defer server.Close() + + transport := &StreamableClientTransport{Endpoint: server.URL} + conn, err := transport.Connect(ctx2) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + defer conn.Close() + + streamableConn, ok := conn.(*streamableClientConn) + if !ok { + t.Fatalf("Expected *streamableClientConn, got %T", conn) + } + + if got := streamableConn.ctx.Value(testKey); got != testValue { + t.Errorf("Context value not propagated: got %v, want %v", got, testValue) + } + + if streamableConn.ctx.Done() == nil { + t.Error("Connection context is not cancellable") + } + + cancel() + select { + case <-streamableConn.ctx.Done(): + case <-time.After(100 * time.Millisecond): + t.Error("Connection context was not cancelled when parent was cancelled") + } + +} From 07f6c490c68284a1f152ec9c0f71e2861b0e687d Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Wed, 24 Sep 2025 14:07:27 -0400 Subject: [PATCH 077/418] mcp: make the streamable client transport less strict (#524) In practice, the streamable client transport was having trouble connecting to various backends, because they are nonconformant in various ways. While it would be nice if all servers conformed to the spec, in practice there are certain spec violations that are recoverable, and we can and should recover them. Specifically: - tolerate 404 instead of 405 for the hanging GET (#393) - tolerate (=ignore) spurious response body for notifications and responses, since we know none are expected Additionally, fix a bug that we weren't parsing Content-Type correctly. Fixes #521 --- examples/client/listfeatures/main.go | 37 ++++++++--- mcp/streamable.go | 54 ++++++++++++++-- mcp/streamable_client_test.go | 94 +++++++++++++++++++++++++++- 3 files changed, 170 insertions(+), 15 deletions(-) diff --git a/examples/client/listfeatures/main.go b/examples/client/listfeatures/main.go index 9d473f0..044b5e9 100644 --- a/examples/client/listfeatures/main.go +++ b/examples/client/listfeatures/main.go @@ -27,30 +27,51 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) +var ( + endpoint = flag.String("http", "", "if set, connect to this streamable endpoint rather than running a stdio server") +) + func main() { flag.Parse() args := flag.Args() - if len(args) == 0 { + if len(args) == 0 && *endpoint == "" { fmt.Fprintln(os.Stderr, "Usage: listfeatures []") + fmt.Fprintln(os.Stderr, "Usage: listfeatures --http=\"https://example.com/server/mcp\"") fmt.Fprintln(os.Stderr, "List all features for a stdio MCP server") fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:\n\tlistfeatures npx @modelcontextprotocol/server-everything") os.Exit(2) } - ctx := context.Background() - cmd := exec.Command(args[0], args[1:]...) + var ( + ctx = context.Background() + transport mcp.Transport + ) + if *endpoint != "" { + transport = &mcp.StreamableClientTransport{ + Endpoint: *endpoint, + } + } else { + cmd := exec.Command(args[0], args[1:]...) + transport = &mcp.CommandTransport{Command: cmd} + } client := mcp.NewClient(&mcp.Implementation{Name: "mcp-client", Version: "v1.0.0"}, nil) - cs, err := client.Connect(ctx, &mcp.CommandTransport{Command: cmd}, nil) + cs, err := client.Connect(ctx, transport, nil) if err != nil { log.Fatal(err) } defer cs.Close() - printSection("tools", cs.Tools(ctx, nil), func(t *mcp.Tool) string { return t.Name }) - printSection("resources", cs.Resources(ctx, nil), func(r *mcp.Resource) string { return r.Name }) - printSection("resource templates", cs.ResourceTemplates(ctx, nil), func(r *mcp.ResourceTemplate) string { return r.Name }) - printSection("prompts", cs.Prompts(ctx, nil), func(p *mcp.Prompt) string { return p.Name }) + if cs.InitializeResult().Capabilities.Tools != nil { + printSection("tools", cs.Tools(ctx, nil), func(t *mcp.Tool) string { return t.Name }) + } + if cs.InitializeResult().Capabilities.Resources != nil { + printSection("resources", cs.Resources(ctx, nil), func(r *mcp.Resource) string { return r.Name }) + printSection("resource templates", cs.ResourceTemplates(ctx, nil), func(r *mcp.ResourceTemplate) string { return r.Name }) + } + if cs.InitializeResult().Capabilities.Prompts != nil { + printSection("prompts", cs.Prompts(ctx, nil), func(p *mcp.Prompt) string { return p.Name }) + } } func printSection[T any](name string, features iter.Seq2[T, error], featName func(T) string) { diff --git a/mcp/streamable.go b/mcp/streamable.go index 8072a63..a96386d 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -12,6 +12,7 @@ import ( "fmt" "io" "iter" + "log/slog" "math" "math/rand/v2" "net/http" @@ -981,6 +982,14 @@ type StreamableClientTransport struct { // MaxRetries is the maximum number of times to attempt a reconnect before giving up. // It defaults to 5. To disable retries, use a negative number. MaxRetries int + + // TODO(rfindley): propose exporting these. + // If strict is set, the transport is in 'strict mode', where any violation + // of the MCP spec causes a failure. + strict bool + // If logger is set, it is used to log aspects of the transport, such as spec + // violations that were ignored. + logger *slog.Logger } // These settings are not (yet) exposed to the user in @@ -1025,6 +1034,8 @@ func (t *StreamableClientTransport) Connect(ctx context.Context) (Connection, er incoming: make(chan jsonrpc.Message, 10), done: make(chan struct{}), maxRetries: maxRetries, + strict: t.strict, + logger: t.logger, ctx: connCtx, cancel: cancel, failed: make(chan struct{}), @@ -1039,6 +1050,8 @@ type streamableClientConn struct { cancel context.CancelFunc incoming chan jsonrpc.Message maxRetries int + strict bool // from [StreamableClientTransport.strict] + logger *slog.Logger // from [StreamableClientTransport.logger] // Guard calls to Close, as it may be called multiple times. closeOnce sync.Once @@ -1152,9 +1165,11 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e } var requestSummary string + var isCall bool switch msg := msg.(type) { case *jsonrpc.Request: requestSummary = fmt.Sprintf("sending %q", msg.Method) + isCall = msg.IsCall() case *jsonrpc.Response: requestSummary = fmt.Sprintf("sending jsonrpc response #%d", msg.ID) default: @@ -1209,11 +1224,24 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e } } if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusAccepted { + // [§2.1.4]: "If the input is a JSON-RPC response or notification: + // If the server accepts the input, the server MUST return HTTP status code 202 Accepted with no body." + // + // [§2.1.4]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#listening-for-messages-from-the-server + resp.Body.Close() + return nil + } else if !isCall && !c.strict { + // Some servers return 200, even with an empty json body. + // Ignore this response in non-strict mode. + if c.logger != nil { + c.logger.Warn(fmt.Sprintf("unexpected status code %d from non-call", resp.StatusCode)) + } resp.Body.Close() return nil } - switch ct := resp.Header.Get("Content-Type"); ct { + contentType := strings.TrimSpace(strings.SplitN(resp.Header.Get("Content-Type"), ";", 2)[0]) + switch contentType { case "application/json": go c.handleJSON(requestSummary, resp) @@ -1223,7 +1251,7 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e default: resp.Body.Close() - return fmt.Errorf("%s: unsupported content type %q", requestSummary, ct) + return fmt.Errorf("%s: unsupported content type %q", requestSummary, contentType) } return nil } @@ -1294,18 +1322,36 @@ func (c *streamableClientConn) handleSSE(requestSummary string, initialResp *htt newResp, err := c.reconnect(lastEventID) if err != nil { // All reconnection attempts failed: fail the connection. - c.fail(fmt.Errorf("%s: failed to reconnect: %v", requestSummary, err)) + c.fail(fmt.Errorf("%s: failed to reconnect (session ID: %v): %v", requestSummary, c.sessionID, err)) return } resp = newResp if resp.StatusCode == http.StatusMethodNotAllowed && persistent { + // [§2.2.3]: "The server MUST either return Content-Type: + // text/event-stream in response to this HTTP GET, or else return HTTP + // 405 Method Not Allowed, indicating that the server does not offer an + // SSE stream at this endpoint." + // + // [§2.2.3]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#listening-for-messages-from-the-server + // The server doesn't support the hanging GET. resp.Body.Close() return } + if resp.StatusCode == http.StatusNotFound && persistent && !c.strict { + // modelcontextprotocol/gosdk#393: some servers return NotFound instead + // of MethodNotAllowed for the persistent GET. + // + // Treat this like MethodNotAllowed in non-strict mode. + if c.logger != nil { + c.logger.Warn("got 404 instead of 405 for hanging GET") + } + resp.Body.Close() + return + } // (see equivalent handling in [streamableClientConn.Write]). if resp.StatusCode == http.StatusNotFound { - c.fail(fmt.Errorf("%s: failed to reconnect: %w", requestSummary, errSessionMissing)) + c.fail(fmt.Errorf("%s: failed to reconnect (session ID: %v): %w", requestSummary, c.sessionID, errSessionMissing)) return } if resp.StatusCode < 200 || resp.StatusCode >= 300 { diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 001d3a6..45c4a2e 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -256,7 +256,7 @@ func TestStreamableClientGETHandling(t *testing.T) { responses: fakeResponses{ {"POST", "", methodInitialize}: { header: header{ - "Content-Type": "application/json", + "Content-Type": "application/json; charset=utf-8", // should ignore the charset sessionIDHeader: "123", }, body: jsonBody(t, initResp), @@ -293,8 +293,8 @@ func TestStreamableClientGETHandling(t *testing.T) { t.Fatalf("client.Connect() failed: %v", err) } - // wait for all required requests to be handled, with exponential - // backoff. + // Since we need the client to observe the result of the hanging GET, + // wait for all requests to be handled. start := time.Now() delay := 1 * time.Millisecond for range 10 { @@ -323,3 +323,91 @@ func TestStreamableClientGETHandling(t *testing.T) { }) } } + +func TestStreamableClientStrictness(t *testing.T) { + ctx := context.Background() + + tests := []struct { + label string + strict bool + initializedStatus int + getStatus int + wantConnectError bool + wantListError bool + }{ + {"conformant server", true, http.StatusAccepted, http.StatusMethodNotAllowed, false, false}, + {"strict initialized", true, http.StatusOK, http.StatusMethodNotAllowed, true, false}, + {"unstrict initialized", false, http.StatusOK, http.StatusMethodNotAllowed, false, false}, + {"strict GET", true, http.StatusAccepted, http.StatusNotFound, false, true}, + {"unstrict GET", false, http.StatusOK, http.StatusNotFound, false, false}, + } + for _, test := range tests { + t.Run(test.label, func(t *testing.T) { + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodInitialize}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "123", + }, + body: jsonBody(t, initResp), + }, + {"POST", "123", notificationInitialized}: { + status: test.initializedStatus, + wantProtocolVersion: latestProtocolVersion, + }, + {"GET", "123", ""}: { + header: header{ + "Content-Type": "text/event-stream", + }, + status: test.getStatus, + wantProtocolVersion: latestProtocolVersion, + }, + {"POST", "123", methodListTools}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "123", + }, + body: jsonBody(t, resp(2, &ListToolsResult{Tools: []*Tool{}}, nil)), + optional: true, + }, + {"DELETE", "123", ""}: {optional: true}, + }, + } + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL, strict: test.strict} + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, nil) + if (err != nil) != test.wantConnectError { + t.Errorf("client.Connect() returned error %v; want error: %t", err, test.wantConnectError) + } + if err != nil { + return + } + // Since we need the client to observe the result of the hanging GET, + // wait for all requests to be handled. + start := time.Now() + delay := 1 * time.Millisecond + for range 10 { + if len(fake.missingRequests()) == 0 { + break + } + time.Sleep(delay) + delay *= 2 + } + if missing := fake.missingRequests(); len(missing) > 0 { + t.Errorf("did not receive expected requests after %s: %v", time.Since(start), missing) + } + _, err = session.ListTools(ctx, nil) + if (err != nil) != test.wantListError { + t.Errorf("ListTools returned error %v; want error: %t", err, test.wantListError) + } + if err := session.Close(); err != nil { + t.Errorf("closing session: %v", err) + } + }) + } +} From b9c1419ab008713817095b58160da581fcac3bcf Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Thu, 25 Sep 2025 14:23:35 -0400 Subject: [PATCH 078/418] README: update for v0.7.0 (#528) --- README.md | 4 ++-- internal/readme/README.src.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a12a026..960f51f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -# MCP Go SDK v0.6.0 +# MCP Go SDK v0.7.0 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/modelcontextprotocol/go-sdk) @@ -7,7 +7,7 @@ This version contains minor breaking changes. See the [release notes]( -https://github.com/modelcontextprotocol/go-sdk/releases/tag/v0.6.0) for details. +https://github.com/modelcontextprotocol/go-sdk/releases/tag/v0.7.0) for details. [![PkgGoDev](https://pkg.go.dev/badge/github.com/modelcontextprotocol/go-sdk)](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md index 10ce9b6..7119dda 100644 --- a/internal/readme/README.src.md +++ b/internal/readme/README.src.md @@ -1,4 +1,4 @@ -# MCP Go SDK v0.6.0 +# MCP Go SDK v0.7.0 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/modelcontextprotocol/go-sdk) @@ -6,7 +6,7 @@ This version contains minor breaking changes. See the [release notes]( -https://github.com/modelcontextprotocol/go-sdk/releases/tag/v0.6.0) for details. +https://github.com/modelcontextprotocol/go-sdk/releases/tag/v0.7.0) for details. [![PkgGoDev](https://pkg.go.dev/badge/github.com/modelcontextprotocol/go-sdk)](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) From 5f05f8a43a675b56f00b1e0aead480966753ef66 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Thu, 25 Sep 2025 16:06:24 -0400 Subject: [PATCH 079/418] many: minor cosmetic changes (#529) --- README.md | 6 +++--- auth/auth.go | 2 -- internal/jsonrpc2/messages.go | 4 ++-- internal/readme/README.src.md | 2 +- internal/readme/client/client.go | 2 +- internal/readme/server/server.go | 2 +- jsonrpc/jsonrpc.go | 4 ++-- mcp/client.go | 4 ++-- mcp/content.go | 4 ++-- mcp/event.go | 1 - mcp/protocol.go | 2 +- mcp/server.go | 1 + mcp/shared.go | 6 +++--- oauthex/oauthex.go | 2 ++ 14 files changed, 21 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 960f51f..b67ab2a 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ The SDK consists of several importable packages: their own transports. - The [`github.com/modelcontextprotocol/go-sdk/auth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth) - package provides some primitives for supporting oauth. + package provides some primitives for supporting OAuth. - The [`github.com/modelcontextprotocol/go-sdk/oauthex`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/oauthex) package provides extensions to the OAuth protocol, such as ProtectedResourceMetadata. @@ -78,7 +78,7 @@ func main() { // Create a server with a single tool. server := mcp.NewServer(&mcp.Implementation{Name: "greeter", Version: "v1.0.0"}, nil) mcp.AddTool(server, &mcp.Tool{Name: "greet", Description: "say hi"}, SayHi) - // Run the server over stdin/stdout, until the client disconnects + // Run the server over stdin/stdout, until the client disconnects. if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil { log.Fatal(err) } @@ -106,7 +106,7 @@ func main() { // Create a new client, with no features. client := mcp.NewClient(&mcp.Implementation{Name: "mcp-client", Version: "v1.0.0"}, nil) - // Connect to a server over stdin/stdout + // Connect to a server over stdin/stdout. transport := &mcp.CommandTransport{Command: exec.Command("myserver")} session, err := client.Connect(ctx, transport, nil) if err != nil { diff --git a/auth/auth.go b/auth/auth.go index 7cc0074..0eea1d8 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -56,8 +56,6 @@ func TokenInfoFromContext(ctx context.Context) *TokenInfo { // If verification succeeds, the [TokenInfo] is added to the request's context and the request proceeds. // If verification fails, the request fails with a 401 Unauthenticated, and the WWW-Authenticate header // is populated to enable [protected resource metadata]. -// - // // [protected resource metadata]: https://datatracker.ietf.org/doc/rfc9728 func RequireBearerToken(verifier TokenVerifier, opts *RequireBearerTokenOptions) func(http.Handler) http.Handler { diff --git a/internal/jsonrpc2/messages.go b/internal/jsonrpc2/messages.go index 9c0d5d6..791e698 100644 --- a/internal/jsonrpc2/messages.go +++ b/internal/jsonrpc2/messages.go @@ -16,8 +16,8 @@ type ID struct { value any } -// MakeID coerces the given Go value to an ID. The value is assumed to be the -// default JSON marshaling of a Request identifier -- nil, float64, or string. +// MakeID coerces the given Go value to an ID. The value should be the +// default JSON marshaling of a Request identifier: nil, float64, or string. // // Returns an error if the value type was not a valid Request ID type. // diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md index 7119dda..329f0ff 100644 --- a/internal/readme/README.src.md +++ b/internal/readme/README.src.md @@ -32,7 +32,7 @@ The SDK consists of several importable packages: their own transports. - The [`github.com/modelcontextprotocol/go-sdk/auth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth) - package provides some primitives for supporting oauth. + package provides some primitives for supporting OAuth. - The [`github.com/modelcontextprotocol/go-sdk/oauthex`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/oauthex) package provides extensions to the OAuth protocol, such as ProtectedResourceMetadata. diff --git a/internal/readme/client/client.go b/internal/readme/client/client.go index 9f35796..8fc9972 100644 --- a/internal/readme/client/client.go +++ b/internal/readme/client/client.go @@ -19,7 +19,7 @@ func main() { // Create a new client, with no features. client := mcp.NewClient(&mcp.Implementation{Name: "mcp-client", Version: "v1.0.0"}, nil) - // Connect to a server over stdin/stdout + // Connect to a server over stdin/stdout. transport := &mcp.CommandTransport{Command: exec.Command("myserver")} session, err := client.Connect(ctx, transport, nil) if err != nil { diff --git a/internal/readme/server/server.go b/internal/readme/server/server.go index bfa0825..5d7656d 100644 --- a/internal/readme/server/server.go +++ b/internal/readme/server/server.go @@ -32,7 +32,7 @@ func main() { // Create a server with a single tool. server := mcp.NewServer(&mcp.Implementation{Name: "greeter", Version: "v1.0.0"}, nil) mcp.AddTool(server, &mcp.Tool{Name: "greet", Description: "say hi"}, SayHi) - // Run the server over stdin/stdout, until the client disconnects + // Run the server over stdin/stdout, until the client disconnects. if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil { log.Fatal(err) } diff --git a/jsonrpc/jsonrpc.go b/jsonrpc/jsonrpc.go index 1cf1202..1633d4e 100644 --- a/jsonrpc/jsonrpc.go +++ b/jsonrpc/jsonrpc.go @@ -19,8 +19,8 @@ type ( Response = jsonrpc2.Response ) -// MakeID coerces the given Go value to an ID. The value is assumed to be the -// default JSON marshaling of a Request identifier -- nil, float64, or string. +// MakeID coerces the given Go value to an ID. The value should be the +// default JSON marshaling of a Request identifier: nil, float64, or string. // // Returns an error if the value type was not a valid Request ID type. func MakeID(v any) (ID, error) { diff --git a/mcp/client.go b/mcp/client.go index dea3e85..ad95384 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -668,7 +668,7 @@ func (cs *ClientSession) callProgressNotificationHandler(ctx context.Context, pa // NotifyProgress sends a progress notification from the client to the server // associated with this session. // This can be used if the client is performing a long-running task that was -// initiated by the server +// initiated by the server. func (cs *ClientSession) NotifyProgress(ctx context.Context, params *ProgressNotificationParams) error { return handleNotify(ctx, notificationProgress, newClientRequest(cs, orZero[Params](params))) } @@ -701,7 +701,7 @@ func (cs *ClientSession) Resources(ctx context.Context, params *ListResourcesPar // ResourceTemplates provides an iterator for all resource templates available on the server, // automatically fetching pages and managing cursors. -// The `params` argument can set the initial cursor. +// The params argument can set the initial cursor. // Iteration stops at the first encountered error, which will be yielded. func (cs *ClientSession) ResourceTemplates(ctx context.Context, params *ListResourceTemplatesParams) iter.Seq2[*ResourceTemplate, error] { if params == nil { diff --git a/mcp/content.go b/mcp/content.go index 108b027..e53cad1 100644 --- a/mcp/content.go +++ b/mcp/content.go @@ -189,7 +189,7 @@ type ResourceContents struct { Meta Meta `json:"_meta,omitempty"` } -func (r ResourceContents) MarshalJSON() ([]byte, error) { +func (r *ResourceContents) MarshalJSON() ([]byte, error) { // If we could assume Go 1.24, we could use omitzero for Blob and avoid this method. if r.URI == "" { return nil, errors.New("ResourceContents missing URI") @@ -197,7 +197,7 @@ func (r ResourceContents) MarshalJSON() ([]byte, error) { if r.Blob == nil { // Text. Marshal normally. type wireResourceContents ResourceContents // (lacks MarshalJSON method) - return json.Marshal((wireResourceContents)(r)) + return json.Marshal((wireResourceContents)(*r)) } // Blob. if r.Text != "" { diff --git a/mcp/event.go b/mcp/event.go index bd78cde..1dd36f4 100644 --- a/mcp/event.go +++ b/mcp/event.go @@ -176,7 +176,6 @@ type EventStore interface { // with all of its streams. // A store cannot rely on this method being called for cleanup. It should institute // additional mechanisms, such as timeouts, to reclaim storage. - // SessionClosed(_ context.Context, sessionID string) error // There is no StreamClosed method. A server doesn't know when a stream is finished, because diff --git a/mcp/protocol.go b/mcp/protocol.go index f3f23f5..eacb904 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -18,7 +18,7 @@ import ( ) // Optional annotations for the client. The client can use annotations to inform -// how objects are used or displayed +// how objects are used or displayed. type Annotations struct { // Describes who the intended customer of this object or data is. // diff --git a/mcp/server.go b/mcp/server.go index 27de09a..882ac9f 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -27,6 +27,7 @@ import ( "github.com/yosida95/uritemplate/v3" ) +// DefaultPageSize is the default for [ServerOptions.PageSize]. const DefaultPageSize = 1000 // A Server is an instance of an MCP server. diff --git a/mcp/shared.go b/mcp/shared.go index 69b8836..46e994f 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -453,13 +453,13 @@ func clientRequestFor[P Params](s *ClientSession, p P) *ClientRequest[P] { // Params is a parameter (input) type for an MCP call or notification. type Params interface { - // isParams discourages implementation of Params outside of this package. - isParams() - // GetMeta returns metadata from a value. GetMeta() map[string]any // SetMeta sets the metadata on a value. SetMeta(map[string]any) + + // isParams discourages implementation of Params outside of this package. + isParams() } // RequestParams is a parameter (input) type for an MCP request. diff --git a/oauthex/oauthex.go b/oauthex/oauthex.go index eb3a3e7..df32678 100644 --- a/oauthex/oauthex.go +++ b/oauthex/oauthex.go @@ -7,4 +7,6 @@ package oauthex import "github.com/modelcontextprotocol/go-sdk/internal/oauthex" +// ProtectedResourceMetadata is the metadata for an OAuth 2.0 protected resource, +// as defined in section 2 of https://www.rfc-editor.org/rfc/rfc9728.html. type ProtectedResourceMetadata = oauthex.ProtectedResourceMetadata From 964574b81844a1800fedc73ac53f56211bf4c607 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Fri, 26 Sep 2025 11:55:54 -0400 Subject: [PATCH 080/418] mcp: allow any value for schemas (#517) As discussed at length in https://github.com/modelcontextprotocol/go-sdk/issues/518, coupling our schema fields to jsonschema.Schema limits our user's ability to use a different schema package, and makes it impossible for our client to talk to some backends. This CL relaxes the type of `*jsonschema.Schema` fields to be of type `any`, which is a breaking change but is unlikely to affect most server code. (It *is* a nontrivial change for clients). Fixes https://github.com/modelcontextprotocol/go-sdk/issues/518 --- examples/server/toolschemas/main.go | 28 ++++++++++---- mcp/client.go | 26 ++++++++----- mcp/client_list_test.go | 9 ++++- mcp/protocol.go | 49 ++++++++++++++++++----- mcp/server.go | 60 ++++++++++++++++++++++------- mcp/server_test.go | 2 +- mcp/tool_example_test.go | 60 ++++++++++++++++++++++++++--- mcp/util.go | 14 +++++++ 8 files changed, 201 insertions(+), 47 deletions(-) diff --git a/examples/server/toolschemas/main.go b/examples/server/toolschemas/main.go index a53acd4..abc1e3f 100644 --- a/examples/server/toolschemas/main.go +++ b/examples/server/toolschemas/main.go @@ -126,15 +126,13 @@ func main() { // Add the 'greeting' tool in a few different ways. // First, we can just use [mcp.AddTool], and get the out-of-the-box handling - // it provides: + // it provides for schema inference, validation, parsing, and packing the + // result. mcp.AddTool(server, &mcp.Tool{Name: "simple greeting"}, simpleGreeting) - // Next, we can create our schemas entirely manually, and add them using - // [mcp.Server.AddTool]. Since we're working manually, we can add some - // constraints on the length of the name. - // - // We don't need to do all this work: below, we use jsonschema.For to start - // from the default schema. + // Alternatively, we can create our schemas entirely manually, and add them + // using [mcp.Server.AddTool]. Since we're using the 'raw' API, we have to do + // the parsing and validation ourselves manual, err := newManualGreeter() if err != nil { log.Fatal(err) @@ -145,6 +143,22 @@ func main() { OutputSchema: outputSchema, }, manual.greet) + // We can even use raw schema values. In this case, note that we're not + // validating the input at all. + server.AddTool(&mcp.Tool{ + Name: "unvalidated greeting", + InputSchema: json.RawMessage(`{"type":"object","properties":{"user":{"type":"string"}}}`), + }, func(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Note: no validation! + var args struct{ User string } + if err := json.Unmarshal(req.Params.Arguments, &args); err != nil { + return nil, err + } + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "Hi " + args.User}}, + }, nil + }) + // Finally, note that we can also use custom schemas with a ToolHandlerFor. // We can do this in two ways: by using one of the schema values constructed // above, or by using jsonschema.For and adjusting the resulting schema. diff --git a/mcp/client.go b/mcp/client.go index ad95384..f8150e9 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -295,7 +295,8 @@ func (c *Client) elicit(ctx context.Context, req *ElicitRequest) (*ElicitResult, } // Validate that the requested schema only contains top-level properties without nesting - if err := validateElicitSchema(req.Params.RequestedSchema); err != nil { + schema, err := validateElicitSchema(req.Params.RequestedSchema) + if err != nil { return nil, jsonrpc2.NewError(CodeInvalidParams, err.Error()) } @@ -305,11 +306,11 @@ func (c *Client) elicit(ctx context.Context, req *ElicitRequest) (*ElicitResult, } // Validate elicitation result content against requested schema - if req.Params.RequestedSchema != nil && res.Content != nil { + if schema != nil && res.Content != nil { // TODO: is this the correct behavior if validation fails? // It isn't the *server's* params that are invalid, so why would we return // this code to the server? - resolved, err := req.Params.RequestedSchema.Resolve(nil) + resolved, err := schema.Resolve(nil) if err != nil { return nil, jsonrpc2.NewError(CodeInvalidParams, fmt.Sprintf("failed to resolve requested schema: %v", err)) } @@ -324,14 +325,19 @@ func (c *Client) elicit(ctx context.Context, req *ElicitRequest) (*ElicitResult, // validateElicitSchema validates that the schema conforms to MCP elicitation schema requirements. // Per the MCP specification, elicitation schemas are limited to flat objects with primitive properties only. -func validateElicitSchema(schema *jsonschema.Schema) error { - if schema == nil { - return nil // nil schema is allowed +func validateElicitSchema(wireSchema any) (*jsonschema.Schema, error) { + if wireSchema == nil { + return nil, nil // nil schema is allowed + } + + var schema *jsonschema.Schema + if err := remarshal(wireSchema, &schema); err != nil { + return nil, err } // The root schema must be of type "object" if specified if schema.Type != "" && schema.Type != "object" { - return fmt.Errorf("elicit schema must be of type 'object', got %q", schema.Type) + return nil, fmt.Errorf("elicit schema must be of type 'object', got %q", schema.Type) } // Check if the schema has properties @@ -342,12 +348,12 @@ func validateElicitSchema(schema *jsonschema.Schema) error { } if err := validateElicitProperty(propName, propSchema); err != nil { - return err + return nil, err } } } - return nil + return schema, nil } // validateElicitProperty validates a single property in an elicitation schema. @@ -383,7 +389,7 @@ func validateElicitStringProperty(propName string, propSchema *jsonschema.Schema if propSchema.Extra != nil { if enumNamesRaw, exists := propSchema.Extra["enumNames"]; exists { // Type check enumNames - should be a slice - if enumNamesSlice, ok := enumNamesRaw.([]interface{}); ok { + if enumNamesSlice, ok := enumNamesRaw.([]any); ok { if len(enumNamesSlice) != len(propSchema.Enum) { return fmt.Errorf("elicit schema property %q has %d enum values but %d enumNames, they must match", propName, len(propSchema.Enum), len(enumNamesSlice)) } diff --git a/mcp/client_list_test.go b/mcp/client_list_test.go index 0183a73..6974ad5 100644 --- a/mcp/client_list_test.go +++ b/mcp/client_list_test.go @@ -6,6 +6,7 @@ package mcp_test import ( "context" + "encoding/json" "iter" "log" "testing" @@ -41,7 +42,13 @@ func TestList(t *testing.T) { if err != nil { t.Fatal(err) } - tt.InputSchema = is + data, err := json.Marshal(is) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &tt.InputSchema); err != nil { + t.Fatal(err) + } wantTools = append(wantTools, tt) } t.Run("list", func(t *testing.T) { diff --git a/mcp/protocol.go b/mcp/protocol.go index eacb904..1312dfb 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -13,8 +13,6 @@ package mcp import ( "encoding/json" "fmt" - - "github.com/google/jsonschema-go/jsonschema" ) // Optional annotations for the client. The client can use annotations to inform @@ -913,14 +911,38 @@ type Tool struct { // This can be used by clients to improve the LLM's understanding of available // tools. It can be thought of like a "hint" to the model. Description string `json:"description,omitempty"` - // A JSON Schema object defining the expected parameters for the tool. - InputSchema *jsonschema.Schema `json:"inputSchema"` + // InputSchema holds a JSON Schema object defining the expected parameters + // for the tool. + // + // From the server, this field may be set to any value that JSON-marshals to + // valid JSON schema (including json.RawMessage). However, for tools added + // using [AddTool], which automatically validates inputs and outputs, the + // schema must be in a draft the SDK understands. Currently, the SDK uses + // github.com/google/jsonschema-go for inference and validation, which only + // supports the 2020-12 draft of JSON schema. To do your own validation, use + // [Server.AddTool]. + // + // From the client, this field will hold the default JSON marshaling of the + // server's input schema (a map[string]any). + InputSchema any `json:"inputSchema"` // Intended for programmatic or logical use, but used as a display name in past // specs or fallback (if title isn't present). Name string `json:"name"` - // An optional JSON Schema object defining the structure of the tool's output - // returned in the structuredContent field of a CallToolResult. - OutputSchema *jsonschema.Schema `json:"outputSchema,omitempty"` + // OutputSchema holds an optional JSON Schema object defining the structure + // of the tool's output returned in the StructuredContent field of a + // CallToolResult. + // + // From the server, this field may be set to any value that JSON-marshals to + // valid JSON schema (including json.RawMessage). However, for tools added + // using [AddTool], which automatically validates inputs and outputs, the + // schema must be in a draft the SDK understands. Currently, the SDK uses + // github.com/google/jsonschema-go for inference and validation, which only + // supports the 2020-12 draft of JSON schema. To do your own validation, use + // [Server.AddTool]. + // + // From the client, this field will hold the default JSON marshaling of the + // server's output schema (a map[string]any). + OutputSchema any `json:"outputSchema,omitempty"` // Intended for UI and end-user contexts — optimized to be human-readable and // easily understood, even by those unfamiliar with domain-specific terminology. // If not provided, Annotations.Title should be used for display if present, @@ -1022,9 +1044,18 @@ type ElicitParams struct { Meta `json:"_meta,omitempty"` // The message to present to the user. Message string `json:"message"` - // A restricted subset of JSON Schema. + // A JSON schema object defining the requested elicitation schema. + // + // From the server, this field may be set to any value that can JSON-marshal + // to valid JSON schema (including json.RawMessage for raw schema values). + // Internally, the SDK uses github.com/google/jsonschema-go for validation, + // which only supports the 2020-12 draft of the JSON schema spec. + // + // From the client, this field will use the default JSON marshaling (a + // map[string]any). + // // Only top-level properties are allowed, without nesting. - RequestedSchema *jsonschema.Schema `json:"requestedSchema"` + RequestedSchema any `json:"requestedSchema"` } func (x *ElicitParams) isParams() {} diff --git a/mcp/server.go b/mcp/server.go index 882ac9f..b74494b 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -165,9 +165,9 @@ func (s *Server) RemovePrompts(names ...string) { // // The tool's input schema must be non-nil and have the type "object". For a tool // that takes no input, or one where any input is valid, set [Tool.InputSchema] to -// &jsonschema.Schema{Type: "object"}. +// `{"type": "object"}`, using your preferred library or `json.RawMessage`. // -// If present, the output schema must also have type "object". +// If present, [Tool.OutputSchema] must also have type "object". // // When the handler is invoked as part of a CallTool request, req.Params.Arguments // will be a json.RawMessage. @@ -190,11 +190,29 @@ func (s *Server) AddTool(t *Tool, h ToolHandler) { // discovered until runtime, when the LLM sent bad data. panic(fmt.Errorf("AddTool %q: missing input schema", t.Name)) } - if t.InputSchema.Type != "object" { + if s, ok := t.InputSchema.(*jsonschema.Schema); ok && s.Type != "object" { panic(fmt.Errorf(`AddTool %q: input schema must have type "object"`, t.Name)) + } else { + var m map[string]any + if err := remarshal(t.InputSchema, &m); err != nil { + panic(fmt.Errorf("AddTool %q: can't marshal input schema to a JSON object: %v", t.Name, err)) + } + if typ := m["type"]; typ != "object" { + panic(fmt.Errorf(`AddTool %q: input schema must have type "object" (got %v)`, t.Name, typ)) + } } - if t.OutputSchema != nil && t.OutputSchema.Type != "object" { - panic(fmt.Errorf(`AddTool %q: output schema must have type "object"`, t.Name)) + if t.OutputSchema != nil { + if s, ok := t.OutputSchema.(*jsonschema.Schema); ok && s.Type != "object" { + panic(fmt.Errorf(`AddTool %q: output schema must have type "object"`, t.Name)) + } else { + var m map[string]any + if err := remarshal(t.OutputSchema, &m); err != nil { + panic(fmt.Errorf("AddTool %q: can't marshal output schema to a JSON object: %v", t.Name, err)) + } + if typ := m["type"]; typ != "object" { + panic(fmt.Errorf(`AddTool %q: output schema must have type "object" (got %v)`, t.Name, typ)) + } + } } st := &serverTool{tool: t, handler: h} // Assume there was a change, since add replaces existing tools. @@ -332,7 +350,8 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHan // // TODO(rfindley): we really shouldn't ever return 'null' results. Maybe we // should have a jsonschema.Zero(schema) helper? -func setSchema[T any](sfield **jsonschema.Schema, rfield **jsonschema.Resolved) (zero any, err error) { +func setSchema[T any](sfield *any, rfield **jsonschema.Resolved) (zero any, err error) { + var internalSchema *jsonschema.Schema if *sfield == nil { rt := reflect.TypeFor[T]() if rt.Kind() == reflect.Pointer { @@ -340,28 +359,41 @@ func setSchema[T any](sfield **jsonschema.Schema, rfield **jsonschema.Resolved) zero = reflect.Zero(rt).Interface() } // TODO: we should be able to pass nil opts here. - *sfield, err = jsonschema.ForType(rt, &jsonschema.ForOptions{}) + internalSchema, err = jsonschema.ForType(rt, &jsonschema.ForOptions{}) + if err == nil { + *sfield = internalSchema + } + } else { + if err := remarshal(*sfield, &internalSchema); err != nil { + return zero, err + } } if err != nil { return zero, err } - *rfield, err = (*sfield).Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true}) + *rfield, err = internalSchema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true}) return zero, err } // AddTool adds a tool and typed tool handler to the server. // // If the tool's input schema is nil, it is set to the schema inferred from the -// In type parameter, using [jsonschema.For]. The In type argument must be a -// map or a struct, so that its inferred JSON Schema has type "object". +// In type parameter. Types are inferred from Go types, and property +// descriptions are read from the 'jsonschema' struct tag. Internally, the SDK +// uses the github.com/google/jsonschema-go package for ineference and +// validation. The In type argument must be a map or a struct, so that its +// inferred JSON Schema has type "object", as required by the spec. As a +// special case, if the In type is 'any', the tool's input schema is set to an +// empty object schema value. // // If the tool's output schema is nil, and the Out type is not 'any', the // output schema is set to the schema inferred from the Out type argument, -// which also must be a map or struct. +// which must also be a map or struct. If the Out type is 'any', the output +// schema is omitted. // -// Unlike [Server.AddTool], AddTool does a lot automatically, and forces tools -// to conform to the MCP spec. See [ToolHandlerFor] for a detailed description -// of this automatic behavior. +// Unlike [Server.AddTool], AddTool does a lot automatically, and forces +// tools to conform to the MCP spec. See [ToolHandlerFor] for a detailed +// description of this automatic behavior. func AddTool[In, Out any](s *Server, t *Tool, h ToolHandlerFor[In, Out]) { tt, hh, err := toolForErr(t, h) if err != nil { diff --git a/mcp/server_test.go b/mcp/server_test.go index 249ef90..ec6114d 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -492,7 +492,7 @@ func TestAddTool(t *testing.T) { type schema = jsonschema.Schema -func testToolForSchema[In, Out any](t *testing.T, tool *Tool, in string, out Out, wantIn, wantOut *schema, wantErrContaining string) { +func testToolForSchema[In, Out any](t *testing.T, tool *Tool, in string, out Out, wantIn, wantOut any, wantErrContaining string) { t.Helper() th := func(context.Context, *CallToolRequest, In) (*CallToolResult, Out, error) { return nil, out, nil diff --git a/mcp/tool_example_test.go b/mcp/tool_example_test.go index e41250d..8efc3ee 100644 --- a/mcp/tool_example_test.go +++ b/mcp/tool_example_test.go @@ -16,6 +16,49 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) +func ExampleServer_AddTool_rawSchema() { + // In some scenarios, you may want your server to be a pass-through, with + // JSON schema coming from another source. Or perhaps you want to implement + // tool validation using a different JSON schema library. + // + // For these cases, you can use [mcp.Server.AddTool], which is the "raw" form + // of the API. Note that it is the caller's responsibility to validate inputs + // and outputs. + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + server.AddTool(&mcp.Tool{ + Name: "greet", + InputSchema: json.RawMessage(`{"type":"object","properties":{"user":{"type":"string"}}}`), + }, func(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Note: no validation! + var args struct{ User string } + if err := json.Unmarshal(req.Params.Arguments, &args); err != nil { + // TODO: we should use a jsonrpc error here, to be consistent with other + // SDKs. + return nil, err + } + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "Hi " + args.User}}, + }, nil + }) + + ctx := context.Background() + session, err := connect(ctx, server) + if err != nil { + log.Fatal(err) + } + defer session.Close() + + res, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "greet", + Arguments: map[string]any{"user": "you"}, + }) + if err != nil { + log.Fatal(err) + } + fmt.Println(res.Content[0].(*mcp.TextContent).Text) + // Output: Hi you +} + func ExampleAddTool_customMarshalling() { // Sometimes when you want to customize the input or output schema for a // tool, you need to customize the schema of a single helper type that's used @@ -68,7 +111,7 @@ func ExampleAddTool_customMarshalling() { } // Output: // my_tool { - // "type": "object", + // "additionalProperties": false, // "properties": { // "end": { // "type": "string" @@ -80,7 +123,7 @@ func ExampleAddTool_customMarshalling() { // "type": "string" // } // }, - // "additionalProperties": false + // "type": "object" // } } @@ -200,9 +243,9 @@ func ExampleAddTool_complexSchema() { } // Formatting the entire schemas would be too much output. // Just check that our customizations were effective. - fmt.Println("max days:", *t.InputSchema.Properties["days"].Maximum) - fmt.Println("max confidence:", *t.OutputSchema.Properties["confidence"].Maximum) - fmt.Println("weather types:", t.OutputSchema.Properties["dailyForecast"].Items.Properties["type"].Enum) + fmt.Println("max days:", jsonPath(t.InputSchema, "properties", "days", "maximum")) + fmt.Println("max confidence:", jsonPath(t.OutputSchema, "properties", "confidence", "maximum")) + fmt.Println("weather types:", jsonPath(t.OutputSchema, "properties", "dailyForecast", "items", "properties", "type", "enum")) } // Output: // max days: 10 @@ -218,3 +261,10 @@ func connect(ctx context.Context, server *mcp.Server) (*mcp.ClientSession, error client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) return client.Connect(ctx, t2, nil) } + +func jsonPath(s any, path ...string) any { + if len(path) == 0 { + return s + } + return jsonPath(s.(map[string]any)[path[0]], path[1:]...) +} diff --git a/mcp/util.go b/mcp/util.go index 102c088..5ada466 100644 --- a/mcp/util.go +++ b/mcp/util.go @@ -6,6 +6,7 @@ package mcp import ( "crypto/rand" + "encoding/json" ) func assert(cond bool, msg string) { @@ -27,3 +28,16 @@ func randText() string { } return string(src) } + +// remarshal marshals from to JSON, and then unmarshals into to, which must be +// a pointer type. +func remarshal(from, to any) error { + data, err := json.Marshal(from) + if err != nil { + return err + } + if err := json.Unmarshal(data, to); err != nil { + return err + } + return nil +} From b2048cd73abbba527ba2252c1c31767221d54ecc Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Fri, 26 Sep 2025 14:04:28 -0400 Subject: [PATCH 081/418] mcp: unexport incidentally exported error codes (#536) Unexport error codes that were accidentally exported. I don't think it's possible that they could be useful for users (see #535). Fixes #535 --- mcp/client.go | 10 +++++----- mcp/mcp_test.go | 14 +++++++------- mcp/resource.go | 2 +- mcp/server.go | 4 ++-- mcp/shared.go | 8 +++----- mcp/tool_test.go | 6 +++--- 6 files changed, 21 insertions(+), 23 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index f8150e9..fb77b3f 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -283,7 +283,7 @@ func (c *Client) listRoots(_ context.Context, req *ListRootsRequest) (*ListRoots func (c *Client) createMessage(ctx context.Context, req *CreateMessageRequest) (*CreateMessageResult, error) { if c.opts.CreateMessageHandler == nil { // TODO: wrap or annotate this error? Pick a standard code? - return nil, jsonrpc2.NewError(CodeUnsupportedMethod, "client does not support CreateMessage") + return nil, jsonrpc2.NewError(codeUnsupportedMethod, "client does not support CreateMessage") } return c.opts.CreateMessageHandler(ctx, req) } @@ -291,13 +291,13 @@ func (c *Client) createMessage(ctx context.Context, req *CreateMessageRequest) ( func (c *Client) elicit(ctx context.Context, req *ElicitRequest) (*ElicitResult, error) { if c.opts.ElicitationHandler == nil { // TODO: wrap or annotate this error? Pick a standard code? - return nil, jsonrpc2.NewError(CodeUnsupportedMethod, "client does not support elicitation") + return nil, jsonrpc2.NewError(codeUnsupportedMethod, "client does not support elicitation") } // Validate that the requested schema only contains top-level properties without nesting schema, err := validateElicitSchema(req.Params.RequestedSchema) if err != nil { - return nil, jsonrpc2.NewError(CodeInvalidParams, err.Error()) + return nil, jsonrpc2.NewError(codeInvalidParams, err.Error()) } res, err := c.opts.ElicitationHandler(ctx, req) @@ -312,11 +312,11 @@ func (c *Client) elicit(ctx context.Context, req *ElicitRequest) (*ElicitResult, // this code to the server? resolved, err := schema.Resolve(nil) if err != nil { - return nil, jsonrpc2.NewError(CodeInvalidParams, fmt.Sprintf("failed to resolve requested schema: %v", err)) + return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("failed to resolve requested schema: %v", err)) } if err := resolved.Validate(res.Content); err != nil { - return nil, jsonrpc2.NewError(CodeInvalidParams, fmt.Sprintf("elicitation result content does not match requested schema: %v", err)) + return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("elicitation result content does not match requested schema: %v", err)) } } diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 9cc105c..0e1ee1f 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -322,7 +322,7 @@ func TestEndToEnd(t *testing.T) { } { rres, err := cs.ReadResource(ctx, &ReadResourceParams{URI: tt.uri}) if err != nil { - if code := errorCode(err); code == CodeResourceNotFound { + if code := errorCode(err); code == codeResourceNotFound { if tt.mimeType != "" { t.Errorf("%s: not found but expected it to be", tt.uri) } @@ -994,8 +994,8 @@ func TestElicitationUnsupportedMethod(t *testing.T) { if err == nil { t.Error("expected error when ElicitationHandler is not provided, got nil") } - if code := errorCode(err); code != CodeUnsupportedMethod { - t.Errorf("got error code %d, want %d (CodeUnsupportedMethod)", code, CodeUnsupportedMethod) + if code := errorCode(err); code != codeUnsupportedMethod { + t.Errorf("got error code %d, want %d (CodeUnsupportedMethod)", code, codeUnsupportedMethod) } if !strings.Contains(err.Error(), "does not support elicitation") { t.Errorf("error should mention unsupported elicitation, got: %v", err) @@ -1342,8 +1342,8 @@ func TestElicitationSchemaValidation(t *testing.T) { t.Errorf("expected error for invalid schema %q, got nil", tc.name) return } - if code := errorCode(err); code != CodeInvalidParams { - t.Errorf("got error code %d, want %d (CodeInvalidParams)", code, CodeInvalidParams) + if code := errorCode(err); code != codeInvalidParams { + t.Errorf("got error code %d, want %d (CodeInvalidParams)", code, codeInvalidParams) } if !strings.Contains(err.Error(), tc.expectedError) { t.Errorf("error message %q does not contain expected text %q", err.Error(), tc.expectedError) @@ -1462,8 +1462,8 @@ func TestElicitationCapabilityDeclaration(t *testing.T) { if err == nil { t.Error("expected UnsupportedMethod error when no capability declared") } - if code := errorCode(err); code != CodeUnsupportedMethod { - t.Errorf("got error code %d, want %d (CodeUnsupportedMethod)", code, CodeUnsupportedMethod) + if code := errorCode(err); code != codeUnsupportedMethod { + t.Errorf("got error code %d, want %d (CodeUnsupportedMethod)", code, codeUnsupportedMethod) } }) } diff --git a/mcp/resource.go b/mcp/resource.go index 0658c66..8746eda 100644 --- a/mcp/resource.go +++ b/mcp/resource.go @@ -41,7 +41,7 @@ type ResourceHandler func(context.Context, *ReadResourceRequest) (*ReadResourceR // not be found. func ResourceNotFoundError(uri string) error { return &jsonrpc2.WireError{ - Code: CodeResourceNotFound, + Code: codeResourceNotFound, Message: "Resource not found", Data: json.RawMessage(fmt.Sprintf(`{"uri":%q}`, uri)), } diff --git a/mcp/server.go b/mcp/server.go index b74494b..c80b7d9 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -526,7 +526,7 @@ func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetProm if !ok { // Return a proper JSON-RPC error with the correct error code return nil, &jsonrpc2.WireError{ - Code: CodeInvalidParams, + Code: codeInvalidParams, Message: fmt.Sprintf("unknown prompt %q", req.Params.Name), } } @@ -553,7 +553,7 @@ func (s *Server) callTool(ctx context.Context, req *CallToolRequest) (*CallToolR s.mu.Unlock() if !ok { return nil, &jsonrpc2.WireError{ - Code: CodeInvalidParams, + Code: codeInvalidParams, Message: fmt.Sprintf("unknown tool %q", req.Params.Name), } } diff --git a/mcp/shared.go b/mcp/shared.go index 46e994f..e90bcbd 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -331,13 +331,11 @@ func clientSessionMethod[P Params, R Result](f func(*ClientSession, context.Cont // Error codes const ( - // TODO: should these be unexported? - - CodeResourceNotFound = -32002 + codeResourceNotFound = -32002 // The error code if the method exists and was called properly, but the peer does not support it. - CodeUnsupportedMethod = -31001 + codeUnsupportedMethod = -31001 // The error code for invalid parameters - CodeInvalidParams = -32602 + codeInvalidParams = -32602 ) // notifySessions calls Notify on all the sessions. diff --git a/mcp/tool_test.go b/mcp/tool_test.go index ef26e9d..c440fcc 100644 --- a/mcp/tool_test.go +++ b/mcp/tool_test.go @@ -66,7 +66,7 @@ func TestToolErrorHandling(t *testing.T) { // Create a tool that returns a structured error structuredErrorHandler := func(ctx context.Context, req *CallToolRequest, args map[string]any) (*CallToolResult, any, error) { return nil, nil, &jsonrpc2.WireError{ - Code: CodeInvalidParams, + Code: codeInvalidParams, Message: "internal server error", } } @@ -111,8 +111,8 @@ func TestToolErrorHandling(t *testing.T) { t.Fatalf("expected WireError, got %[1]T: %[1]v", err) } - if wireErr.Code != CodeInvalidParams { - t.Errorf("expected error code %d, got %d", CodeInvalidParams, wireErr.Code) + if wireErr.Code != codeInvalidParams { + t.Errorf("expected error code %d, got %d", codeInvalidParams, wireErr.Code) } }) From 1489ba76810fbf49cc2e90c162660f0feca024a3 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Fri, 26 Sep 2025 14:15:59 -0400 Subject: [PATCH 082/418] README: update for v0.8.0 (#532) To be submitted right before the release. --- README.md | 4 ++-- internal/readme/README.src.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b67ab2a..55cdfc5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -# MCP Go SDK v0.7.0 +# MCP Go SDK v0.8.0 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/modelcontextprotocol/go-sdk) @@ -7,7 +7,7 @@ This version contains minor breaking changes. See the [release notes]( -https://github.com/modelcontextprotocol/go-sdk/releases/tag/v0.7.0) for details. +https://github.com/modelcontextprotocol/go-sdk/releases/tag/v0.8.0) for details. [![PkgGoDev](https://pkg.go.dev/badge/github.com/modelcontextprotocol/go-sdk)](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md index 329f0ff..3fd305a 100644 --- a/internal/readme/README.src.md +++ b/internal/readme/README.src.md @@ -1,4 +1,4 @@ -# MCP Go SDK v0.7.0 +# MCP Go SDK v0.8.0 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/modelcontextprotocol/go-sdk) @@ -6,7 +6,7 @@ This version contains minor breaking changes. See the [release notes]( -https://github.com/modelcontextprotocol/go-sdk/releases/tag/v0.7.0) for details. +https://github.com/modelcontextprotocol/go-sdk/releases/tag/v0.8.0) for details. [![PkgGoDev](https://pkg.go.dev/badge/github.com/modelcontextprotocol/go-sdk)](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) From ab092510d20a23bed672a27164957e0ff1104bc7 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Fri, 26 Sep 2025 14:16:10 -0400 Subject: [PATCH 083/418] mcp: fix inaccurate package doc (#534) --- mcp/mcp.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mcp/mcp.go b/mcp/mcp.go index 88321a1..56e950b 100644 --- a/mcp/mcp.go +++ b/mcp/mcp.go @@ -80,9 +80,9 @@ // validated. As a special case, if the output type is 'any', no output schema // is generated. // -// func double(_ context.Context, _ *mcp.CallToolRequest, in In) (*mcp.CallToolResponse, Out, error) { +// func double(_ context.Context, _ *mcp.CallToolRequest, in In) (*mcp.CallToolResult, Out, error) { // return nil, Out{Answer: 2*in.Number}, nil // } // ... -// mcp.AddTool(&mcp.Tool{Name: "double", Description: "double a number"}, double) +// mcp.AddTool(server, &mcp.Tool{Name: "double"}, double) package mcp From 294e1f360cc109fb32115d7601aff6c0dc8855cf Mon Sep 17 00:00:00 2001 From: Sam Thanawalla <17936816+samthanawalla@users.noreply.github.com> Date: Mon, 29 Sep 2025 09:29:12 -0400 Subject: [PATCH 084/418] internal/oauthex: add dynamic client registration (#519) This CL implements https://www.rfc-editor.org/rfc/rfc7591.html See https://github.com/modelcontextprotocol/typescript-sdk/blob/main/src/client/auth.ts as a reference. For #493 --- internal/oauthex/auth_meta.go | 207 ++++++++++++++++++ internal/oauthex/auth_meta_test.go | 203 +++++++++++++++++ .../oauthex/testdata/client-auth-meta.json | 8 + 3 files changed, 418 insertions(+) create mode 100644 internal/oauthex/testdata/client-auth-meta.json diff --git a/internal/oauthex/auth_meta.go b/internal/oauthex/auth_meta.go index 1f075f8..5bbbb41 100644 --- a/internal/oauthex/auth_meta.go +++ b/internal/oauthex/auth_meta.go @@ -8,10 +8,14 @@ package oauthex import ( + "bytes" "context" + "encoding/json" "errors" "fmt" + "io" "net/http" + "time" ) // AuthServerMeta represents the metadata for an OAuth 2.0 authorization server, @@ -109,6 +113,153 @@ type AuthServerMeta struct { CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"` } +// ClientRegistrationMetadata represents the client metadata fields for the DCR POST request (RFC 7591). +type ClientRegistrationMetadata struct { + // RedirectURIs is a REQUIRED JSON array of redirection URI strings for use in + // redirect-based flows (such as the authorization code grant). + RedirectURIs []string `json:"redirect_uris"` + + // TokenEndpointAuthMethod is an OPTIONAL string indicator of the requested + // authentication method for the token endpoint. + // If omitted, the default is "client_secret_basic". + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` + + // GrantTypes is an OPTIONAL JSON array of OAuth 2.0 grant type strings + // that the client will restrict itself to using. + // If omitted, the default is ["authorization_code"]. + GrantTypes []string `json:"grant_types,omitempty"` + + // ResponseTypes is an OPTIONAL JSON array of OAuth 2.0 response type strings + // that the client will restrict itself to using. + // If omitted, the default is ["code"]. + ResponseTypes []string `json:"response_types,omitempty"` + + // ClientName is a RECOMMENDED human-readable name of the client to be presented + // to the end-user. + ClientName string `json:"client_name,omitempty"` + + // ClientURI is a RECOMMENDED URL of a web page providing information about the client. + ClientURI string `json:"client_uri,omitempty"` + + // LogoURI is an OPTIONAL URL of a logo for the client, which may be displayed + // to the end-user. + LogoURI string `json:"logo_uri,omitempty"` + + // Scope is an OPTIONAL string containing a space-separated list of scope values + // that the client will restrict itself to using. + Scope string `json:"scope,omitempty"` + + // Contacts is an OPTIONAL JSON array of strings representing ways to contact + // people responsible for this client (e.g., email addresses). + Contacts []string `json:"contacts,omitempty"` + + // TOSURI is an OPTIONAL URL that the client provides to the end-user + // to read about the client's terms of service. + TOSURI string `json:"tos_uri,omitempty"` + + // PolicyURI is an OPTIONAL URL that the client provides to the end-user + // to read about the client's privacy policy. + PolicyURI string `json:"policy_uri,omitempty"` + + // JWKSURI is an OPTIONAL URL for the client's JSON Web Key Set [JWK] document. + // This is preferred over the 'jwks' parameter. + JWKSURI string `json:"jwks_uri,omitempty"` + + // JWKS is an OPTIONAL client's JSON Web Key Set [JWK] document, passed by value. + // This is an alternative to providing a JWKSURI. + JWKS string `json:"jwks,omitempty"` + + // SoftwareID is an OPTIONAL unique identifier string for the client software, + // constant across all instances and versions. + SoftwareID string `json:"software_id,omitempty"` + + // SoftwareVersion is an OPTIONAL version identifier string for the client software. + SoftwareVersion string `json:"software_version,omitempty"` + + // SoftwareStatement is an OPTIONAL JWT that asserts client metadata values. + // Values in the software statement take precedence over other metadata values. + SoftwareStatement string `json:"software_statement,omitempty"` +} + +// ClientRegistrationResponse represents the fields returned by the Authorization Server +// (RFC 7591, Section 3.2.1 and 3.2.2). +type ClientRegistrationResponse struct { + // ClientRegistrationMetadata contains all registered client metadata, returned by the + // server on success, potentially with modified or defaulted values. + ClientRegistrationMetadata + + // ClientID is the REQUIRED newly issued OAuth 2.0 client identifier. + ClientID string `json:"client_id"` + + // ClientSecret is an OPTIONAL client secret string. + ClientSecret string `json:"client_secret,omitempty"` + + // ClientIDIssuedAt is an OPTIONAL Unix timestamp when the ClientID was issued. + ClientIDIssuedAt time.Time `json:"client_id_issued_at,omitempty"` + + // ClientSecretExpiresAt is the REQUIRED (if client_secret is issued) Unix + // timestamp when the secret expires, or 0 if it never expires. + ClientSecretExpiresAt time.Time `json:"client_secret_expires_at,omitempty"` +} + +func (r *ClientRegistrationResponse) MarshalJSON() ([]byte, error) { + type alias ClientRegistrationResponse + var clientIDIssuedAt int64 + var clientSecretExpiresAt int64 + + if !r.ClientIDIssuedAt.IsZero() { + clientIDIssuedAt = r.ClientIDIssuedAt.Unix() + } + if !r.ClientSecretExpiresAt.IsZero() { + clientSecretExpiresAt = r.ClientSecretExpiresAt.Unix() + } + + return json.Marshal(&struct { + ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"` + ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"` + *alias + }{ + ClientIDIssuedAt: clientIDIssuedAt, + ClientSecretExpiresAt: clientSecretExpiresAt, + alias: (*alias)(r), + }) +} + +func (r *ClientRegistrationResponse) UnmarshalJSON(data []byte) error { + type alias ClientRegistrationResponse + aux := &struct { + ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"` + ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"` + *alias + }{ + alias: (*alias)(r), + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + if aux.ClientIDIssuedAt != 0 { + r.ClientIDIssuedAt = time.Unix(aux.ClientIDIssuedAt, 0) + } + if aux.ClientSecretExpiresAt != 0 { + r.ClientSecretExpiresAt = time.Unix(aux.ClientSecretExpiresAt, 0) + } + return nil +} + +// ClientRegistrationError is the error response from the Authorization Server +// for a failed registration attempt (RFC 7591, Section 3.2.2). +type ClientRegistrationError struct { + // ErrorCode is the REQUIRED error code if registration failed (RFC 7591, 3.2.2). + ErrorCode string `json:"error"` + + // ErrorDescription is an OPTIONAL human-readable error message. + ErrorDescription string `json:"error_description,omitempty"` +} + +func (e *ClientRegistrationError) Error() string { + return fmt.Sprintf("registration failed: %s (%s)", e.ErrorCode, e.ErrorDescription) +} + var wellKnownPaths = []string{ "/.well-known/oauth-authorization-server", "/.well-known/openid-configuration", @@ -143,3 +294,59 @@ func GetAuthServerMeta(ctx context.Context, issuerURL string, c *http.Client) (* } return nil, fmt.Errorf("failed to get auth server metadata from %q: %w", issuerURL, errors.Join(errs...)) } + +// RegisterClient performs Dynamic Client Registration according to RFC 7591. +func RegisterClient(ctx context.Context, registrationEndpoint string, clientMeta *ClientRegistrationMetadata, c *http.Client) (*ClientRegistrationResponse, error) { + if registrationEndpoint == "" { + return nil, fmt.Errorf("registration_endpoint is required") + } + + if c == nil { + c = http.DefaultClient + } + + payload, err := json.Marshal(clientMeta) + if err != nil { + return nil, fmt.Errorf("failed to marshal client metadata: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", registrationEndpoint, bytes.NewBuffer(payload)) + if err != nil { + return nil, fmt.Errorf("failed to create registration request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := c.Do(req) + if err != nil { + return nil, fmt.Errorf("registration request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read registration response body: %w", err) + } + + if resp.StatusCode == http.StatusCreated { + var regResponse ClientRegistrationResponse + if err := json.Unmarshal(body, ®Response); err != nil { + return nil, fmt.Errorf("failed to decode successful registration response: %w (%s)", err, string(body)) + } + if regResponse.ClientID == "" { + return nil, fmt.Errorf("registration response is missing required 'client_id' field") + } + return ®Response, nil + } + + if resp.StatusCode == http.StatusBadRequest { + var regError ClientRegistrationError + if err := json.Unmarshal(body, ®Error); err != nil { + return nil, fmt.Errorf("failed to decode registration error response: %w (%s)", err, string(body)) + } + return nil, ®Error + } + + return nil, fmt.Errorf("registration failed with status %s: %s", resp.Status, string(body)) +} diff --git a/internal/oauthex/auth_meta_test.go b/internal/oauthex/auth_meta_test.go index b83402f..6ff9f3d 100644 --- a/internal/oauthex/auth_meta_test.go +++ b/internal/oauthex/auth_meta_test.go @@ -5,10 +5,18 @@ package oauthex import ( + "context" "encoding/json" + "io" + "net/http" + "net/http/httptest" "os" "path/filepath" + "strings" "testing" + "time" + + "github.com/google/go-cmp/cmp" ) func TestAuthMetaParse(t *testing.T) { @@ -26,3 +34,198 @@ func TestAuthMetaParse(t *testing.T) { t.Errorf("got %q, want %q", g, w) } } + +func TestClientRegistrationMetadataParse(t *testing.T) { + // Verify that we can parse a typical client metadata JSON. + data, err := os.ReadFile(filepath.FromSlash("testdata/client-auth-meta.json")) + if err != nil { + t.Fatal(err) + } + var a ClientRegistrationMetadata + if err := json.Unmarshal(data, &a); err != nil { + t.Fatal(err) + } + // Spot check + if g, w := a.ClientName, "My Test App"; g != w { + t.Errorf("got ClientName %q, want %q", g, w) + } + if g, w := len(a.RedirectURIs), 2; g != w { + t.Errorf("got %d RedirectURIs, want %d", g, w) + } +} + +func TestRegisterClient(t *testing.T) { + testCases := []struct { + name string + handler http.HandlerFunc + clientMeta *ClientRegistrationMetadata + wantClientID string + wantErr string + }{ + { + name: "Success", + handler: func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("Expected POST, got %s", r.Method) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + var receivedMeta ClientRegistrationMetadata + if err := json.Unmarshal(body, &receivedMeta); err != nil { + t.Fatalf("Failed to unmarshal request body: %v", err) + } + if receivedMeta.ClientName != "Test App" { + t.Errorf("Expected ClientName 'Test App', got '%s'", receivedMeta.ClientName) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"client_id":"test-client-id","client_secret":"test-client-secret","client_name":"Test App"}`)) + }, + clientMeta: &ClientRegistrationMetadata{ClientName: "Test App", RedirectURIs: []string{"http://localhost/cb"}}, + wantClientID: "test-client-id", + }, + { + name: "Missing ClientID in Response", + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"client_secret":"test-client-secret"}`)) // No client_id + }, + clientMeta: &ClientRegistrationMetadata{RedirectURIs: []string{"http://localhost/cb"}}, + wantErr: "registration response is missing required 'client_id' field", + }, + { + name: "Standard OAuth Error", + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"invalid_redirect_uri","error_description":"Redirect URI is not valid."}`)) + }, + clientMeta: &ClientRegistrationMetadata{RedirectURIs: []string{"http://invalid/cb"}}, + wantErr: "registration failed: invalid_redirect_uri (Redirect URI is not valid.)", + }, + { + name: "Non-JSON Server Error", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Internal Server Error")) + }, + clientMeta: &ClientRegistrationMetadata{RedirectURIs: []string{"http://localhost/cb"}}, + wantErr: "registration failed with status 500 Internal Server Error", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(tc.handler) + defer server.Close() + + info, err := RegisterClient(context.Background(), server.URL, tc.clientMeta, server.Client()) + + if tc.wantErr != "" { + if err == nil { + t.Fatalf("Expected an error containing '%s', but got nil", tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("Expected error to contain '%s', got '%v'", tc.wantErr, err) + } + return + } + + if err != nil { + t.Fatalf("Expected no error, but got: %v", err) + } + if info.ClientID != tc.wantClientID { + t.Errorf("Expected client_id '%s', got '%s'", tc.wantClientID, info.ClientID) + } + }) + } + + t.Run("No Endpoint", func(t *testing.T) { + _, err := RegisterClient(context.Background(), "", &ClientRegistrationMetadata{}, nil) + if err == nil { + t.Fatal("Expected an error for missing registration endpoint, got nil") + } + expectedErr := "registration_endpoint is required" + if err.Error() != expectedErr { + t.Errorf("Expected error '%s', got '%v'", expectedErr, err) + } + }) +} + +func TestClientRegistrationResponseJSON(t *testing.T) { + testCases := []struct { + name string + in ClientRegistrationResponse + wantJSON string + }{ + { + name: "full response", + in: ClientRegistrationResponse{ + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + ClientIDIssuedAt: time.Unix(1758840047, 0), + ClientSecretExpiresAt: time.Unix(1790376047, 0), + }, + wantJSON: `{"client_id":"test-client-id","client_secret":"test-client-secret","client_id_issued_at":1758840047,"client_secret_expires_at":1790376047, "redirect_uris": null}`, + }, + { + name: "minimal response with only required fields", + in: ClientRegistrationResponse{ + ClientID: "test-client-id-minimal", + }, + wantJSON: `{"client_id":"test-client-id-minimal", "redirect_uris":null}`, + }, + { + name: "response with a secret that does not expire", + in: ClientRegistrationResponse{ + ClientID: "test-client-id-no-expiry", + ClientSecret: "test-secret-no-expiry", + }, + wantJSON: `{"client_id":"test-client-id-no-expiry","client_secret":"test-secret-no-expiry", "redirect_uris":null}`, + }, + { + name: "unmarshal with zero timestamp", + in: ClientRegistrationResponse{ClientID: "client-id-zero"}, + wantJSON: `{"client_id":"client-id-zero", "redirect_uris":null}`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Test MarshalJSON + t.Run("marshal", func(t *testing.T) { + b, err := json.Marshal(&tc.in) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + var gotMap, wantMap map[string]any + if err := json.Unmarshal(b, &gotMap); err != nil { + t.Fatalf("failed to unmarshal actual result: %v", err) + } + if err := json.Unmarshal([]byte(tc.wantJSON), &wantMap); err != nil { + t.Fatalf("failed to unmarshal expected result: %v", err) + } + + if diff := cmp.Diff(wantMap, gotMap); diff != "" { + t.Errorf("Marshal() mismatch (-want +got):\n%s", diff) + } + }) + + // Test UnmarshalJSON + t.Run("unmarshal", func(t *testing.T) { + var got ClientRegistrationResponse + if err := json.Unmarshal([]byte(tc.wantJSON), &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if diff := cmp.Diff(tc.in, got); diff != "" { + t.Errorf("Unmarshal() mismatch (-want +got):\n%s", diff) + } + }) + }) + } +} diff --git a/internal/oauthex/testdata/client-auth-meta.json b/internal/oauthex/testdata/client-auth-meta.json new file mode 100644 index 0000000..c07f5be --- /dev/null +++ b/internal/oauthex/testdata/client-auth-meta.json @@ -0,0 +1,8 @@ +{ + "client_name": "My Test App", + "redirect_uris": [ + "https://client.example.org/callback", + "https://client.example.org/callback2" + ], + "scope": "read write" +} From b835e07c2659e545679f0f1f7d8a1326cebd4220 Mon Sep 17 00:00:00 2001 From: Chuck Ha Date: Mon, 29 Sep 2025 11:19:05 -0700 Subject: [PATCH 085/418] mcp: fix inconsistent logging new lines (#541) This change keeps the logging consistent with a new line after each log whether an error or not. Fixes #540 Co-authored-by: Chuck Ha --- mcp/transport.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mcp/transport.go b/mcp/transport.go index d2109e7..a9cfa37 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -230,7 +230,7 @@ func (s *loggingConn) Read(ctx context.Context) (jsonrpc.Message, error) { if err != nil { s.mu.Lock() - fmt.Fprintf(s.w, "read error: %v", err) + fmt.Fprintf(s.w, "read error: %v\n", err) s.mu.Unlock() } else { data, err := jsonrpc2.EncodeMessage(msg) @@ -250,7 +250,7 @@ func (s *loggingConn) Write(ctx context.Context, msg jsonrpc.Message) error { err := s.delegate.Write(ctx, msg) if err != nil { s.mu.Lock() - fmt.Fprintf(s.w, "write error: %v", err) + fmt.Fprintf(s.w, "write error: %v\n", err) s.mu.Unlock() } else { data, err := jsonrpc2.EncodeMessage(msg) From c2381a3fa5ce04ba617ad2ea4122c7262b1cf880 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Mon, 29 Sep 2025 14:45:47 -0400 Subject: [PATCH 086/418] docs: fix broken links (#542) --- docs/README.md | 10 +++++----- internal/docs/README.src.md | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/README.md b/docs/README.md index b4268c8..9f32507 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,21 +15,21 @@ protocol. 1. [Authorization](protocol.md#authorization) 1. [Security](protocol.md#security) 1. [Utilities](protocol.md#utilities) - 1. [Cancellation](utilities.md#cancellation) - 1. [Ping](utilities.md#ping) - 1. [Progress](utilities.md#progress) + 1. [Cancellation](protocol.md#cancellation) + 1. [Ping](protocol.md#ping) + 1. [Progress](protocol.md#progress) ## Client Features 1. [Roots](client.md#roots) 1. [Sampling](client.md#sampling) -1. [Elicitation](clients.md#elicitation) +1. [Elicitation](client.md#elicitation) ## Server Features 1. [Prompts](server.md#prompts) 1. [Resources](server.md#resources) -1. [Tools](tools.md) +1. [Tools](server.md#tools) 1. [Utilities](server.md#utilities) 1. [Completion](server.md#completion) 1. [Logging](server.md#logging) diff --git a/internal/docs/README.src.md b/internal/docs/README.src.md index b252f94..7f7b224 100644 --- a/internal/docs/README.src.md +++ b/internal/docs/README.src.md @@ -14,21 +14,21 @@ protocol. 1. [Authorization](protocol.md#authorization) 1. [Security](protocol.md#security) 1. [Utilities](protocol.md#utilities) - 1. [Cancellation](utilities.md#cancellation) - 1. [Ping](utilities.md#ping) - 1. [Progress](utilities.md#progress) + 1. [Cancellation](protocol.md#cancellation) + 1. [Ping](protocol.md#ping) + 1. [Progress](protocol.md#progress) ## Client Features 1. [Roots](client.md#roots) 1. [Sampling](client.md#sampling) -1. [Elicitation](clients.md#elicitation) +1. [Elicitation](client.md#elicitation) ## Server Features 1. [Prompts](server.md#prompts) 1. [Resources](server.md#resources) -1. [Tools](tools.md) +1. [Tools](server.md#tools) 1. [Utilities](server.md#utilities) 1. [Completion](server.md#completion) 1. [Logging](server.md#logging) From af4bddf3ea84dd4009074f9438d2c048ae61fc6a Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Mon, 29 Sep 2025 15:33:35 -0400 Subject: [PATCH 087/418] README: remove caveats (#543) In preparation for the v1.0.0 release, remove caveats from the README. For #328 --- README.md | 14 +------------- internal/readme/README.src.md | 14 +------------- 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 55cdfc5..81af0b0 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,13 @@ -# MCP Go SDK v0.8.0 +# MCP Go SDK [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/modelcontextprotocol/go-sdk) -***BREAKING CHANGES*** - -This version contains minor breaking changes. -See the [release notes]( -https://github.com/modelcontextprotocol/go-sdk/releases/tag/v0.8.0) for details. - [![PkgGoDev](https://pkg.go.dev/badge/github.com/modelcontextprotocol/go-sdk)](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) This repository contains an implementation of the official Go software development kit (SDK) for the Model Context Protocol (MCP). -> [!IMPORTANT] -> The SDK is in release-candidate state, and is going to be tagged v1.0.0 -> soon (see https://github.com/modelcontextprotocol/go-sdk/issues/328). -> We do not anticipate significant API changes or instability. Please use it -> and [file issues](https://github.com/modelcontextprotocol/go-sdk/issues/new/choose). - ## Package / Feature documentation The SDK consists of several importable packages: diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md index 3fd305a..e83c5fc 100644 --- a/internal/readme/README.src.md +++ b/internal/readme/README.src.md @@ -1,24 +1,12 @@ -# MCP Go SDK v0.8.0 +# MCP Go SDK [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/modelcontextprotocol/go-sdk) -***BREAKING CHANGES*** - -This version contains minor breaking changes. -See the [release notes]( -https://github.com/modelcontextprotocol/go-sdk/releases/tag/v0.8.0) for details. - [![PkgGoDev](https://pkg.go.dev/badge/github.com/modelcontextprotocol/go-sdk)](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) This repository contains an implementation of the official Go software development kit (SDK) for the Model Context Protocol (MCP). -> [!IMPORTANT] -> The SDK is in release-candidate state, and is going to be tagged v1.0.0 -> soon (see https://github.com/modelcontextprotocol/go-sdk/issues/328). -> We do not anticipate significant API changes or instability. Please use it -> and [file issues](https://github.com/modelcontextprotocol/go-sdk/issues/new/choose). - ## Package / Feature documentation The SDK consists of several importable packages: From fd0dc9d741e8e8fae8d2331d7891c3296115a8db Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Tue, 30 Sep 2025 07:33:59 -0400 Subject: [PATCH 088/418] internal/oauthex: address URL attacks in PRM (#539) Fix Protected Resource Metadata to prevent URL attacks, as described in the issue. For #526 --- internal/oauthex/oauth2.go | 18 ++++++++++++++++++ internal/oauthex/resource_meta.go | 14 ++++++++++---- oauthex/oauthex.go | 4 +++- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/internal/oauthex/oauth2.go b/internal/oauthex/oauth2.go index de16449..fc08c6b 100644 --- a/internal/oauthex/oauth2.go +++ b/internal/oauthex/oauth2.go @@ -65,3 +65,21 @@ func getJSON[T any](ctx context.Context, c *http.Client, url string, limit int64 } return &t, nil } + +// checkURLScheme ensures that its argument is a valid URL with a scheme +// that prevents XSS attacks. +// See #526. +func checkURLScheme(u string) error { + if u == "" { + return nil + } + uu, err := url.Parse(u) + if err != nil { + return err + } + scheme := strings.ToLower(uu.Scheme) + if scheme == "javascript" || scheme == "data" || scheme == "vbscript" { + return fmt.Errorf("URL has disallowed scheme %q", scheme) + } + return nil +} diff --git a/internal/oauthex/resource_meta.go b/internal/oauthex/resource_meta.go index 71d52cd..95c9dd0 100644 --- a/internal/oauthex/resource_meta.go +++ b/internal/oauthex/resource_meta.go @@ -159,11 +159,11 @@ func GetProtectedResourceMetadataFromHeader(ctx context.Context, header http.Hea // getPRM makes a GET request to the given URL, and validates the response. // As part of the validation, it compares the returned resource field to wantResource. -func getPRM(ctx context.Context, url string, c *http.Client, wantResource string) (*ProtectedResourceMetadata, error) { - if !strings.HasPrefix(strings.ToUpper(url), "HTTPS://") { - return nil, fmt.Errorf("resource URL %q does not use HTTPS", url) +func getPRM(ctx context.Context, purl string, c *http.Client, wantResource string) (*ProtectedResourceMetadata, error) { + if !strings.HasPrefix(strings.ToUpper(purl), "HTTPS://") { + return nil, fmt.Errorf("resource URL %q does not use HTTPS", purl) } - prm, err := getJSON[ProtectedResourceMetadata](ctx, c, url, 1<<20) + prm, err := getJSON[ProtectedResourceMetadata](ctx, c, purl, 1<<20) if err != nil { return nil, err } @@ -171,6 +171,12 @@ func getPRM(ctx context.Context, url string, c *http.Client, wantResource string if prm.Resource != wantResource { return nil, fmt.Errorf("got metadata resource %q, want %q", prm.Resource, wantResource) } + // Validate the authorization server URLs to prevent XSS attacks (see #526). + for _, u := range prm.AuthorizationServers { + if err := checkURLScheme(u); err != nil { + return nil, err + } + } return prm, nil } diff --git a/oauthex/oauthex.go b/oauthex/oauthex.go index df32678..3c28dce 100644 --- a/oauthex/oauthex.go +++ b/oauthex/oauthex.go @@ -5,7 +5,9 @@ // Package oauthex implements extensions to OAuth2. package oauthex -import "github.com/modelcontextprotocol/go-sdk/internal/oauthex" +import ( + "github.com/modelcontextprotocol/go-sdk/internal/oauthex" +) // ProtectedResourceMetadata is the metadata for an OAuth 2.0 protected resource, // as defined in section 2 of https://www.rfc-editor.org/rfc/rfc9728.html. From 6dbf6c6450e3f418c97277ee16192cb1daba99e7 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Tue, 30 Sep 2025 15:53:00 -0400 Subject: [PATCH 089/418] all: minor cleanup (#545) Also fix a flaky test case in TestStreamableServerTransport. --- docs/client.md | 2 +- mcp/client_example_test.go | 2 +- mcp/server.go | 5 ++++- mcp/streamable_test.go | 5 +++-- mcp/transport.go | 8 ++++++-- 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/client.md b/docs/client.md index 2cbe082..8fc7544 100644 --- a/docs/client.md +++ b/docs/client.md @@ -145,7 +145,7 @@ func Example_elicitation() { } defer ss.Close() - c := mcp.NewClient(testImpl, &mcp.ClientOptions{ + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ ElicitationHandler: func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) { return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"test": "value"}}, nil }, diff --git a/mcp/client_example_test.go b/mcp/client_example_test.go index bba3da4..225daba 100644 --- a/mcp/client_example_test.go +++ b/mcp/client_example_test.go @@ -112,7 +112,7 @@ func Example_elicitation() { } defer ss.Close() - c := mcp.NewClient(testImpl, &mcp.ClientOptions{ + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ ElicitationHandler: func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) { return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"test": "value"}}, nil }, diff --git a/mcp/server.go b/mcp/server.go index c80b7d9..44483af 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -380,7 +380,7 @@ func setSchema[T any](sfield *any, rfield **jsonschema.Resolved) (zero any, err // If the tool's input schema is nil, it is set to the schema inferred from the // In type parameter. Types are inferred from Go types, and property // descriptions are read from the 'jsonschema' struct tag. Internally, the SDK -// uses the github.com/google/jsonschema-go package for ineference and +// uses the github.com/google/jsonschema-go package for inference and // validation. The In type argument must be a map or a struct, so that its // inferred JSON Schema has type "object", as required by the spec. As a // special case, if the In type is 'any', the tool's input schema is set to an @@ -498,6 +498,9 @@ func (s *Server) changeAndNotify(notification string, params Params, change func } // Sessions returns an iterator that yields the current set of server sessions. +// +// There is no guarantee that the iterator observes sessions that are added or +// removed during iteration. func (s *Server) Sessions() iter.Seq[*ServerSession] { s.mu.Lock() clients := slices.Clone(s.sessions) diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 3576d2b..f7cda72 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -675,13 +675,14 @@ func TestStreamableServerTransport(t *testing.T) { headers: http.Header{"MCP-Protocol-Version": {"2025-03-26"}}, // Two messages => batch. Expect OK with two responses in order. messages: []jsonrpc.Message{ + // Note: only include one request here, because responses are not + // necessarily sorted. req(201, "tools/call", &CallToolParams{Name: "tool"}), - req(202, "tools/call", &CallToolParams{Name: "tool"}), + req(0, "notifications/roots/list_changed", &RootsListChangedParams{}), }, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{ resp(201, &CallToolResult{Content: []Content{}}, nil), - resp(202, &CallToolResult{Content: []Content{}}, nil), }, }, }, diff --git a/mcp/transport.go b/mcp/transport.go index a9cfa37..b13d69c 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -104,8 +104,12 @@ func (t *InMemoryTransport) Connect(context.Context) (Connection, error) { return newIOConn(t.rwc), nil } -// NewInMemoryTransports returns two [InMemoryTransports] that connect to each -// other. +// NewInMemoryTransports returns two [InMemoryTransport] objects that connect +// to each other. +// +// The resulting transports are symmetrical: use either to connect to a server, +// and then the other to connect to a client. Servers must be connected before +// clients, as the client initializes the MCP session during connection. func NewInMemoryTransports() (*InMemoryTransport, *InMemoryTransport) { c1, c2 := net.Pipe() return &InMemoryTransport{c1}, &InMemoryTransport{c2} From 1c3f38f777421774329bc81aefec0e8c0caffdb0 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Tue, 30 Sep 2025 16:07:07 -0400 Subject: [PATCH 090/418] all: more minor cleanup (#547) Fix broken / missing links. --- docs/protocol.md | 8 ++++---- internal/docs/protocol.src.md | 8 ++++---- mcp/streamable_client_test.go | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index c87e522..3a031af 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -128,8 +128,8 @@ which starts the a `exec.Cmd` as a subprocess and communicates over its stdin/stdout. **Server-side**: the server side of the `stdio` transport is implemented by -`StdioTransport`, which connects over the current processes `os.Stdin` and -`os.Stdout`. +[`StdioTransport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#StdioTransport), +which connects over the current processes `os.Stdin` and `os.Stdout`. ### Streamable Transport @@ -201,7 +201,7 @@ to see the logical session > Stateless mode is not directly discussed in the spec, and is still being > defined. See modelcontextprotocol/modelcontextprotocol#1364, > modelcontextprotocol/modelcontextprotocol#1372, or -> modelcontextprotocol/modelcontextprotocol#11442 for potential refinements. +> modelcontextprotocol/modelcontextprotocol#1442 for potential refinements. _See [examples/server/distributed](../examples/server/distributed/main.go) for an example using statless mode to implement a server distributed across @@ -267,7 +267,7 @@ The [_auth middleware example_](https://github.com/modelcontextprotocol/go-sdk/ Client-side OAuth is implemented by setting [`StreamableClientTransport.HTTPClient`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk@v0.5.0/mcp#StreamableClientTransport.HTTPClient) to a custom [`http.Client`](https://pkg.go.dev/net/http#Client) -Additional support is forthcoming; see #493. +Additional support is forthcoming; see modelcontextprotocol/go-sdk#493. ## Security diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index 88c023c..e85374a 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -74,8 +74,8 @@ which starts the a `exec.Cmd` as a subprocess and communicates over its stdin/stdout. **Server-side**: the server side of the `stdio` transport is implemented by -`StdioTransport`, which connects over the current processes `os.Stdin` and -`os.Stdout`. +[`StdioTransport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#StdioTransport), +which connects over the current processes `os.Stdin` and `os.Stdout`. ### Streamable Transport @@ -128,7 +128,7 @@ to see the logical session > Stateless mode is not directly discussed in the spec, and is still being > defined. See modelcontextprotocol/modelcontextprotocol#1364, > modelcontextprotocol/modelcontextprotocol#1372, or -> modelcontextprotocol/modelcontextprotocol#11442 for potential refinements. +> modelcontextprotocol/modelcontextprotocol#1442 for potential refinements. _See [examples/server/distributed](../examples/server/distributed/main.go) for an example using statless mode to implement a server distributed across @@ -194,7 +194,7 @@ The [_auth middleware example_](https://github.com/modelcontextprotocol/go-sdk/ Client-side OAuth is implemented by setting [`StreamableClientTransport.HTTPClient`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk@v0.5.0/mcp#StreamableClientTransport.HTTPClient) to a custom [`http.Client`](https://pkg.go.dev/net/http#Client) -Additional support is forthcoming; see #493. +Additional support is forthcoming; see modelcontextprotocol/go-sdk#493. ## Security diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 45c4a2e..9116677 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -310,7 +310,7 @@ func TestStreamableClientGETHandling(t *testing.T) { _, err = session.ListTools(ctx, nil) if (err != nil) != (test.wantErrorContaining != "") { - t.Errorf("After initialization, got error %v, want %v", err, test.wantErrorContaining) + t.Errorf("After initialization, got error %v, want containing %q", err, test.wantErrorContaining) } else if err != nil { if !strings.Contains(err.Error(), test.wantErrorContaining) { t.Errorf("After initialization, got error %s, want containing %q", err, test.wantErrorContaining) From 069aae16df70ea254fe703c77b263d3b919f3e44 Mon Sep 17 00:00:00 2001 From: Sam Thanawalla <17936816+samthanawalla@users.noreply.github.com> Date: Tue, 30 Sep 2025 16:16:30 -0400 Subject: [PATCH 091/418] mcp: add client-side OAuth flow (#544) This is a preliminary implementation of OAuth 2.1 for the client. When a StreamableClientTransport encounters a 401 Unauthorized response from the server, it initiates the OAuth flow described in thec authorization section of the MCP spec (https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization). On success, the transport obtains an access token which it passes to all subsequent requests. Much remains to be done here: * Resource Indicators, as described in section 2.5.1 of the MCP spec. * All of this is unexported, so it is available only to our own StreamingClientTransport. We should add API so people can use it with their own transports. * And, of course, tests. We should test against fake implementations but also, if we can find any, real reference implementations. For #19 Co-authored-by: Jonathan Amsterdam --------- Co-authored-by: Jonathan Amsterdam --- auth/client.go | 109 ++++++++++++++++++++++++++++++ auth/client_test.go | 102 ++++++++++++++++++++++++++++ go.mod | 1 + go.sum | 6 +- internal/oauthex/resource_meta.go | 12 ++-- 5 files changed, 220 insertions(+), 10 deletions(-) create mode 100644 auth/client.go create mode 100644 auth/client_test.go diff --git a/auth/client.go b/auth/client.go new file mode 100644 index 0000000..2dda135 --- /dev/null +++ b/auth/client.go @@ -0,0 +1,109 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +//go:build mcp_go_client_oauth + +package auth + +import ( + "context" + "errors" + "net/http" + "sync" + + "github.com/modelcontextprotocol/go-sdk/internal/oauthex" + "golang.org/x/oauth2" +) + +// An OAuthHandler conducts an OAuth flow and returns a [oauth2.TokenSource] if the authorization +// is approved, or an error if not. +type OAuthHandler func(context.Context, OAuthHandlerArgs) (oauth2.TokenSource, error) + +// OAuthHandlerArgs are arguments to an [OAuthHandler]. +type OAuthHandlerArgs struct { + // The URL to fetch protected resource metadata, extracted from the WWW-Authenticate header. + // Empty if not present or there was an error obtaining it. + ResourceMetadataURL string +} + +// HTTPTransport is an [http.RoundTripper] that follows the MCP +// OAuth protocol when it encounters a 401 Unauthorized response. +type HTTPTransport struct { + handler OAuthHandler + mu sync.Mutex // protects opts.Base + opts HTTPTransportOptions +} + +// NewHTTPTransport returns a new [*HTTPTransport]. +// The handler is invoked when an HTTP request results in a 401 Unauthorized status. +// It is called only once per transport. Once a TokenSource is obtained, it is used +// for the lifetime of the transport; subsequent 401s are not processed. +func NewHTTPTransport(handler OAuthHandler, opts *HTTPTransportOptions) (*HTTPTransport, error) { + if handler == nil { + return nil, errors.New("handler cannot be nil") + } + t := &HTTPTransport{ + handler: handler, + } + if opts != nil { + t.opts = *opts + } + if t.opts.Base == nil { + t.opts.Base = http.DefaultTransport + } + return t, nil +} + +// HTTPTransportOptions are options to [NewHTTPTransport]. +type HTTPTransportOptions struct { + // Base is the [http.RoundTripper] to use. + // If nil, [http.DefaultTransport] is used. + Base http.RoundTripper +} + +func (t *HTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { + t.mu.Lock() + base := t.opts.Base + t.mu.Unlock() + + resp, err := base.RoundTrip(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusUnauthorized { + return resp, nil + } + if _, ok := base.(*oauth2.Transport); ok { + // We failed to authorize even with a token source; give up. + return resp, nil + } + + resp.Body.Close() + // Try to authorize. + t.mu.Lock() + defer t.mu.Unlock() + // If we don't have a token source, get one by following the OAuth flow. + // (We may have obtained one while t.mu was not held above.) + // TODO: We hold the lock for the entire OAuth flow. This could be a long + // time. Is there a better way? + if _, ok := t.opts.Base.(*oauth2.Transport); !ok { + authHeaders := resp.Header[http.CanonicalHeaderKey("WWW-Authenticate")] + ts, err := t.handler(req.Context(), OAuthHandlerArgs{ + ResourceMetadataURL: extractResourceMetadataURL(authHeaders), + }) + if err != nil { + return nil, err + } + t.opts.Base = &oauth2.Transport{Base: t.opts.Base, Source: ts} + } + return t.opts.Base.RoundTrip(req.Clone(req.Context())) +} + +func extractResourceMetadataURL(authHeaders []string) string { + cs, err := oauthex.ParseWWWAuthenticate(authHeaders) + if err != nil { + return "" + } + return oauthex.ResourceMetadataURL(cs) +} diff --git a/auth/client_test.go b/auth/client_test.go new file mode 100644 index 0000000..310fc56 --- /dev/null +++ b/auth/client_test.go @@ -0,0 +1,102 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +//go:build mcp_go_client_oauth + +package auth + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "golang.org/x/oauth2" +) + +// TestHTTPTransport validates the OAuth HTTPTransport. +func TestHTTPTransport(t *testing.T) { + const testToken = "test-token-123" + fakeTokenSource := oauth2.StaticTokenSource(&oauth2.Token{ + AccessToken: testToken, + TokenType: "Bearer", + }) + + // authServer simulates a resource that requires OAuth. + authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == fmt.Sprintf("Bearer %s", testToken) { + w.WriteHeader(http.StatusOK) + return + } + + w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="http://metadata.example.com"`) + w.WriteHeader(http.StatusUnauthorized) + })) + defer authServer.Close() + + t.Run("successful auth flow", func(t *testing.T) { + var handlerCalls int + handler := func(ctx context.Context, args OAuthHandlerArgs) (oauth2.TokenSource, error) { + handlerCalls++ + if args.ResourceMetadataURL != "http://metadata.example.com" { + t.Errorf("handler got metadata URL %q, want %q", args.ResourceMetadataURL, "http://metadata.example.com") + } + return fakeTokenSource, nil + } + + transport, err := NewHTTPTransport(handler, nil) + if err != nil { + t.Fatalf("NewHTTPTransport() failed: %v", err) + } + client := &http.Client{Transport: transport} + + resp, err := client.Get(authServer.URL) + if err != nil { + t.Fatalf("client.Get() failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("got status %d, want %d", resp.StatusCode, http.StatusOK) + } + if handlerCalls != 1 { + t.Errorf("handler was called %d times, want 1", handlerCalls) + } + + // Second request should reuse the token and not call the handler again. + resp2, err := client.Get(authServer.URL) + if err != nil { + t.Fatalf("second client.Get() failed: %v", err) + } + defer resp2.Body.Close() + + if resp2.StatusCode != http.StatusOK { + t.Errorf("second request got status %d, want %d", resp2.StatusCode, http.StatusOK) + } + if handlerCalls != 1 { + t.Errorf("handler should still be called only once, but was %d", handlerCalls) + } + }) + + t.Run("handler returns error", func(t *testing.T) { + handlerErr := errors.New("user rejected auth") + handler := func(ctx context.Context, args OAuthHandlerArgs) (oauth2.TokenSource, error) { + return nil, handlerErr + } + + transport, err := NewHTTPTransport(handler, nil) + if err != nil { + t.Fatalf("NewHTTPTransport() failed: %v", err) + } + client := &http.Client{Transport: transport} + + _, err = client.Get(authServer.URL) + if !errors.Is(err, handlerErr) { + t.Errorf("client.Get() returned error %v, want %v", err, handlerErr) + } + }) +} diff --git a/go.mod b/go.mod index b78c25e..d5917a1 100644 --- a/go.mod +++ b/go.mod @@ -7,5 +7,6 @@ require ( github.com/google/go-cmp v0.7.0 github.com/google/jsonschema-go v0.3.0 github.com/yosida95/uritemplate/v3 v3.0.2 + golang.org/x/oauth2 v0.30.0 golang.org/x/tools v0.34.0 ) diff --git a/go.sum b/go.sum index 2006a67..32ceedf 100644 --- a/go.sum +++ b/go.sum @@ -2,13 +2,11 @@ github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeD github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/jsonschema-go v0.2.3 h1:dkP3B96OtZKKFvdrUSaDkL+YDx8Uw9uC4Y+eukpCnmM= -github.com/google/jsonschema-go v0.2.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= -github.com/google/jsonschema-go v0.2.4-0.20250922144851-e08864c65371 h1:e1VCqWtKpTYBOBhPcgGV5whTlMFpTbH5Ghm56wpxBsk= -github.com/google/jsonschema-go v0.2.4-0.20250922144851-e08864c65371/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= diff --git a/internal/oauthex/resource_meta.go b/internal/oauthex/resource_meta.go index 95c9dd0..5787fb1 100644 --- a/internal/oauthex/resource_meta.go +++ b/internal/oauthex/resource_meta.go @@ -146,11 +146,11 @@ func GetProtectedResourceMetadataFromHeader(ctx context.Context, header http.Hea if len(headers) == 0 { return nil, nil } - cs, err := parseWWWAuthenticate(headers) + cs, err := ParseWWWAuthenticate(headers) if err != nil { return nil, err } - url := resourceMetadataURL(cs) + url := ResourceMetadataURL(cs) if url == "" { return nil, nil } @@ -193,9 +193,9 @@ type challenge struct { Params map[string]string } -// resourceMetadataURL returns a resource metadata URL from the given challenges, +// ResourceMetadataURL returns a resource metadata URL from the given challenges, // or the empty string if there is none. -func resourceMetadataURL(cs []challenge) string { +func ResourceMetadataURL(cs []challenge) string { for _, c := range cs { if u := c.Params["resource_metadata"]; u != "" { return u @@ -204,11 +204,11 @@ func resourceMetadataURL(cs []challenge) string { return "" } -// parseWWWAuthenticate parses a WWW-Authenticate header string. +// ParseWWWAuthenticate parses a WWW-Authenticate header string. // The header format is defined in RFC 9110, Section 11.6.1, and can contain // one or more challenges, separated by commas. // It returns a slice of challenges or an error if one of the headers is malformed. -func parseWWWAuthenticate(headers []string) ([]challenge, error) { +func ParseWWWAuthenticate(headers []string) ([]challenge, error) { // GENERATED BY GEMINI 2.5 (human-tweaked) var challenges []challenge for _, h := range headers { From 49047aebe8040dcd125341d2b9981524543c4fe9 Mon Sep 17 00:00:00 2001 From: Konstantin Alikhanov Date: Wed, 1 Oct 2025 00:24:07 +0400 Subject: [PATCH 092/418] Add IOTransport for separate reader/writer (#376) This commit adds ability to create a mcp.Transport with specific io.ReadCloser and io.WriteCloser. Ii is needed when you need create an mcp.Client for mcp.Server launched in separate process and you need to communicate with it with it's own stdin/stdout. --- mcp/transport.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/mcp/transport.go b/mcp/transport.go index b13d69c..111887b 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -93,6 +93,18 @@ func (*StdioTransport) Connect(context.Context) (Connection, error) { return newIOConn(rwc{os.Stdin, os.Stdout}), nil } +// An IOTransport is a [Transport] that communicates over separate +// io.ReadCloser and io.WriteCloser using newline-delimited JSON. +type IOTransport struct { + Reader io.ReadCloser + Writer io.WriteCloser +} + +// Connect implements the [Transport] interface. +func (t *IOTransport) Connect(context.Context) (Connection, error) { + return newIOConn(rwc{t.Reader, t.Writer}), nil +} + // An InMemoryTransport is a [Transport] that communicates over an in-memory // network connection, using newline-delimited JSON. type InMemoryTransport struct { From 1dcbf62661fc9c54ae364e0af80433db347e2fc4 Mon Sep 17 00:00:00 2001 From: Suraj Bobade Date: Fri, 3 Oct 2025 00:14:32 +0530 Subject: [PATCH 093/418] mcp/server: enable server logging (#501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mcp/server.go: implement server‑side logging - Thread a *slog.Logger through server paths to improve debuggability - Introduce no‑op discard handler + ensureLogger so loggers are never nil - Use ensured loggers in Server - address todo comments related to adding logging support All tests are passing and no functionality should change with this PR Fixes https://github.com/modelcontextprotocol/go-sdk/issues/170 --- mcp/logging.go | 17 +++++++++++++++++ mcp/server.go | 34 +++++++++++++++++++++++++++++++++- mcp/streamable.go | 24 ++++++++++++++++++++++-- 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/mcp/logging.go b/mcp/logging.go index b3186a9..208427e 100644 --- a/mcp/logging.go +++ b/mcp/logging.go @@ -88,6 +88,23 @@ type LoggingHandler struct { handler slog.Handler } +// discardHandler is a slog.Handler that drops all logs. +// TODO: use slog.DiscardHandler when we require Go 1.24+. +type discardHandler struct{} + +func (discardHandler) Enabled(context.Context, slog.Level) bool { return false } +func (discardHandler) Handle(context.Context, slog.Record) error { return nil } +func (discardHandler) WithAttrs([]slog.Attr) slog.Handler { return discardHandler{} } +func (discardHandler) WithGroup(string) slog.Handler { return discardHandler{} } + +// ensureLogger returns l if non-nil, otherwise a discard logger. +func ensureLogger(l *slog.Logger) *slog.Logger { + if l != nil { + return l + } + return slog.New(discardHandler{}) +} + // NewLoggingHandler creates a [LoggingHandler] that logs to the given [ServerSession] using a // [slog.JSONHandler]. func NewLoggingHandler(ss *ServerSession, opts *LoggingHandlerOptions) *LoggingHandler { diff --git a/mcp/server.go b/mcp/server.go index 44483af..f5b99cf 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -12,6 +12,7 @@ import ( "encoding/json" "fmt" "iter" + "log/slog" "maps" "net/url" "path/filepath" @@ -54,6 +55,8 @@ type Server struct { type ServerOptions struct { // Optional instructions for connected clients. Instructions string + // If non-nil, log server activity. + Logger *slog.Logger // If non-nil, called when "notifications/initialized" is received. InitializedHandler func(context.Context, *InitializedRequest) // PageSize is the maximum number of items to return in a single page for @@ -130,6 +133,10 @@ func NewServer(impl *Implementation, options *ServerOptions) *Server { opts.GetSessionID = randText } + if opts.Logger == nil { // ensure we have a logger + opts.Logger = ensureLogger(nil) + } + return &Server{ impl: impl, opts: opts, @@ -695,6 +702,7 @@ func (s *Server) ResourceUpdated(ctx context.Context, params *ResourceUpdatedNot sessions := slices.Collect(maps.Keys(subscribedSessions)) s.mu.Unlock() notifySessions(sessions, notificationResourceUpdated, params) + s.opts.Logger.Info("resource updated notification sent", "uri", params.URI, "subscriber_count", len(sessions)) return nil } @@ -712,6 +720,7 @@ func (s *Server) subscribe(ctx context.Context, req *SubscribeRequest) (*emptyRe s.resourceSubscriptions[req.Params.URI] = make(map[*ServerSession]bool) } s.resourceSubscriptions[req.Params.URI][req.Session] = true + s.opts.Logger.Info("resource subscribed", "uri", req.Params.URI, "session_id", req.Session.ID()) return &emptyResult{}, nil } @@ -733,6 +742,7 @@ func (s *Server) unsubscribe(ctx context.Context, req *UnsubscribeRequest) (*emp delete(s.resourceSubscriptions, req.Params.URI) } } + s.opts.Logger.Info("resource unsubscribed", "uri", req.Params.URI, "session_id", req.Session.ID()) return &emptyResult{}, nil } @@ -751,8 +761,10 @@ func (s *Server) unsubscribe(ctx context.Context, req *UnsubscribeRequest) (*emp // It need not be called on servers that are used for multiple concurrent connections, // as with [StreamableHTTPHandler]. func (s *Server) Run(ctx context.Context, t Transport) error { + s.opts.Logger.Info("server run start") ss, err := s.Connect(ctx, t, nil) if err != nil { + s.opts.Logger.Error("server connect failed", "error", err) return err } @@ -764,8 +776,14 @@ func (s *Server) Run(ctx context.Context, t Transport) error { select { case <-ctx.Done(): ss.Close() + s.opts.Logger.Error("server run cancelled", "error", ctx.Err()) return ctx.Err() case err := <-ssClosed: + if err != nil { + s.opts.Logger.Error("server session ended with error", "error", err) + } else { + s.opts.Logger.Info("server session ended") + } return err } } @@ -781,6 +799,7 @@ func (s *Server) bind(mcpConn Connection, conn *jsonrpc2.Connection, state *Serv s.mu.Lock() s.sessions = append(s.sessions, ss) s.mu.Unlock() + s.opts.Logger.Info("server session connected", "session_id", ss.ID()) return ss } @@ -796,6 +815,7 @@ func (s *Server) disconnect(cc *ServerSession) { for _, subscribedSessions := range s.resourceSubscriptions { delete(subscribedSessions, cc) } + s.opts.Logger.Info("server session disconnected", "session_id", cc.ID()) } // ServerSessionOptions configures the server session. @@ -820,7 +840,14 @@ func (s *Server) Connect(ctx context.Context, t Transport, opts *ServerSessionOp state = opts.State onClose = opts.onClose } - return connect(ctx, t, s, state, onClose) + + s.opts.Logger.Info("server connecting") + ss, err := connect(ctx, t, s, state, onClose) + if err != nil { + s.opts.Logger.Error("server connect error", "error", err) + return nil, err + } + return ss, nil } // TODO: (nit) move all ServerSession methods below the ServerSession declaration. @@ -840,9 +867,11 @@ func (ss *ServerSession) initialized(ctx context.Context, params *InitializedPar }) if !wasInit { + ss.server.opts.Logger.Error("initialized before initialize") return nil, fmt.Errorf("%q before %q", notificationInitialized, methodInitialize) } if wasInitd { + ss.server.opts.Logger.Error("duplicate initialized notification") return nil, fmt.Errorf("duplicate %q received", notificationInitialized) } if ss.server.opts.KeepAlive > 0 { @@ -851,6 +880,7 @@ func (ss *ServerSession) initialized(ctx context.Context, params *InitializedPar if h := ss.server.opts.InitializedHandler; h != nil { h(ctx, serverRequestFor(ss, params)) } + ss.server.opts.Logger.Info("session initialized") return nil, nil } @@ -1088,6 +1118,7 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, case methodInitialize, methodPing, notificationInitialized: default: if !initialized { + 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) } } @@ -1144,6 +1175,7 @@ func (ss *ServerSession) setLevel(_ context.Context, params *SetLoggingLevelPara ss.updateState(func(state *ServerSessionState) { state.LogLevel = params.Level }) + ss.server.opts.Logger.Info("client log level set", "level", params.Level) return &emptyResult{}, nil } diff --git a/mcp/streamable.go b/mcp/streamable.go index a96386d..4886f80 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -68,6 +68,10 @@ type StreamableHTTPOptions struct { // // [§2.1.5]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#sending-messages-to-the-server JSONResponse bool + + // Logger specifies the logger to use. + // If nil, do not log. + Logger *slog.Logger } // NewStreamableHTTPHandler returns a new [StreamableHTTPHandler]. @@ -83,6 +87,11 @@ func NewStreamableHTTPHandler(getServer func(*http.Request) *Server, opts *Strea if opts != nil { h.opts = *opts } + + if h.opts.Logger == nil { // ensure we have a logger + h.opts.Logger = ensureLogger(nil) + } + return h } @@ -368,6 +377,8 @@ type StreamableServerTransport struct { // StreamableHTTPOptions.JSONResponse is exported. jsonResponse bool + logger *slog.Logger + // connection is non-nil if and only if the transport has been connected. connection *streamableServerConn } @@ -382,6 +393,7 @@ func (t *StreamableServerTransport) Connect(ctx context.Context) (Connection, er stateless: t.Stateless, eventStore: t.EventStore, jsonResponse: t.jsonResponse, + logger: t.logger, incoming: make(chan jsonrpc.Message, 10), done: make(chan struct{}), streams: make(map[string]*stream), @@ -408,6 +420,8 @@ type streamableServerConn struct { jsonResponse bool eventStore EventStore + logger *slog.Logger + incoming chan jsonrpc.Message // messages from the client to the server mu sync.Mutex // guards all fields below @@ -755,7 +769,7 @@ func (c *streamableServerConn) respondSSE(stream *stream, w http.ResponseWriter, } if _, err := writeEvent(w, e); err != nil { // Connection closed or broken. - // TODO(#170): log when we add server-side logging. + c.logger.Warn("error writing event", "error", err) return false } writes++ @@ -774,7 +788,13 @@ func (c *streamableServerConn) respondSSE(stream *stream, w http.ResponseWriter, // simplify. http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } else { - // TODO(#170): log when we add server-side logging + if ctx.Err() != nil { + // Client disconnected or cancelled the request. + c.logger.Error("stream context done", "error", ctx.Err()) + } else { + // Some other error. + c.logger.Error("error receiving message", "error", err) + } } return } From 4439658dc452bb423a430cf607d83f3ffedc5b02 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 4 Oct 2025 00:07:13 +0100 Subject: [PATCH 094/418] Update readme generation instructions --- CONTRIBUTING.md | 4 ++-- README.md | 2 +- internal/readme/README.src.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 726ac95..001c35d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -111,11 +111,11 @@ The top-level `README.md` file is generated from `internal/readme/README.src.md` and should not be edited directly. To update the README: 1. Make your changes to `internal/readme/README.src.md` -2. Run `make` in the `internal/readme/` directory to regenerate `README.md` +2. Run `go generate ./internal/readme` from the repository root to regenerate `README.md` 3. Commit both files together The CI system will automatically check that the README is up-to-date by running -`make` and verifying no changes result. If you see a CI failure about the +`go generate ./internal/readme` and verifying no changes result. If you see a CI failure about the README being out of sync, follow the steps above to regenerate it. ## Timeouts diff --git a/README.md b/README.md index 81af0b0..2a2d408 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ servers. ## Contributing -We welcome contributions to the SDK! Please see See +We welcome contributions to the SDK! Please see [CONTRIBUTING.md](/CONTRIBUTING.md) for details of how to contribute. ## Acknowledgements / Alternatives diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md index e83c5fc..cdc2cd6 100644 --- a/internal/readme/README.src.md +++ b/internal/readme/README.src.md @@ -48,7 +48,7 @@ servers. ## Contributing -We welcome contributions to the SDK! Please see See +We welcome contributions to the SDK! Please see [CONTRIBUTING.md](/CONTRIBUTING.md) for details of how to contribute. ## Acknowledgements / Alternatives From 547b5c14268237460b0b43fea53fa9eab56cd8f0 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Mon, 6 Oct 2025 13:34:20 -0400 Subject: [PATCH 095/418] mcp: fix panics with nil logger; ensure test handlers do not panic (#557) PR 501 added logging to the streamable server, but didn't pass a non-nil logger to the streamable transport (an oversight). However, tests still passed due to net/http's panic recovery. Update test servers to enforce that handlers do not panic, and fix the panic by: 1. Passing along the configured logger to the session transport. 2. Using ensureLogger in Connect, since we technically allow StreamableServerTransports to be constructed by the user. Fixes #556 --- mcp/streamable.go | 7 ++++++- mcp/streamable_test.go | 41 ++++++++++++++++++++++++++++++----------- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 4886f80..20eb13d 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -236,6 +236,7 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque SessionID: sessionID, Stateless: h.opts.Stateless, jsonResponse: h.opts.JSONResponse, + logger: h.opts.Logger, } // To support stateless mode, we initialize the session with a default @@ -377,6 +378,10 @@ type StreamableServerTransport struct { // StreamableHTTPOptions.JSONResponse is exported. jsonResponse bool + // optional logger provided through the [StreamableHTTPOptions.Logger]. + // + // TODO(rfindley): logger should be exported, since we want to allow people + // to write their own streamable HTTP handler. logger *slog.Logger // connection is non-nil if and only if the transport has been connected. @@ -393,7 +398,7 @@ func (t *StreamableServerTransport) Connect(ctx context.Context) (Connection, er stateless: t.Stateless, eventStore: t.EventStore, jsonResponse: t.jsonResponse, - logger: t.logger, + logger: ensureLogger(t.logger), // see #556: must be non-nil incoming: make(chan jsonrpc.Message, 10), done: make(chan struct{}), streams: make(map[string]*stream), diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index f7cda72..6ccaebf 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -18,6 +18,8 @@ import ( "net/http/httptest" "net/http/httputil" "net/url" + "os" + "runtime" "sort" "strings" "sync" @@ -91,7 +93,7 @@ func TestStreamableTransports(t *testing.T) { headerMu sync.Mutex lastHeader http.Header ) - httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + httpServer := httptest.NewServer(mustNotPanic(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { headerMu.Lock() lastHeader = r.Header headerMu.Unlock() @@ -102,7 +104,7 @@ func TestStreamableTransports(t *testing.T) { t.Errorf("got cookie %q, want %q", cookie.Value, "test-value") } handler.ServeHTTP(w, r) - })) + }))) defer httpServer.Close() // Create a client and connect it to the server using our StreamableClientTransport. @@ -315,7 +317,7 @@ func testClientReplay(t *testing.T, test clientReplayTest) { return new(CallToolResult), nil, nil }) - realServer := httptest.NewServer(NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil)) + realServer := httptest.NewServer(mustNotPanic(t, NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil))) defer realServer.Close() realServerURL, err := url.Parse(realServer.URL) if err != nil { @@ -324,6 +326,7 @@ func testClientReplay(t *testing.T, test clientReplayTest) { // Configure a proxy that sits between the client and the real server. proxyHandler := httputil.NewSingleHostReverseProxy(realServerURL) + // note: don't use mustNotPanic here as the proxy WILL panic when killed. proxy := httptest.NewServer(proxyHandler) proxyAddr := proxy.Listener.Addr().String() // Get the address to restart it later. @@ -434,7 +437,7 @@ func TestServerTransportCleanup(t *testing.T) { chans[sessionID] <- struct{}{} } - httpServer := httptest.NewServer(handler) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -484,7 +487,7 @@ func TestServerInitiatedSSE(t *testing.T) { notifications := make(chan string) server := NewServer(testImpl, nil) - httpServer := httptest.NewServer(NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil)) + httpServer := httptest.NewServer(mustNotPanic(t, NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil))) defer httpServer.Close() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -857,7 +860,7 @@ func TestStreamableServerTransport(t *testing.T) { } func testStreamableHandler(t *testing.T, handler http.Handler, requests []streamableRequest) { - httpServer := httptest.NewServer(handler) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() // blocks records request blocks by jsonrpc. ID. @@ -1247,7 +1250,7 @@ func TestStreamableStateless(t *testing.T) { testClientCompatibility := func(t *testing.T, handler http.Handler) { ctx := context.Background() - httpServer := httptest.NewServer(handler) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() cs, err := NewClient(testImpl, nil).Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, nil) if err != nil { @@ -1332,7 +1335,7 @@ func TestTokenInfo(t *testing.T) { }, nil } handler := auth.RequireBearerToken(verifier, nil)(streamHandler) - httpServer := httptest.NewServer(handler) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() transport := &StreamableClientTransport{Endpoint: httpServer.URL} @@ -1366,7 +1369,7 @@ func TestStreamableGET(t *testing.T) { server := NewServer(testImpl, nil) handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) - httpServer := httptest.NewServer(handler) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -1442,7 +1445,7 @@ func TestStreamableClientContextPropagation(t *testing.T) { defer cancel() ctx2 := context.WithValue(ctx, testKey, testValue) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + server := httptest.NewServer(mustNotPanic(t, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { switch req.Method { case "POST": w.Header().Set("Content-Type", "application/json") @@ -1455,7 +1458,7 @@ func TestStreamableClientContextPropagation(t *testing.T) { case "DELETE": w.WriteHeader(http.StatusNoContent) } - })) + }))) defer server.Close() transport := &StreamableClientTransport{Endpoint: server.URL} @@ -1486,3 +1489,19 @@ func TestStreamableClientContextPropagation(t *testing.T) { } } + +// mustNotPanic is a helper to enforce that test handlers do not panic (see +// issue #556). +func mustNotPanic(t *testing.T, h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + defer func() { + if r := recover(); r != nil { + buf := make([]byte, 1<<20) + n := runtime.Stack(buf, false) + fmt.Fprintf(os.Stderr, "handler panic: %v\n\n%s", r, buf[:n]) + t.Errorf("handler panicked: %v", r) + } + }() + h.ServeHTTP(w, req) + }) +} From 9348d33b755c77611a5e875493c6bc4500f5eace Mon Sep 17 00:00:00 2001 From: Sam Thanawalla <17936816+samthanawalla@users.noreply.github.com> Date: Tue, 7 Oct 2025 08:39:49 -0400 Subject: [PATCH 096/418] oauthex: move internal/oauthex to oauthex (#559) This PR relocates oauthex and moves DCR logic into dcr.go. Fixes #558 --- auth/client.go | 2 +- oauthex/auth_meta.go | 147 ++++++++++++++++++ oauthex/auth_meta_test.go | 30 ++++ .../oauthex/auth_meta.go => oauthex/dcr.go | 133 +--------------- .../auth_meta_test.go => oauthex/dcr_test.go | 18 +-- {internal/oauthex => oauthex}/oauth2.go | 3 + {internal/oauthex => oauthex}/oauth2_test.go | 2 + oauthex/oauthex.go | 88 ++++++++++- .../oauthex => oauthex}/resource_meta.go | 88 +---------- .../testdata/client-auth-meta.json | 0 .../testdata/google-auth-meta.json | 0 11 files changed, 272 insertions(+), 239 deletions(-) create mode 100644 oauthex/auth_meta.go create mode 100644 oauthex/auth_meta_test.go rename internal/oauthex/auth_meta.go => oauthex/dcr.go (55%) rename internal/oauthex/auth_meta_test.go => oauthex/dcr_test.go (94%) rename {internal/oauthex => oauthex}/oauth2.go (98%) rename {internal/oauthex => oauthex}/oauth2_test.go (99%) rename {internal/oauthex => oauthex}/resource_meta.go (67%) rename {internal/oauthex => oauthex}/testdata/client-auth-meta.json (100%) rename {internal/oauthex => oauthex}/testdata/google-auth-meta.json (100%) diff --git a/auth/client.go b/auth/client.go index 2dda135..7c65e9d 100644 --- a/auth/client.go +++ b/auth/client.go @@ -12,7 +12,7 @@ import ( "net/http" "sync" - "github.com/modelcontextprotocol/go-sdk/internal/oauthex" + "github.com/modelcontextprotocol/go-sdk/oauthex" "golang.org/x/oauth2" ) diff --git a/oauthex/auth_meta.go b/oauthex/auth_meta.go new file mode 100644 index 0000000..73b8fb1 --- /dev/null +++ b/oauthex/auth_meta.go @@ -0,0 +1,147 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// This file implements Authorization Server Metadata. +// See https://www.rfc-editor.org/rfc/rfc8414.html. + +//go:build mcp_go_client_oauth + +package oauthex + +import ( + "context" + "errors" + "fmt" + "net/http" +) + +// AuthServerMeta represents the metadata for an OAuth 2.0 authorization server, +// as defined in [RFC 8414]. +// +// Not supported: +// - signed metadata +// +// [RFC 8414]: https://tools.ietf.org/html/rfc8414) +type AuthServerMeta struct { + // GENERATED BY GEMINI 2.5. + + // Issuer is the REQUIRED URL identifying the authorization server. + Issuer string `json:"issuer"` + + // AuthorizationEndpoint is the REQUIRED URL of the server's OAuth 2.0 authorization endpoint. + AuthorizationEndpoint string `json:"authorization_endpoint"` + + // TokenEndpoint is the REQUIRED URL of the server's OAuth 2.0 token endpoint. + TokenEndpoint string `json:"token_endpoint"` + + // JWKSURI is the REQUIRED URL of the server's JSON Web Key Set [JWK] document. + JWKSURI string `json:"jwks_uri"` + + // RegistrationEndpoint is the RECOMMENDED URL of the server's OAuth 2.0 Dynamic Client Registration endpoint. + RegistrationEndpoint string `json:"registration_endpoint,omitempty"` + + // ScopesSupported is a RECOMMENDED JSON array of strings containing a list of the OAuth 2.0 + // "scope" values that this server supports. + ScopesSupported []string `json:"scopes_supported,omitempty"` + + // ResponseTypesSupported is a REQUIRED JSON array of strings containing a list of the OAuth 2.0 + // "response_type" values that this server supports. + ResponseTypesSupported []string `json:"response_types_supported"` + + // ResponseModesSupported is a RECOMMENDED JSON array of strings containing a list of the OAuth 2.0 + // "response_mode" values that this server supports. + ResponseModesSupported []string `json:"response_modes_supported,omitempty"` + + // GrantTypesSupported is a RECOMMENDED JSON array of strings containing a list of the OAuth 2.0 + // grant type values that this server supports. + GrantTypesSupported []string `json:"grant_types_supported,omitempty"` + + // TokenEndpointAuthMethodsSupported is a RECOMMENDED JSON array of strings containing a list of + // client authentication methods supported by this token endpoint. + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"` + + // TokenEndpointAuthSigningAlgValuesSupported is a RECOMMENDED JSON array of strings containing + // a list of the JWS signing algorithms ("alg" values) supported by the token endpoint for + // the signature on the JWT used to authenticate the client. + TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported,omitempty"` + + // ServiceDocumentation is a RECOMMENDED URL of a page containing human-readable documentation + // for the service. + ServiceDocumentation string `json:"service_documentation,omitempty"` + + // UILocalesSupported is a RECOMMENDED JSON array of strings representing supported + // BCP47 [RFC5646] language tag values for display in the user interface. + UILocalesSupported []string `json:"ui_locales_supported,omitempty"` + + // OpPolicyURI is a RECOMMENDED URL that the server provides to the person registering + // the client to read about the server's operator policies. + OpPolicyURI string `json:"op_policy_uri,omitempty"` + + // OpTOSURI is a RECOMMENDED URL that the server provides to the person registering the + // client to read about the server's terms of service. + OpTOSURI string `json:"op_tos_uri,omitempty"` + + // RevocationEndpoint is a RECOMMENDED URL of the server's OAuth 2.0 revocation endpoint. + RevocationEndpoint string `json:"revocation_endpoint,omitempty"` + + // RevocationEndpointAuthMethodsSupported is a RECOMMENDED JSON array of strings containing + // a list of client authentication methods supported by this revocation endpoint. + RevocationEndpointAuthMethodsSupported []string `json:"revocation_endpoint_auth_methods_supported,omitempty"` + + // RevocationEndpointAuthSigningAlgValuesSupported is a RECOMMENDED JSON array of strings + // containing a list of the JWS signing algorithms ("alg" values) supported by the revocation + // endpoint for the signature on the JWT used to authenticate the client. + RevocationEndpointAuthSigningAlgValuesSupported []string `json:"revocation_endpoint_auth_signing_alg_values_supported,omitempty"` + + // IntrospectionEndpoint is a RECOMMENDED URL of the server's OAuth 2.0 introspection endpoint. + IntrospectionEndpoint string `json:"introspection_endpoint,omitempty"` + + // IntrospectionEndpointAuthMethodsSupported is a RECOMMENDED JSON array of strings containing + // a list of client authentication methods supported by this introspection endpoint. + IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported,omitempty"` + + // IntrospectionEndpointAuthSigningAlgValuesSupported is a RECOMMENDED JSON array of strings + // containing a list of the JWS signing algorithms ("alg" values) supported by the introspection + // endpoint for the signature on the JWT used to authenticate the client. + IntrospectionEndpointAuthSigningAlgValuesSupported []string `json:"introspection_endpoint_auth_signing_alg_values_supported,omitempty"` + + // CodeChallengeMethodsSupported is a RECOMMENDED JSON array of strings containing a list of + // PKCE code challenge methods supported by this authorization server. + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"` +} + +var wellKnownPaths = []string{ + "/.well-known/oauth-authorization-server", + "/.well-known/openid-configuration", +} + +// GetAuthServerMeta issues a GET request to retrieve authorization server metadata +// from an OAuth authorization server with the given issuerURL. +// +// It follows [RFC 8414]: +// - The well-known paths specified there are inserted into the URL's path, one at time. +// The first to succeed is used. +// - The Issuer field is checked against issuerURL. +// +// [RFC 8414]: https://tools.ietf.org/html/rfc8414 +func GetAuthServerMeta(ctx context.Context, issuerURL string, c *http.Client) (*AuthServerMeta, error) { + var errs []error + for _, p := range wellKnownPaths { + u, err := prependToPath(issuerURL, p) + if err != nil { + // issuerURL is bad; no point in continuing. + return nil, err + } + asm, err := getJSON[AuthServerMeta](ctx, c, u, 1<<20) + if err == nil { + if asm.Issuer != issuerURL { // section 3.3 + // Security violation; don't keep trying. + return nil, fmt.Errorf("metadata issuer %q does not match issuer URL %q", asm.Issuer, issuerURL) + } + return asm, nil + } + errs = append(errs, err) + } + return nil, fmt.Errorf("failed to get auth server metadata from %q: %w", issuerURL, errors.Join(errs...)) +} diff --git a/oauthex/auth_meta_test.go b/oauthex/auth_meta_test.go new file mode 100644 index 0000000..bee8f90 --- /dev/null +++ b/oauthex/auth_meta_test.go @@ -0,0 +1,30 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +//go:build mcp_go_client_oauth + +package oauthex + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestAuthMetaParse(t *testing.T) { + // Verify that we parse Google's auth server metadata. + data, err := os.ReadFile(filepath.FromSlash("testdata/google-auth-meta.json")) + if err != nil { + t.Fatal(err) + } + var a AuthServerMeta + if err := json.Unmarshal(data, &a); err != nil { + t.Fatal(err) + } + // Spot check. + if g, w := a.Issuer, "https://accounts.google.com"; g != w { + t.Errorf("got %q, want %q", g, w) + } +} diff --git a/internal/oauthex/auth_meta.go b/oauthex/dcr.go similarity index 55% rename from internal/oauthex/auth_meta.go rename to oauthex/dcr.go index 5bbbb41..75ce296 100644 --- a/internal/oauthex/auth_meta.go +++ b/oauthex/dcr.go @@ -5,114 +5,20 @@ // This file implements Authorization Server Metadata. // See https://www.rfc-editor.org/rfc/rfc8414.html. +//go:build mcp_go_client_oauth + package oauthex import ( "bytes" "context" "encoding/json" - "errors" "fmt" "io" "net/http" "time" ) -// AuthServerMeta represents the metadata for an OAuth 2.0 authorization server, -// as defined in [RFC 8414]. -// -// Not supported: -// - signed metadata -// -// [RFC 8414]: https://tools.ietf.org/html/rfc8414) -type AuthServerMeta struct { - // GENERATED BY GEMINI 2.5. - - // Issuer is the REQUIRED URL identifying the authorization server. - Issuer string `json:"issuer"` - - // AuthorizationEndpoint is the REQUIRED URL of the server's OAuth 2.0 authorization endpoint. - AuthorizationEndpoint string `json:"authorization_endpoint"` - - // TokenEndpoint is the REQUIRED URL of the server's OAuth 2.0 token endpoint. - TokenEndpoint string `json:"token_endpoint"` - - // JWKSURI is the REQUIRED URL of the server's JSON Web Key Set [JWK] document. - JWKSURI string `json:"jwks_uri"` - - // RegistrationEndpoint is the RECOMMENDED URL of the server's OAuth 2.0 Dynamic Client Registration endpoint. - RegistrationEndpoint string `json:"registration_endpoint,omitempty"` - - // ScopesSupported is a RECOMMENDED JSON array of strings containing a list of the OAuth 2.0 - // "scope" values that this server supports. - ScopesSupported []string `json:"scopes_supported,omitempty"` - - // ResponseTypesSupported is a REQUIRED JSON array of strings containing a list of the OAuth 2.0 - // "response_type" values that this server supports. - ResponseTypesSupported []string `json:"response_types_supported"` - - // ResponseModesSupported is a RECOMMENDED JSON array of strings containing a list of the OAuth 2.0 - // "response_mode" values that this server supports. - ResponseModesSupported []string `json:"response_modes_supported,omitempty"` - - // GrantTypesSupported is a RECOMMENDED JSON array of strings containing a list of the OAuth 2.0 - // grant type values that this server supports. - GrantTypesSupported []string `json:"grant_types_supported,omitempty"` - - // TokenEndpointAuthMethodsSupported is a RECOMMENDED JSON array of strings containing a list of - // client authentication methods supported by this token endpoint. - TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"` - - // TokenEndpointAuthSigningAlgValuesSupported is a RECOMMENDED JSON array of strings containing - // a list of the JWS signing algorithms ("alg" values) supported by the token endpoint for - // the signature on the JWT used to authenticate the client. - TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported,omitempty"` - - // ServiceDocumentation is a RECOMMENDED URL of a page containing human-readable documentation - // for the service. - ServiceDocumentation string `json:"service_documentation,omitempty"` - - // UILocalesSupported is a RECOMMENDED JSON array of strings representing supported - // BCP47 [RFC5646] language tag values for display in the user interface. - UILocalesSupported []string `json:"ui_locales_supported,omitempty"` - - // OpPolicyURI is a RECOMMENDED URL that the server provides to the person registering - // the client to read about the server's operator policies. - OpPolicyURI string `json:"op_policy_uri,omitempty"` - - // OpTOSURI is a RECOMMENDED URL that the server provides to the person registering the - // client to read about the server's terms of service. - OpTOSURI string `json:"op_tos_uri,omitempty"` - - // RevocationEndpoint is a RECOMMENDED URL of the server's OAuth 2.0 revocation endpoint. - RevocationEndpoint string `json:"revocation_endpoint,omitempty"` - - // RevocationEndpointAuthMethodsSupported is a RECOMMENDED JSON array of strings containing - // a list of client authentication methods supported by this revocation endpoint. - RevocationEndpointAuthMethodsSupported []string `json:"revocation_endpoint_auth_methods_supported,omitempty"` - - // RevocationEndpointAuthSigningAlgValuesSupported is a RECOMMENDED JSON array of strings - // containing a list of the JWS signing algorithms ("alg" values) supported by the revocation - // endpoint for the signature on the JWT used to authenticate the client. - RevocationEndpointAuthSigningAlgValuesSupported []string `json:"revocation_endpoint_auth_signing_alg_values_supported,omitempty"` - - // IntrospectionEndpoint is a RECOMMENDED URL of the server's OAuth 2.0 introspection endpoint. - IntrospectionEndpoint string `json:"introspection_endpoint,omitempty"` - - // IntrospectionEndpointAuthMethodsSupported is a RECOMMENDED JSON array of strings containing - // a list of client authentication methods supported by this introspection endpoint. - IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported,omitempty"` - - // IntrospectionEndpointAuthSigningAlgValuesSupported is a RECOMMENDED JSON array of strings - // containing a list of the JWS signing algorithms ("alg" values) supported by the introspection - // endpoint for the signature on the JWT used to authenticate the client. - IntrospectionEndpointAuthSigningAlgValuesSupported []string `json:"introspection_endpoint_auth_signing_alg_values_supported,omitempty"` - - // CodeChallengeMethodsSupported is a RECOMMENDED JSON array of strings containing a list of - // PKCE code challenge methods supported by this authorization server. - CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"` -} - // ClientRegistrationMetadata represents the client metadata fields for the DCR POST request (RFC 7591). type ClientRegistrationMetadata struct { // RedirectURIs is a REQUIRED JSON array of redirection URI strings for use in @@ -260,41 +166,6 @@ func (e *ClientRegistrationError) Error() string { return fmt.Sprintf("registration failed: %s (%s)", e.ErrorCode, e.ErrorDescription) } -var wellKnownPaths = []string{ - "/.well-known/oauth-authorization-server", - "/.well-known/openid-configuration", -} - -// GetAuthServerMeta issues a GET request to retrieve authorization server metadata -// from an OAuth authorization server with the given issuerURL. -// -// It follows [RFC 8414]: -// - The well-known paths specified there are inserted into the URL's path, one at time. -// The first to succeed is used. -// - The Issuer field is checked against issuerURL. -// -// [RFC 8414]: https://tools.ietf.org/html/rfc8414 -func GetAuthServerMeta(ctx context.Context, issuerURL string, c *http.Client) (*AuthServerMeta, error) { - var errs []error - for _, p := range wellKnownPaths { - u, err := prependToPath(issuerURL, p) - if err != nil { - // issuerURL is bad; no point in continuing. - return nil, err - } - asm, err := getJSON[AuthServerMeta](ctx, c, u, 1<<20) - if err == nil { - if asm.Issuer != issuerURL { // section 3.3 - // Security violation; don't keep trying. - return nil, fmt.Errorf("metadata issuer %q does not match issuer URL %q", asm.Issuer, issuerURL) - } - return asm, nil - } - errs = append(errs, err) - } - return nil, fmt.Errorf("failed to get auth server metadata from %q: %w", issuerURL, errors.Join(errs...)) -} - // RegisterClient performs Dynamic Client Registration according to RFC 7591. func RegisterClient(ctx context.Context, registrationEndpoint string, clientMeta *ClientRegistrationMetadata, c *http.Client) (*ClientRegistrationResponse, error) { if registrationEndpoint == "" { diff --git a/internal/oauthex/auth_meta_test.go b/oauthex/dcr_test.go similarity index 94% rename from internal/oauthex/auth_meta_test.go rename to oauthex/dcr_test.go index 6ff9f3d..7c5551e 100644 --- a/internal/oauthex/auth_meta_test.go +++ b/oauthex/dcr_test.go @@ -2,6 +2,8 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. +//go:build mcp_go_client_oauth + package oauthex import ( @@ -19,22 +21,6 @@ import ( "github.com/google/go-cmp/cmp" ) -func TestAuthMetaParse(t *testing.T) { - // Verify that we parse Google's auth server metadata. - data, err := os.ReadFile(filepath.FromSlash("testdata/google-auth-meta.json")) - if err != nil { - t.Fatal(err) - } - var a AuthServerMeta - if err := json.Unmarshal(data, &a); err != nil { - t.Fatal(err) - } - // Spot check. - if g, w := a.Issuer, "https://accounts.google.com"; g != w { - t.Errorf("got %q, want %q", g, w) - } -} - func TestClientRegistrationMetadataParse(t *testing.T) { // Verify that we can parse a typical client metadata JSON. data, err := os.ReadFile(filepath.FromSlash("testdata/client-auth-meta.json")) diff --git a/internal/oauthex/oauth2.go b/oauthex/oauth2.go similarity index 98% rename from internal/oauthex/oauth2.go rename to oauthex/oauth2.go index fc08c6b..f876be0 100644 --- a/internal/oauthex/oauth2.go +++ b/oauthex/oauth2.go @@ -3,6 +3,9 @@ // license that can be found in the LICENSE file. // Package oauthex implements extensions to OAuth2. + +//go:build mcp_go_client_oauth + package oauthex import ( diff --git a/internal/oauthex/oauth2_test.go b/oauthex/oauth2_test.go similarity index 99% rename from internal/oauthex/oauth2_test.go rename to oauthex/oauth2_test.go index 9c3da15..e4c71cb 100644 --- a/internal/oauthex/oauth2_test.go +++ b/oauthex/oauth2_test.go @@ -2,6 +2,8 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. +//go:build mcp_go_client_oauth + package oauthex import ( diff --git a/oauthex/oauthex.go b/oauthex/oauthex.go index 3c28dce..34ed55b 100644 --- a/oauthex/oauthex.go +++ b/oauthex/oauthex.go @@ -5,10 +5,88 @@ // Package oauthex implements extensions to OAuth2. package oauthex -import ( - "github.com/modelcontextprotocol/go-sdk/internal/oauthex" -) - // ProtectedResourceMetadata is the metadata for an OAuth 2.0 protected resource, // as defined in section 2 of https://www.rfc-editor.org/rfc/rfc9728.html. -type ProtectedResourceMetadata = oauthex.ProtectedResourceMetadata +// +// The following features are not supported: +// - additional keys (§2, last sentence) +// - human-readable metadata (§2.1) +// - signed metadata (§2.2) +type ProtectedResourceMetadata struct { + // GENERATED BY GEMINI 2.5. + + // Resource (resource) is the protected resource's resource identifier. + // Required. + Resource string `json:"resource"` + + // AuthorizationServers (authorization_servers) is an optional slice containing a list of + // OAuth authorization server issuer identifiers (as defined in RFC 8414) that can be + // used with this protected resource. + AuthorizationServers []string `json:"authorization_servers,omitempty"` + + // JWKSURI (jwks_uri) is an optional URL of the protected resource's JSON Web Key (JWK) Set + // document. This contains public keys belonging to the protected resource, such as + // signing key(s) that the resource server uses to sign resource responses. + JWKSURI string `json:"jwks_uri,omitempty"` + + // ScopesSupported (scopes_supported) is a recommended slice containing a list of scope + // values (as defined in RFC 6749) used in authorization requests to request access + // to this protected resource. + ScopesSupported []string `json:"scopes_supported,omitempty"` + + // BearerMethodsSupported (bearer_methods_supported) is an optional slice containing + // a list of the supported methods of sending an OAuth 2.0 bearer token to the + // protected resource. Defined values are "header", "body", and "query". + BearerMethodsSupported []string `json:"bearer_methods_supported,omitempty"` + + // ResourceSigningAlgValuesSupported (resource_signing_alg_values_supported) is an optional + // slice of JWS signing algorithms (alg values) supported by the protected + // resource for signing resource responses. + ResourceSigningAlgValuesSupported []string `json:"resource_signing_alg_values_supported,omitempty"` + + // ResourceName (resource_name) is a human-readable name of the protected resource + // intended for display to the end user. It is RECOMMENDED that this field be included. + // This value may be internationalized. + ResourceName string `json:"resource_name,omitempty"` + + // ResourceDocumentation (resource_documentation) is an optional URL of a page containing + // human-readable information for developers using the protected resource. + // This value may be internationalized. + ResourceDocumentation string `json:"resource_documentation,omitempty"` + + // ResourcePolicyURI (resource_policy_uri) is an optional URL of a page containing + // human-readable policy information on how a client can use the data provided. + // This value may be internationalized. + ResourcePolicyURI string `json:"resource_policy_uri,omitempty"` + + // ResourceTOSURI (resource_tos_uri) is an optional URL of a page containing the protected + // resource's human-readable terms of service. This value may be internationalized. + ResourceTOSURI string `json:"resource_tos_uri,omitempty"` + + // TLSClientCertificateBoundAccessTokens (tls_client_certificate_bound_access_tokens) is an + // optional boolean indicating support for mutual-TLS client certificate-bound + // access tokens (RFC 8705). Defaults to false if omitted. + TLSClientCertificateBoundAccessTokens bool `json:"tls_client_certificate_bound_access_tokens,omitempty"` + + // AuthorizationDetailsTypesSupported (authorization_details_types_supported) is an optional + // slice of 'type' values supported by the resource server for the + // 'authorization_details' parameter (RFC 9396). + AuthorizationDetailsTypesSupported []string `json:"authorization_details_types_supported,omitempty"` + + // DPOPSigningAlgValuesSupported (dpop_signing_alg_values_supported) is an optional + // slice of JWS signing algorithms supported by the resource server for validating + // DPoP proof JWTs (RFC 9449). + DPOPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"` + + // DPOPBoundAccessTokensRequired (dpop_bound_access_tokens_required) is an optional boolean + // specifying whether the protected resource always requires the use of DPoP-bound + // access tokens (RFC 9449). Defaults to false if omitted. + DPOPBoundAccessTokensRequired bool `json:"dpop_bound_access_tokens_required,omitempty"` + + // SignedMetadata (signed_metadata) is an optional JWT containing metadata parameters + // about the protected resource as claims. If present, these values take precedence + // over values conveyed in plain JSON. + // TODO:implement. + // Note that §2.2 says it's okay to ignore this. + // SignedMetadata string `json:"signed_metadata,omitempty"` +} diff --git a/internal/oauthex/resource_meta.go b/oauthex/resource_meta.go similarity index 67% rename from internal/oauthex/resource_meta.go rename to oauthex/resource_meta.go index 5787fb1..e584b06 100644 --- a/internal/oauthex/resource_meta.go +++ b/oauthex/resource_meta.go @@ -5,6 +5,8 @@ // This file implements Protected Resource Metadata. // See https://www.rfc-editor.org/rfc/rfc9728.html. +//go:build mcp_go_client_oauth + package oauthex import ( @@ -22,92 +24,6 @@ import ( const defaultProtectedResourceMetadataURI = "/.well-known/oauth-protected-resource" -// ProtectedResourceMetadata is the metadata for an OAuth 2.0 protected resource, -// as defined in section 2 of https://www.rfc-editor.org/rfc/rfc9728.html. -// -// The following features are not supported: -// - additional keys (§2, last sentence) -// - human-readable metadata (§2.1) -// - signed metadata (§2.2) -type ProtectedResourceMetadata struct { - // GENERATED BY GEMINI 2.5. - - // Resource (resource) is the protected resource's resource identifier. - // Required. - Resource string `json:"resource"` - - // AuthorizationServers (authorization_servers) is an optional slice containing a list of - // OAuth authorization server issuer identifiers (as defined in RFC 8414) that can be - // used with this protected resource. - AuthorizationServers []string `json:"authorization_servers,omitempty"` - - // JWKSURI (jwks_uri) is an optional URL of the protected resource's JSON Web Key (JWK) Set - // document. This contains public keys belonging to the protected resource, such as - // signing key(s) that the resource server uses to sign resource responses. - JWKSURI string `json:"jwks_uri,omitempty"` - - // ScopesSupported (scopes_supported) is a recommended slice containing a list of scope - // values (as defined in RFC 6749) used in authorization requests to request access - // to this protected resource. - ScopesSupported []string `json:"scopes_supported,omitempty"` - - // BearerMethodsSupported (bearer_methods_supported) is an optional slice containing - // a list of the supported methods of sending an OAuth 2.0 bearer token to the - // protected resource. Defined values are "header", "body", and "query". - BearerMethodsSupported []string `json:"bearer_methods_supported,omitempty"` - - // ResourceSigningAlgValuesSupported (resource_signing_alg_values_supported) is an optional - // slice of JWS signing algorithms (alg values) supported by the protected - // resource for signing resource responses. - ResourceSigningAlgValuesSupported []string `json:"resource_signing_alg_values_supported,omitempty"` - - // ResourceName (resource_name) is a human-readable name of the protected resource - // intended for display to the end user. It is RECOMMENDED that this field be included. - // This value may be internationalized. - ResourceName string `json:"resource_name,omitempty"` - - // ResourceDocumentation (resource_documentation) is an optional URL of a page containing - // human-readable information for developers using the protected resource. - // This value may be internationalized. - ResourceDocumentation string `json:"resource_documentation,omitempty"` - - // ResourcePolicyURI (resource_policy_uri) is an optional URL of a page containing - // human-readable policy information on how a client can use the data provided. - // This value may be internationalized. - ResourcePolicyURI string `json:"resource_policy_uri,omitempty"` - - // ResourceTOSURI (resource_tos_uri) is an optional URL of a page containing the protected - // resource's human-readable terms of service. This value may be internationalized. - ResourceTOSURI string `json:"resource_tos_uri,omitempty"` - - // TLSClientCertificateBoundAccessTokens (tls_client_certificate_bound_access_tokens) is an - // optional boolean indicating support for mutual-TLS client certificate-bound - // access tokens (RFC 8705). Defaults to false if omitted. - TLSClientCertificateBoundAccessTokens bool `json:"tls_client_certificate_bound_access_tokens,omitempty"` - - // AuthorizationDetailsTypesSupported (authorization_details_types_supported) is an optional - // slice of 'type' values supported by the resource server for the - // 'authorization_details' parameter (RFC 9396). - AuthorizationDetailsTypesSupported []string `json:"authorization_details_types_supported,omitempty"` - - // DPOPSigningAlgValuesSupported (dpop_signing_alg_values_supported) is an optional - // slice of JWS signing algorithms supported by the resource server for validating - // DPoP proof JWTs (RFC 9449). - DPOPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"` - - // DPOPBoundAccessTokensRequired (dpop_bound_access_tokens_required) is an optional boolean - // specifying whether the protected resource always requires the use of DPoP-bound - // access tokens (RFC 9449). Defaults to false if omitted. - DPOPBoundAccessTokensRequired bool `json:"dpop_bound_access_tokens_required,omitempty"` - - // SignedMetadata (signed_metadata) is an optional JWT containing metadata parameters - // about the protected resource as claims. If present, these values take precedence - // over values conveyed in plain JSON. - // TODO:implement. - // Note that §2.2 says it's okay to ignore this. - // SignedMetadata string `json:"signed_metadata,omitempty"` -} - // GetProtectedResourceMetadataFromID issues a GET request to retrieve protected resource // metadata from a resource server by its ID. // The resource ID is an HTTPS URL, typically with a host:port and possibly a path. diff --git a/internal/oauthex/testdata/client-auth-meta.json b/oauthex/testdata/client-auth-meta.json similarity index 100% rename from internal/oauthex/testdata/client-auth-meta.json rename to oauthex/testdata/client-auth-meta.json diff --git a/internal/oauthex/testdata/google-auth-meta.json b/oauthex/testdata/google-auth-meta.json similarity index 100% rename from internal/oauthex/testdata/google-auth-meta.json rename to oauthex/testdata/google-auth-meta.json From e8685b30224a20449f67bc5ec3afb98da2bd84f3 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Tue, 7 Oct 2025 11:08:38 -0400 Subject: [PATCH 097/418] mcp: don't close stdout when shutting down the stdio transport (#562) We need to close stdin when the stdio transport is shut down, to unblock its read loop. However, we don't technically need to close stdout. Fixes #548 --- mcp/transport.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/mcp/transport.go b/mcp/transport.go index 111887b..f2f93f4 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -90,9 +90,16 @@ type StdioTransport struct{} // Connect implements the [Transport] interface. func (*StdioTransport) Connect(context.Context) (Connection, error) { - return newIOConn(rwc{os.Stdin, os.Stdout}), nil + return newIOConn(rwc{os.Stdin, nopCloserWriter{os.Stdout}}), nil } +// nopCloserWriter is an io.WriteCloser with a trivial Close method. +type nopCloserWriter struct { + io.Writer +} + +func (nopCloserWriter) Close() error { return nil } + // An IOTransport is a [Transport] that communicates over separate // io.ReadCloser and io.WriteCloser using newline-delimited JSON. type IOTransport struct { @@ -107,6 +114,9 @@ func (t *IOTransport) Connect(context.Context) (Connection, error) { // An InMemoryTransport is a [Transport] that communicates over an in-memory // network connection, using newline-delimited JSON. +// +// InMemoryTransports should be constructed using [NewInMemoryTransports], +// which returns two transports connected to each other. type InMemoryTransport struct { rwc io.ReadWriteCloser } From 1059f59a27e9705c6b40d7e703176545cb72dc0f Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Wed, 8 Oct 2025 14:30:51 +0100 Subject: [PATCH 098/418] internal/oauthex: fix GetProtectedResourceMetadataFromHeader validation (#561) GetProtectedResourceMetadataFromHeader was validating the resource field against the metadata endpoint URL instead of the original server URL. Per RFC 9728 section 3.3, the resource field must match the URL that the client used to make the request to the resource server. Fixes: #560 --- oauthex/oauth2_test.go | 58 ++++++++++++++++++++++++++++++++++++---- oauthex/resource_meta.go | 17 ++++++------ 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/oauthex/oauth2_test.go b/oauthex/oauth2_test.go index e4c71cb..08d2d31 100644 --- a/oauthex/oauth2_test.go +++ b/oauthex/oauth2_test.go @@ -212,14 +212,15 @@ func TestGetProtectedResourceMetadata(t *testing.T) { server := httptest.NewTLSServer(h) h.installHandlers(server.URL) client := server.Client() - res, err := client.Get(server.URL + "/resource") + serverURL := server.URL + "/resource" + res, err := client.Get(serverURL) if err != nil { t.Fatal(err) } if res.StatusCode != http.StatusUnauthorized { t.Fatal("want unauth") } - prm, err := GetProtectedResourceMetadataFromHeader(ctx, res.Header, client) + prm, err := GetProtectedResourceMetadataFromHeader(ctx, serverURL, res.Header, client) if err != nil { t.Fatal(err) } @@ -240,11 +241,53 @@ func TestGetProtectedResourceMetadata(t *testing.T) { t.Fatal("nil prm") } }) + // Test that metadata URL and resource identifier are properly distinguished (issue #560). + t.Run("FromHeaderValidatesAgainstServerURL", func(t *testing.T) { + h := &fakeResourceHandler{serveWWWAuthenticate: true} + server := httptest.NewTLSServer(h) + h.installHandlers(server.URL) + client := server.Client() + serverURL := server.URL + "/resource" + res, err := client.Get(serverURL) + if err != nil { + t.Fatal(err) + } + // This should succeed because we validate against serverURL, not metadataURL + prm, err := GetProtectedResourceMetadataFromHeader(ctx, serverURL, res.Header, client) + if err != nil { + t.Fatalf("Expected validation to succeed, got error: %v", err) + } + if prm == nil { + t.Fatal("Expected non-nil prm") + } + if prm.Resource != serverURL { + t.Errorf("Expected resource %q, got %q", serverURL, prm.Resource) + } + }) + t.Run("FromHeaderRejectsImpersonation", func(t *testing.T) { + h := &fakeResourceHandler{serveWWWAuthenticate: true, resourceOverride: "https://attacker.com/evil"} + server := httptest.NewTLSServer(h) + h.installHandlers(server.URL) + client := server.Client() + serverURL := server.URL + "/resource" + res, err := client.Get(serverURL) + if err != nil { + t.Fatal(err) + } + prm, err := GetProtectedResourceMetadataFromHeader(ctx, serverURL, res.Header, client) + if err == nil { + t.Fatal("Expected validation error for mismatched resource, got nil") + } + if prm != nil { + t.Fatal("Expected nil prm on validation failure") + } + }) } type fakeResourceHandler struct { http.ServeMux serveWWWAuthenticate bool + resourceOverride string // If set, use this instead of correct resource (for testing validation) } func (h *fakeResourceHandler) installHandlers(serverURL string) { @@ -258,11 +301,16 @@ func (h *fakeResourceHandler) installHandlers(serverURL string) { })) h.Handle("GET "+path, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - // If there is a WWW-Authenticate header, the resource field is the value of that header. - // If not, it's the server URL without the "/.well-known/..." part. + // Per RFC 9728 section 3.3, the resource field should contain the actual resource identifier, + // which is the URL the client uses to access the resource (serverURL + "/resource" for WWW-Authenticate case). + // For the FromID test case, it's just the serverURL. resource := serverURL if h.serveWWWAuthenticate { - resource = url + resource = serverURL + "/resource" + } + // Allow testing with custom resource values (e.g., impersonation attacks). + if h.resourceOverride != "" { + resource = h.resourceOverride } prm := &ProtectedResourceMetadata{Resource: resource} if err := json.NewEncoder(w).Encode(prm); err != nil { diff --git a/oauthex/resource_meta.go b/oauthex/resource_meta.go index e584b06..bb61f79 100644 --- a/oauthex/resource_meta.go +++ b/oauthex/resource_meta.go @@ -53,10 +53,11 @@ func GetProtectedResourceMetadataFromID(ctx context.Context, resourceID string, // GetProtectedResourceMetadataFromHeader retrieves protected resource metadata // using information in the given header, using the given client (or the default // client if nil). -// It issues a GET request to a URL discovered by parsing the WWW-Authenticate headers in the given request, -// It then validates the resource field of the resulting metadata against the given URL. -// If there is no URL in the request, it returns nil, nil. -func GetProtectedResourceMetadataFromHeader(ctx context.Context, header http.Header, c *http.Client) (_ *ProtectedResourceMetadata, err error) { +// It issues a GET request to a URL discovered by parsing the WWW-Authenticate headers in the given request. +// Per RFC 9728 section 3.3, it validates that the resource field of the resulting metadata +// matches the serverURL (the URL that the client used to make the original request to the resource server). +// If there is no metadata URL in the header, it returns nil, nil. +func GetProtectedResourceMetadataFromHeader(ctx context.Context, serverURL string, header http.Header, c *http.Client) (_ *ProtectedResourceMetadata, err error) { defer util.Wrapf(&err, "GetProtectedResourceMetadataFromHeader") headers := header[http.CanonicalHeaderKey("WWW-Authenticate")] if len(headers) == 0 { @@ -66,11 +67,11 @@ func GetProtectedResourceMetadataFromHeader(ctx context.Context, header http.Hea if err != nil { return nil, err } - url := ResourceMetadataURL(cs) - if url == "" { + metadataURL := ResourceMetadataURL(cs) + if metadataURL == "" { return nil, nil } - return getPRM(ctx, url, c, url) + return getPRM(ctx, metadataURL, c, serverURL) } // getPRM makes a GET request to the given URL, and validates the response. @@ -83,7 +84,7 @@ func getPRM(ctx context.Context, purl string, c *http.Client, wantResource strin if err != nil { return nil, err } - // Validate the Resource field to thwart impersonation attacks (section 3.3). + // Validate the Resource field (see RFC 9728, section 3.3). if prm.Resource != wantResource { return nil, fmt.Errorf("got metadata resource %q, want %q", prm.Resource, wantResource) } From 770dbdfe0f11d77f4d7dd435b32981f8e14e2e45 Mon Sep 17 00:00:00 2001 From: Sam Thanawalla <17936816+samthanawalla@users.noreply.github.com> Date: Thu, 9 Oct 2025 10:38:54 -0400 Subject: [PATCH 099/418] oauthex: update fake oauth server (#564) This PR adds /mcp, /.well-known/oauth-protected-resource, and /register to the fake server setup. This will be useful for future tests. For: #19 --- internal/testing/fake_auth_server.go | 149 ---------------- oauthex/oauthex_test.go | 258 +++++++++++++++++++++++++++ 2 files changed, 258 insertions(+), 149 deletions(-) delete mode 100644 internal/testing/fake_auth_server.go create mode 100644 oauthex/oauthex_test.go diff --git a/internal/testing/fake_auth_server.go b/internal/testing/fake_auth_server.go deleted file mode 100644 index 79fafe4..0000000 --- a/internal/testing/fake_auth_server.go +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright 2025 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -package testing - -import ( - "crypto/sha256" - "encoding/base64" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/golang-jwt/jwt/v5" -) - -const ( - authServerPort = ":8080" - issuer = "http://localhost" + authServerPort - tokenExpiry = time.Hour -) - -var jwtSigningKey = []byte("fake-secret-key") - -type authCodeInfo struct { - codeChallenge string - redirectURI string -} - -type state struct { - authCodes map[string]authCodeInfo -} - -// NewFakeAuthMux constructs a ServeMux that implements an OAuth 2.1 authentication -// server. It should be used with [httptest.NewTLSServer]. -func NewFakeAuthMux() *http.ServeMux { - s := &state{authCodes: make(map[string]authCodeInfo)} - mux := http.NewServeMux() - mux.HandleFunc("/.well-known/oauth-authorization-server", s.handleMetadata) - mux.HandleFunc("/authorize", s.handleAuthorize) - mux.HandleFunc("/token", s.handleToken) - return mux -} - -func (s *state) handleMetadata(w http.ResponseWriter, r *http.Request) { - issuer := "https://localhost:" + r.URL.Port() - metadata := map[string]any{ - "issuer": issuer, - "authorization_endpoint": issuer + "/authorize", - "token_endpoint": issuer + "/token", - "jwks_uri": issuer + "/.well-known/jwks.json", - "scopes_supported": []string{"openid", "profile", "email"}, - "response_types_supported": []string{"code"}, - "grant_types_supported": []string{"authorization_code"}, - "token_endpoint_auth_methods_supported": []string{"none"}, - "code_challenge_methods_supported": []string{"S256"}, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(metadata) -} - -func (s *state) handleAuthorize(w http.ResponseWriter, r *http.Request) { - query := r.URL.Query() - responseType := query.Get("response_type") - redirectURI := query.Get("redirect_uri") - codeChallenge := query.Get("code_challenge") - codeChallengeMethod := query.Get("code_challenge_method") - - if responseType != "code" { - http.Error(w, "unsupported_response_type", http.StatusBadRequest) - return - } - if redirectURI == "" { - http.Error(w, "invalid_request (no redirect_uri)", http.StatusBadRequest) - return - } - if codeChallenge == "" || codeChallengeMethod != "S256" { - http.Error(w, "invalid_request (code challenge is not S256)", http.StatusBadRequest) - return - } - if query.Get("client_id") == "" { - http.Error(w, "invalid_request (missing client_id)", http.StatusBadRequest) - return - } - - authCode := "fake-auth-code-" + fmt.Sprintf("%d", time.Now().UnixNano()) - s.authCodes[authCode] = authCodeInfo{ - codeChallenge: codeChallenge, - redirectURI: redirectURI, - } - - redirectURL := fmt.Sprintf("%s?code=%s&state=%s", redirectURI, authCode, query.Get("state")) - http.Redirect(w, r, redirectURL, http.StatusFound) -} - -func (s *state) handleToken(w http.ResponseWriter, r *http.Request) { - r.ParseForm() - grantType := r.Form.Get("grant_type") - code := r.Form.Get("code") - codeVerifier := r.Form.Get("code_verifier") - // Ignore redirect_uri; it is not required in 2.1. - // https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#redirect-uri-in-token-request - - if grantType != "authorization_code" { - http.Error(w, "unsupported_grant_type", http.StatusBadRequest) - return - } - - authCodeInfo, ok := s.authCodes[code] - if !ok { - http.Error(w, "invalid_grant", http.StatusBadRequest) - return - } - delete(s.authCodes, code) - - // PKCE verification - hasher := sha256.New() - hasher.Write([]byte(codeVerifier)) - calculatedChallenge := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil)) - if calculatedChallenge != authCodeInfo.codeChallenge { - http.Error(w, "invalid_grant", http.StatusBadRequest) - return - } - - // Issue JWT - now := time.Now() - claims := jwt.MapClaims{ - "iss": issuer, - "sub": "fake-user-id", - "aud": "fake-client-id", - "exp": now.Add(tokenExpiry).Unix(), - "iat": now.Unix(), - } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - accessToken, err := token.SignedString(jwtSigningKey) - if err != nil { - http.Error(w, "server_error", http.StatusInternalServerError) - return - } - - tokenResponse := map[string]any{ - "access_token": accessToken, - "token_type": "Bearer", - "expires_in": int(tokenExpiry.Seconds()), - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(tokenResponse) -} diff --git a/oauthex/oauthex_test.go b/oauthex/oauthex_test.go new file mode 100644 index 0000000..ca19ace --- /dev/null +++ b/oauthex/oauthex_test.go @@ -0,0 +1,258 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +//go:build mcp_go_client_oauth + +package oauthex + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const ( + tokenExpiry = time.Hour +) + +var jwtSigningKey = []byte("fake-secret-key") + +type authCodeInfo struct { + codeChallenge string + redirectURI string +} + +type state struct { + mu sync.Mutex + authCodes map[string]authCodeInfo +} + +// NewFakeMCPServerMux constructs a ServeMux that implements an MCP server +// with an integrated OAuth 2.1 authentication server. It should be used with +// [httptest.NewTLSServer]. +func NewFakeMCPServerMux() *http.ServeMux { + s := &state{authCodes: make(map[string]authCodeInfo)} + mux := http.NewServeMux() + mux.HandleFunc("/mcp", s.handleMCP) + mux.HandleFunc("/.well-known/oauth-protected-resource", s.handleProtectedResourceMetadata) + mux.HandleFunc("/.well-known/oauth-authorization-server", s.handleServerMetadata) + mux.HandleFunc("/register", s.handleDynamicClientRegistration) + mux.HandleFunc("/authorize", s.handleAuthorize) + mux.HandleFunc("/token", s.handleToken) + return mux +} + +// handleMCP is the protected resource endpoint. It requires a valid Bearer token. +// If the token is missing or invalid, it returns a 401 Unauthorized response +// with a WWW-Authenticate header pointing to the resource metadata. +func (s *state) handleMCP(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return jwtSigningKey, nil + }) + + if err != nil || !token.Valid { + metadataURL := getBaseURL(r) + "/.well-known/oauth-protected-resource" + w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer resource_metadata="%s", scope="openid profile email"`, metadataURL)) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Token is valid, serve the resource. + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, "Hello from a protected MCP server!") +} + +// handleProtectedResourceMetadata serves the OAuth 2.0 Protected Resource Metadata document, +// as defined in RFC 9728. +func (s *state) handleProtectedResourceMetadata(w http.ResponseWriter, r *http.Request) { + // Construct the authorization server issuer URL dynamically. + authServerIssuer := getBaseURL(r) + + metadata := ProtectedResourceMetadata{ + ScopesSupported: []string{"openid", "profile", "email"}, + AuthorizationServers: []string{authServerIssuer}, + } + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(metadata); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(buf.Bytes()) +} + +func (s *state) handleServerMetadata(w http.ResponseWriter, r *http.Request) { + issuer := getBaseURL(r) + metadata := AuthServerMeta{ + Issuer: issuer, + AuthorizationEndpoint: issuer + "/authorize", + TokenEndpoint: issuer + "/token", + RegistrationEndpoint: issuer + "/register", + JWKSURI: issuer + "/.well-known/jwks.json", + ScopesSupported: []string{"openid", "profile", "email"}, + ResponseTypesSupported: []string{"code"}, + GrantTypesSupported: []string{"authorization_code"}, + TokenEndpointAuthMethodsSupported: []string{"none"}, + CodeChallengeMethodsSupported: []string{"S256"}, + } + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(metadata); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(buf.Bytes()) +} + +// handleDynamicClientRegistration handles dynamic client registration requests, +// as defined in RFC 7591. +func (s *state) handleDynamicClientRegistration(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + var crm ClientRegistrationMetadata + if err := json.NewDecoder(r.Body).Decode(&crm); err != nil { + http.Error(w, fmt.Sprintf("invalid JSON in request body: %v", err), http.StatusBadRequest) + return + } + clientID := fmt.Sprintf("fake-client-%d", time.Now().UnixNano()) + + response := ClientRegistrationResponse{ + ClientRegistrationMetadata: crm, + ClientID: clientID, + ClientIDIssuedAt: time.Now(), + ClientSecret: "fake-registration-access-secret", + } + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + w.Write(buf.Bytes()) +} + +func (s *state) handleToken(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + grantType := r.Form.Get("grant_type") + code := r.Form.Get("code") + codeVerifier := r.Form.Get("code_verifier") + // Ignore redirect_uri; it is not required in 2.1. + // https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#redirect-uri-in-token-request + + if grantType != "authorization_code" { + http.Error(w, "unsupported_grant_type", http.StatusBadRequest) + return + } + s.mu.Lock() + authCodeInfo, ok := s.authCodes[code] + if !ok { + http.Error(w, "invalid_grant", http.StatusBadRequest) + return + } + delete(s.authCodes, code) + s.mu.Unlock() + + // PKCE verification. + hasher := sha256.New() + hasher.Write([]byte(codeVerifier)) + calculatedChallenge := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil)) + if calculatedChallenge != authCodeInfo.codeChallenge { + http.Error(w, "invalid_grant", http.StatusBadRequest) + return + } + + // Issue JWT. + now := time.Now() + claims := jwt.MapClaims{ + "iss": getBaseURL(r), + "sub": "fake-user-id", + "aud": "fake-client-id", + "exp": now.Add(tokenExpiry).Unix(), + "iat": now.Unix(), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + accessToken, err := token.SignedString(jwtSigningKey) + if err != nil { + http.Error(w, "server_error", http.StatusInternalServerError) + return + } + + tokenResponse := map[string]any{ + "access_token": accessToken, + "token_type": "Bearer", + "expires_in": int(tokenExpiry.Seconds()), + } + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(tokenResponse); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(buf.Bytes()) +} + +func (s *state) handleAuthorize(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + responseType := query.Get("response_type") + redirectURI := query.Get("redirect_uri") + codeChallenge := query.Get("code_challenge") + codeChallengeMethod := query.Get("code_challenge_method") + + if responseType != "code" { + http.Error(w, "unsupported_response_type", http.StatusBadRequest) + return + } + if redirectURI == "" { + http.Error(w, "invalid_request (no redirect_uri)", http.StatusBadRequest) + return + } + if codeChallenge == "" || codeChallengeMethod != "S256" { + http.Error(w, "invalid_request (code challenge is not S256)", http.StatusBadRequest) + return + } + if query.Get("client_id") == "" { + http.Error(w, "invalid_request (missing client_id)", http.StatusBadRequest) + return + } + + authCode := "fake-auth-code-" + fmt.Sprintf("%d", time.Now().UnixNano()) + s.mu.Lock() + s.authCodes[authCode] = authCodeInfo{ + codeChallenge: codeChallenge, + redirectURI: redirectURI, + } + s.mu.Unlock() + + redirectURL := fmt.Sprintf("%s?code=%s&state=%s", redirectURI, authCode, query.Get("state")) + http.Redirect(w, r, redirectURL, http.StatusFound) +} + +// getBaseURL constructs the base URL (scheme://host) from the request. +func getBaseURL(r *http.Request) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + return scheme + "://" + r.Host +} From 4c0a015394a230380e4f9dd89286ef5d46382060 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Fri, 10 Oct 2025 12:03:22 -0400 Subject: [PATCH 100/418] .github: fix quoting in docs check error message (#568) See https://github.com/modelcontextprotocol/go-sdk/actions/runs/18410542710/job/52464273952?pr=567 Apparently, back-ticked code snippets are not rendered, leading to non-sensical suggestions from the docs check failure. Remove back-ticks for now. --- .github/workflows/docs-check.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index cb6b841..984e878 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -25,9 +25,9 @@ jobs: if [ -n "$(git status --porcelain)" ]; then echo "ERROR: docs are not up-to-date!" echo "" - echo "The docs differ from what would be generated by `go generate ./...`." + echo "The docs differ from what would be generated by go generate ./..." echo "Please update internal/**/*.src.md instead of directly editing README.md or docs/ files," - echo "then run `go generate ./...` to regenerate docs." + echo "then run go generate ./... to regenerate docs." echo "" echo "Changes:" git status --porcelain From 7a89c31c7ae6092bea8722631bc0fc0fd723fabf Mon Sep 17 00:00:00 2001 From: Adriel Velazquez Date: Sat, 11 Oct 2025 10:21:16 -0400 Subject: [PATCH 101/418] Correct go run commands to run the logging file (#566) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Readme update and clarification Targeting only the main.go doesn't add the logging file and causes errors when finding additional functions. ``` mcp-server-testing main*​ ☸ prod-3-d 33s ❯ go run main.go server # command-line-arguments ./main.go:127:24: undefined: LoggingHandler mcp-server-testing main*​ ☸ prod-3-d ❯ go run . server 2025/10/09 11:33:13 MCP server listening on localhost:8000 2025/10/09 11:33:13 Available tool: cityTime (cities: nyc, sf, boston) ``` --- examples/http/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/http/README.md b/examples/http/README.md index 16a4801..d18c591 100644 --- a/examples/http/README.md +++ b/examples/http/README.md @@ -13,14 +13,14 @@ The example implements: Start the Server ```bash -go run main.go server +go run . server ``` This starts an MCP server on `http://localhost:8080` (default) that provides a `cityTime` tool. To run a client in another terminal: ```bash -go run main.go client +go run . client ``` The client will: @@ -32,7 +32,7 @@ The client will: At any given time you can pass a custom URL to the program to run it on a custom host/port: ``` -go run main.go -host 0.0.0.0 -port 9000 server +go run . -host 0.0.0.0 -port 9000 server ``` ## Testing with real-world MCP Clients From 920e5a39ba9026af14ab459ce856e6ef907de0b9 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Mon, 13 Oct 2025 23:47:58 +0800 Subject: [PATCH 102/418] docs: improve documentation clarity and client integration steps (#567) - Fix typo in "TroubleShooting" heading, changing it to "Troubleshooting" - Improve CLI usage instructions by formatting commands as code blocks and marking them with "bash" - Change "Start the Server" to a third-level heading for clarity - Add additional code examples for real-world client testing and interaction steps - Clarify and condense instructions for adding the tool to a popular MCP client --------- Signed-off-by: appleboy Co-authored-by: Robert Findley --- examples/http/README.md | 38 +++++++++++++---------- examples/server/auth-middleware/README.md | 38 +++++++++++------------ 2 files changed, 40 insertions(+), 36 deletions(-) diff --git a/examples/http/README.md b/examples/http/README.md index d18c591..3bd8c93 100644 --- a/examples/http/README.md +++ b/examples/http/README.md @@ -10,7 +10,7 @@ The example implements: ## Usage -Start the Server +### Start the Server ```bash go run . server @@ -31,36 +31,40 @@ The client will: At any given time you can pass a custom URL to the program to run it on a custom host/port: -``` + +```bash go run . -host 0.0.0.0 -port 9000 server ``` ## Testing with real-world MCP Clients -Once the server is started, assuming it's the default -localhost:8080, you can try to add it to a popular MCP client: +Once the server is started, assuming it's the default localhost:8080, you can try to add it to a popular MCP client: - claude mcp add -t http timezone http://localhost:8080 +```bash +claude mcp add -t http timezone http://localhost:8080 +``` Once added, Claude Code will be able to discover and use the `cityTime` tool provided by this server. In Claude Code: - > what's the timezone +``` +> what's the timezone - ⏺ I'll get the current time in a major US city for you. +⏺ I'll get the current time in a major US city for you. - ⏺ timezone - cityTime (MCP)(city: "nyc") - ⎿ The current time in New York City is 7:30:16 PM EDT on Wedn - esday, July 23, 2025 +⏺ timezone - cityTime (MCP)(city: "nyc") + ⎿ The current time in New York City is 7:30:16 PM EDT on Wedn + esday, July 23, 2025 - ⏺ The current timezone is EDT (Eastern Daylight Time), and it's - 7:30 PM on Wednesday, July 23, 2025. +⏺ The current timezone is EDT (Eastern Daylight Time), and it's + 7:30 PM on Wednesday, July 23, 2025. - > what timezones do you support? +> what timezones do you support? - ⏺ The timezone tool supports three US cities: - - NYC (Eastern Time) - - SF (Pacific Time) - - Boston (Eastern Time) +⏺ The timezone tool supports three US cities: + - NYC (Eastern Time) + - SF (Pacific Time) + - Boston (Eastern Time) +``` diff --git a/examples/server/auth-middleware/README.md b/examples/server/auth-middleware/README.md index 913022f..aaae767 100644 --- a/examples/server/auth-middleware/README.md +++ b/examples/server/auth-middleware/README.md @@ -154,12 +154,12 @@ server := mcp.NewServer(&mcp.Implementation{Name: "authenticated-mcp-server"}, n // Create authentication middleware authMiddleware := auth.RequireBearerToken(verifier, &auth.RequireBearerTokenOptions{ - Scopes: []string{"read", "write"}, + Scopes: []string{"read", "write"}, }) // Create MCP handler handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { - return server + return server }, nil) // Apply authentication middleware to MCP handler @@ -185,9 +185,9 @@ authenticatedHandler := authMiddleware(customMiddleware(handler)) ```go func jwtVerifier(ctx context.Context, tokenString string) (*auth.TokenInfo, error) { - // JWT token verification logic - // On success: Return TokenInfo - // On failure: Return auth.ErrInvalidToken + // JWT token verification logic + // On success: Return TokenInfo + // On failure: Return auth.ErrInvalidToken } ``` @@ -196,20 +196,20 @@ func jwtVerifier(ctx context.Context, tokenString string) (*auth.TokenInfo, erro ```go // Get authentication information in MCP tool func MyTool(ctx context.Context, req *mcp.CallToolRequest, args MyArgs) (*mcp.CallToolResult, any, error) { - // Extract authentication info from request - userInfo := req.Extra.TokenInfo - - // Check scopes - if !slices.Contains(userInfo.Scopes, "read") { - return nil, nil, fmt.Errorf("insufficient permissions: read scope required") - } - - // Execute tool logic - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: "Tool executed successfully"}, - }, - }, nil, nil + // Extract authentication info from request + userInfo := req.Extra.TokenInfo + + // Check scopes + if !slices.Contains(userInfo.Scopes, "read") { + return nil, nil, fmt.Errorf("insufficient permissions: read scope required") + } + + // Execute tool logic + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "Tool executed successfully"}, + }, + }, nil, nil } ``` From 31e97ad69edad679c726449c4584353502965cf5 Mon Sep 17 00:00:00 2001 From: Ryo Kusnadi Date: Tue, 14 Oct 2025 22:09:28 +0700 Subject: [PATCH 103/418] Add Check PCKE Support (#563) Refer To [Issue 473](https://github.com/modelcontextprotocol/go-sdk/issues/473) Changes: - Add check for PKCE support via server metadata and return an error if not supported. --- oauthex/auth_meta.go | 5 +++ oauthex/auth_meta_test.go | 90 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/oauthex/auth_meta.go b/oauthex/auth_meta.go index 73b8fb1..3723318 100644 --- a/oauthex/auth_meta.go +++ b/oauthex/auth_meta.go @@ -139,6 +139,11 @@ func GetAuthServerMeta(ctx context.Context, issuerURL string, c *http.Client) (* // Security violation; don't keep trying. return nil, fmt.Errorf("metadata issuer %q does not match issuer URL %q", asm.Issuer, issuerURL) } + + if len(asm.CodeChallengeMethodsSupported) == 0 { + return nil, fmt.Errorf("authorization server at %s does not implement PKCE", issuerURL) + } + return asm, nil } errs = append(errs, err) diff --git a/oauthex/auth_meta_test.go b/oauthex/auth_meta_test.go index bee8f90..1e60882 100644 --- a/oauthex/auth_meta_test.go +++ b/oauthex/auth_meta_test.go @@ -7,9 +7,14 @@ package oauthex import ( + "context" "encoding/json" + "net/http" + "net/http/httptest" + "net/url" "os" "path/filepath" + "strings" "testing" ) @@ -28,3 +33,88 @@ func TestAuthMetaParse(t *testing.T) { t.Errorf("got %q, want %q", g, w) } } + +func TestGetAuthServerMetaPKCESupport(t *testing.T) { + ctx := context.Background() + tests := []struct { + name string + hasPKCESupport bool + wantError string + }{ + { + name: "server_with_pkce_support", + hasPKCESupport: true, + }, + { + name: "server_without_pkce_support", + hasPKCESupport: false, + wantError: "does not implement PKCE", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Start a fake OAuth 2.1 auth server + wrapper := http.NewServeMux() + wrapper.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) { + u, _ := url.Parse("https://" + r.Host) + issuer := "https://localhost:" + u.Port() + metadata := AuthServerMeta{ + Issuer: issuer, + AuthorizationEndpoint: issuer + "/authorize", + TokenEndpoint: issuer + "/token", + RegistrationEndpoint: issuer + "/register", + JWKSURI: issuer + "/.well-known/jwks.json", + ScopesSupported: []string{"openid", "profile", "email"}, + ResponseTypesSupported: []string{"code"}, + GrantTypesSupported: []string{"authorization_code"}, + TokenEndpointAuthMethodsSupported: []string{"none"}, + } + + // Add PKCE support based on test case + if tt.hasPKCESupport { + metadata.CodeChallengeMethodsSupported = []string{"S256"} + } + // If hasPKCESupport is false, CodeChallengeMethodsSupported remains empty + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(metadata) + }) + ts := httptest.NewTLSServer(wrapper) + defer ts.Close() + + // The fake server sets issuer to https://localhost:, so compute that issuer. + u, _ := url.Parse(ts.URL) + issuer := "https://localhost:" + u.Port() + + // The fake server presents a cert for example.com; set ServerName accordingly. + httpClient := ts.Client() + if tr, ok := httpClient.Transport.(*http.Transport); ok { + clone := tr.Clone() + clone.TLSClientConfig.ServerName = "example.com" + httpClient.Transport = clone + } + + meta, err := GetAuthServerMeta(ctx, issuer, httpClient) + if tt.wantError != "" { + if err == nil { + t.Fatal("wanted error but got none") + } + if !strings.Contains(err.Error(), tt.wantError) { + t.Errorf("wanted error to contain %q, but got: %v", tt.wantError, err) + } + } else { + if err != nil { + t.Fatalf("unwanted error: %v", err) + } + if meta == nil { + t.Fatal("wanted metadata but got nil") + } + // Verify PKCE support is present + if len(meta.CodeChallengeMethodsSupported) == 0 { + t.Error("wanted PKCE support but CodeChallengeMethodsSupported is empty") + } + } + }) + } +} From 9d6e3074f9183e8a9d816fc701fe2ca8baa1d1b8 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Wed, 15 Oct 2025 14:43:45 -0400 Subject: [PATCH 104/418] mcp: add a explicit test for concurrent streamable sessions (#584) We seem not to have a test that explicitly exercises concurrent streamable sessions. Add one. --- mcp/streamable_test.go | 51 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 6ccaebf..6bbe8c4 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -192,6 +192,57 @@ func TestStreamableTransports(t *testing.T) { } } +func TestStreamableConcurrentHandling(t *testing.T) { + // This test checks that the streamable server and client transports can + // communicate. + type count struct { + Count int + } + + var mu sync.Mutex + counts := make(map[string]int) + + server := NewServer(testImpl, nil) + AddTool(server, &Tool{Name: "inc"}, func(ctx context.Context, req *CallToolRequest, _ any) (*CallToolResult, count, error) { + id := req.Session.ID() + mu.Lock() + defer mu.Unlock() + c := counts[id] + counts[id] = c + 1 + return nil, count{c}, nil + }) + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + defer httpServer.Close() + + ctx := context.Background() + client := NewClient(testImpl, nil) + var wg sync.WaitGroup + for range 100 { + wg.Add(1) + go func() { + defer wg.Done() + clientSession, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, nil) + if err != nil { + t.Errorf("Connect failed: %v", err) + return + } + defer clientSession.Close() + for i := range 10 { + res, err := clientSession.CallTool(ctx, &CallToolParams{Name: "inc"}) + if err != nil { + t.Errorf("CallTool failed: %v", err) + return + } + if got := int(res.StructuredContent.(map[string]any)["Count"].(float64)); got != i { + t.Errorf("got count %d, want %d", got, i) + } + } + }() + } + wg.Wait() +} + func TestStreamableServerShutdown(t *testing.T) { ctx := context.Background() From 14e776db6f90de4ef800909b3ddf7051cac0de99 Mon Sep 17 00:00:00 2001 From: Haokun Tang <60290183+TomCN0803@users.noreply.github.com> Date: Thu, 16 Oct 2025 21:06:15 +0800 Subject: [PATCH 105/418] mcp: optimize re-marshalling of jsonschema.Schema (#581) mcp: optimize re-marshalling of jsonschema.Schema Avoid going through function remarshal when InputSchema/OutputSchema is of type *jsonschema.Schema. --- mcp/server.go | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index f5b99cf..4f7ce75 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -197,8 +197,10 @@ func (s *Server) AddTool(t *Tool, h ToolHandler) { // discovered until runtime, when the LLM sent bad data. panic(fmt.Errorf("AddTool %q: missing input schema", t.Name)) } - if s, ok := t.InputSchema.(*jsonschema.Schema); ok && s.Type != "object" { - panic(fmt.Errorf(`AddTool %q: input schema must have type "object"`, t.Name)) + if s, ok := t.InputSchema.(*jsonschema.Schema); ok { + if s.Type != "object" { + panic(fmt.Errorf(`AddTool %q: input schema must have type "object"`, t.Name)) + } } else { var m map[string]any if err := remarshal(t.InputSchema, &m); err != nil { @@ -209,8 +211,10 @@ func (s *Server) AddTool(t *Tool, h ToolHandler) { } } if t.OutputSchema != nil { - if s, ok := t.OutputSchema.(*jsonschema.Schema); ok && s.Type != "object" { - panic(fmt.Errorf(`AddTool %q: output schema must have type "object"`, t.Name)) + if s, ok := t.OutputSchema.(*jsonschema.Schema); ok { + if s.Type != "object" { + panic(fmt.Errorf(`AddTool %q: output schema must have type "object"`, t.Name)) + } } else { var m map[string]any if err := remarshal(t.OutputSchema, &m); err != nil { @@ -370,10 +374,8 @@ func setSchema[T any](sfield *any, rfield **jsonschema.Resolved) (zero any, err if err == nil { *sfield = internalSchema } - } else { - if err := remarshal(*sfield, &internalSchema); err != nil { - return zero, err - } + } else if err := remarshal(*sfield, &internalSchema); err != nil { + return zero, err } if err != nil { return zero, err From 010bdbc64d950e435b95d6b3432062b20003805e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Friedrich=20Gro=C3=9Fe?= <733004+fgrosse@users.noreply.github.com> Date: Thu, 16 Oct 2025 17:13:30 +0200 Subject: [PATCH 106/418] mcp: fix goroutine leaks in unit tests (#496) This PR fixes goroutine leaks in all unit tests. To find the leaks I integrated https://github.com/uber-go/goleak and I suggest to keep using it to catch any future regressions. I used the folowing script to more easily find individual tests which were leaking goroutines: ``` for t in $(go test -run=Nothing -list=. ./mcp | grep -v ok); do go test ./mcp -run="$t" > /dev/null && echo -n . || echo -e "\nDetected leak: $t"; done ``` --- mcp/client_example_test.go | 4 ++- mcp/cmd_test.go | 33 +++++++++++------------- mcp/mcp_test.go | 25 ++++++------------ mcp/server.go | 1 + mcp/streamable_example_test.go | 3 ++- mcp/streamable_test.go | 46 +++++++++++++++++++++++----------- mcp/transport.go | 9 ++++++- mcp/transport_example_test.go | 2 +- mcp/transport_test.go | 3 ++- 9 files changed, 72 insertions(+), 54 deletions(-) diff --git a/mcp/client_example_test.go b/mcp/client_example_test.go index 225daba..cc7146c 100644 --- a/mcp/client_example_test.go +++ b/mcp/client_example_test.go @@ -42,9 +42,11 @@ func Example_roots() { // Connect the server and client... t1, t2 := mcp.NewInMemoryTransports() - if _, err := s.Connect(ctx, t1, nil); err != nil { + serverSession, err := s.Connect(ctx, t1, nil) + if err != nil { log.Fatal(err) } + defer serverSession.Close() clientSession, err := c.Connect(ctx, t2, nil) if err != nil { diff --git a/mcp/cmd_test.go b/mcp/cmd_test.go index cbaadcb..ebf6b59 100644 --- a/mcp/cmd_test.go +++ b/mcp/cmd_test.go @@ -97,6 +97,8 @@ func TestServerRunContextCancel(t *testing.T) { if err != nil { t.Fatal(err) } + t.Cleanup(func() { session.Close() }) + if err := session.Ping(context.Background(), nil); err != nil { t.Fatal(err) } @@ -122,35 +124,30 @@ func TestServerInterrupt(t *testing.T) { } requireExec(t) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - + t.Log("Starting server command") cmd := createServerCommand(t, "default") client := mcp.NewClient(testImpl, nil) - _, err := client.Connect(ctx, &mcp.CommandTransport{Command: cmd}, nil) + t.Log("Connecting to server") + + ctx := context.Background() + session, err := client.Connect(ctx, &mcp.CommandTransport{Command: cmd}, nil) if err != nil { t.Fatal(err) } - // get a signal when the server process exits - onExit := make(chan struct{}) - go func() { - cmd.Process.Wait() - close(onExit) - }() - - // send a signal to the server process to terminate it + t.Log("Send a signal to the server process to terminate it") if err := cmd.Process.Signal(os.Interrupt); err != nil { t.Fatal(err) } - // wait for the server to exit - // TODO: use synctest when available - select { - case <-time.After(5 * time.Second): - t.Fatal("server did not exit after SIGINT") - case <-onExit: + t.Log("Closing client session so server can exit immediately") + session.Close() + + t.Log("Wait for process to terminate after interrupt signal") + _, err = cmd.Process.Wait() + if err == nil { + t.Errorf("unexpected error: %v", err) } } diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 0e1ee1f..a78c152 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -118,16 +118,6 @@ func TestEndToEnd(t *testing.T) { t.Errorf("after connection, Clients() has length %d, want 1", len(got)) } - // Wait for the server to exit after the client closes its connection. - var clientWG sync.WaitGroup - clientWG.Add(1) - go func() { - if err := ss.Wait(); err != nil { - t.Errorf("server failed: %v", err) - } - clientWG.Done() - }() - loggingMessages := make(chan *LoggingMessageParams, 100) // big enough for all logging opts := &ClientOptions{ CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) { @@ -518,7 +508,9 @@ func TestEndToEnd(t *testing.T) { // Disconnect. cs.Close() - clientWG.Wait() + if err := ss.Wait(); err != nil { + t.Errorf("server failed: %v", err) + } // After disconnecting, neither client nor server should have any // connections. @@ -626,6 +618,7 @@ func basicClientServerConnection(t *testing.T, client *Client, server *Server, c if err != nil { t.Fatal(err) } + t.Cleanup(func() { _ = ss.Close() }) if client == nil { client = NewClient(testImpl, nil) @@ -634,6 +627,8 @@ func basicClientServerConnection(t *testing.T, client *Client, server *Server, c if err != nil { t.Fatal(err) } + t.Cleanup(func() { _ = cs.Close() }) + return cs, ss, func() { cs.Close() ss.Wait() @@ -750,11 +745,7 @@ func TestMiddleware(t *testing.T) { t.Fatal(err) } // Wait for the server to exit after the client closes its connection. - defer func() { - if err := ss.Wait(); err != nil { - t.Errorf("server failed: %v", err) - } - }() + t.Cleanup(func() { _ = ss.Close() }) var sbuf, cbuf bytes.Buffer sbuf.WriteByte('\n') @@ -773,7 +764,7 @@ func TestMiddleware(t *testing.T) { if err != nil { t.Fatal(err) } - defer cs.Close() + t.Cleanup(func() { _ = cs.Close() }) if _, err := cs.ListTools(ctx, nil); err != nil { t.Fatal(err) diff --git a/mcp/server.go b/mcp/server.go index 4f7ce75..17b68c4 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -778,6 +778,7 @@ func (s *Server) Run(ctx context.Context, t Transport) error { select { case <-ctx.Done(): ss.Close() + <-ssClosed // wait until waiting go routine above actually completes s.opts.Logger.Error("server run cancelled", "error", ctx.Err()) return ctx.Err() case err := <-ssClosed: diff --git a/mcp/streamable_example_test.go b/mcp/streamable_example_test.go index 430f274..f1cdf90 100644 --- a/mcp/streamable_example_test.go +++ b/mcp/streamable_example_test.go @@ -18,6 +18,7 @@ import ( // !+streamablehandler +// TODO: Until we have a way to clean up abandoned sessions, this test will leak goroutines (see #499) func ExampleStreamableHTTPHandler() { // Create a new streamable handler, using the same MCP server for every request. // @@ -45,7 +46,7 @@ func ExampleStreamableHTTPHandler_middleware() { server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.1.0"}, nil) handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { return server - }, nil) + }, &mcp.StreamableHTTPOptions{Stateless: true}) loggingHandler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { // Example debugging; you could also capture the response. body, err := io.ReadAll(req.Body) diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 6bbe8c4..3c0b8be 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -322,6 +322,8 @@ func TestStreamableServerShutdown(t *testing.T) { // network failure and receive replayed messages (if replay is configured). It // uses a proxy that is killed and restarted to simulate a recoverable network // outage. +// +// TODO: Until we have a way to clean up abandoned sessions, this test will leak goroutines (see #499) func TestClientReplay(t *testing.T) { for _, test := range []clientReplayTest{ {"default", 0, true}, @@ -369,7 +371,10 @@ func testClientReplay(t *testing.T, test clientReplayTest) { }) realServer := httptest.NewServer(mustNotPanic(t, NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil))) - defer realServer.Close() + t.Cleanup(func() { + t.Log("Closing real HTTP server") + realServer.Close() + }) realServerURL, err := url.Parse(realServer.URL) if err != nil { t.Fatalf("Failed to parse real server URL: %v", err) @@ -396,21 +401,20 @@ func testClientReplay(t *testing.T, test clientReplayTest) { if err != nil { t.Fatalf("client.Connect() failed: %v", err) } - defer clientSession.Close() + t.Cleanup(func() { + t.Log("Closing clientSession") + clientSession.Close() + }) - var ( - wg sync.WaitGroup - callErr error - ) - wg.Add(1) + toolCallResult := make(chan error, 1) go func() { - defer wg.Done() - _, callErr = clientSession.CallTool(ctx, &CallToolParams{Name: "multiMessageTool"}) + _, callErr := clientSession.CallTool(ctx, &CallToolParams{Name: "multiMessageTool"}) + toolCallResult <- callErr }() select { case <-serverReadyToKillProxy: - // Server has sent the first two messages and is paused. + t.Log("Server has sent the first two messages and is paused.") case <-ctx.Done(): t.Fatalf("Context timed out before server was ready to kill proxy") } @@ -438,9 +442,9 @@ func testClientReplay(t *testing.T, test clientReplayTest) { restartedProxy := &http.Server{Handler: proxyHandler} go restartedProxy.Serve(listener) - defer restartedProxy.Close() + t.Cleanup(func() { restartedProxy.Close() }) - wg.Wait() + callErr := <-toolCallResult if test.wantRecovered { // If we've recovered, we should get all 4 notifications and the tool call @@ -514,14 +518,15 @@ func TestServerTransportCleanup(t *testing.T) { if err != nil { t.Fatalf("client.Connect() failed: %v", err) } - defer clientSession.Close() + t.Cleanup(func() { _ = clientSession.Close() }) } for _, ch := range chans { select { case <-ctx.Done(): t.Errorf("did not capture transport deletion event from all session in 10 seconds") - case <-ch: // Received transport deletion signal of this session + case <-ch: + t.Log("Received session transport deletion signal") } } @@ -1307,6 +1312,7 @@ func TestStreamableStateless(t *testing.T) { if err != nil { t.Fatal(err) } + t.Cleanup(func() { cs.Close() }) res, err := cs.CallTool(ctx, &CallToolParams{Name: "greet", Arguments: hiParams{Name: "bar"}}) if err != nil { t.Fatal(err) @@ -1485,6 +1491,18 @@ func TestStreamableGET(t *testing.T) { if got, want := resp.StatusCode, http.StatusOK; got != want { t.Errorf("GET with session ID: got status %d, want %d", got, want) } + + t.Log("Sending final DELETE request to close session and release resources") + del := newReq("DELETE", nil) + del.Header.Set(sessionIDHeader, sessionID) + resp, err = http.DefaultClient.Do(del) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if got, want := resp.StatusCode, http.StatusNoContent; got != want { + t.Errorf("DELETE with session ID: got status %d, want %d", got, want) + } } func TestStreamableClientContextPropagation(t *testing.T) { diff --git a/mcp/transport.go b/mcp/transport.go index f2f93f4..1beab47 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -310,7 +310,14 @@ func (r rwc) Write(p []byte) (n int, err error) { } func (r rwc) Close() error { - return errors.Join(r.rc.Close(), r.wc.Close()) + rcErr := r.rc.Close() + + var wcErr error + if r.wc != nil { // we only allow a nil writer in unit tests + wcErr = r.wc.Close() + } + + return errors.Join(rcErr, wcErr) } // An ioConn is a transport that delimits messages with newlines across diff --git a/mcp/transport_example_test.go b/mcp/transport_example_test.go index ab54a42..7390ea4 100644 --- a/mcp/transport_example_test.go +++ b/mcp/transport_example_test.go @@ -28,7 +28,7 @@ func ExampleLoggingTransport() { if err != nil { log.Fatal(err) } - defer serverSession.Wait() + defer serverSession.Close() client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) var b bytes.Buffer diff --git a/mcp/transport_test.go b/mcp/transport_test.go index 10804a8..aeff366 100644 --- a/mcp/transport_test.go +++ b/mcp/transport_test.go @@ -25,7 +25,7 @@ func TestBatchFraming(t *testing.T) { r, w := io.Pipe() tport := newIOConn(rwc{r, w}) tport.outgoingBatch = make([]jsonrpc.Message, 0, 2) - defer tport.Close() + t.Cleanup(func() { tport.Close() }) // Read the two messages into a channel, for easy testing later. read := make(chan jsonrpc.Message) @@ -101,6 +101,7 @@ func TestIOConnRead(t *testing.T) { tr := newIOConn(rwc{ rc: io.NopCloser(strings.NewReader(tt.input)), }) + t.Cleanup(func() { tr.Close() }) if tt.protocolVersion != "" { tr.sessionUpdated(ServerSessionState{ InitializeParams: &InitializeParams{ From 104d39c6099e0754b73c30084b7053d642713e86 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Thu, 16 Oct 2025 12:11:49 -0400 Subject: [PATCH 107/418] mcp: allow disabling stream replay; disable by default (#585) This CL implements two changes that must be logically connected. The first is to provide a mechanism for disabling stream replay. Previously, our documentation stated that if StreamableServerTransport.EventStore was nil, a default in-memory event store would be used. In that case, there would be no way to completely disable stream replay, and as described in #576, there are many cases where replay is undesirable. But of course changing the behavior of the transport zero value implicitly affects its default behavior, and so this CL also implements the proposal #580: stream replay is disabled by default. Implementing this change required a significant refactoring, as previously we were relying on the event store for serialized message delivery from the JSON-RPC layer to the MCP layer: the connection would write to the event store, and independently the stream (be it an incoming POST or replay GET) would iterate and serve messages in the stream. In order to achieve the goals of this CL, it was necessary to decouple storage from delivery. The 'stream' abstraction now tracks a delivery callback that writes straight to the HTTP response. It would have been convenient to store the ongoing http.ResponseWriter directly in the stream (this is how typescript does it), but due to the design of our EventStore API, only the HTTP handler knows the next event index, so a 'deliver' abstraction was an unfortunate requirement (suggestions for how to further simplify this are welcome). More simplification is possible: in particular, as a result of this refactoring it should be entirely possible to clean up streams once we've received all responses. Any replay would only need access to the EventStore, if at all. This is left to a follow-up CL, to limit this already significant change. Furthermore, a nice consequence of this refactoring is that, when not using event storage, servers can get synchronous feedback that message delivery failed, which should avoid unnecessary work. We can additionally cancel ongoing requests on early client termination, but that is also left to a follow-up CL. Throughout, the terminology 'standalone SSE stream' replaced 'hanging GET' when referring to the non-replay GET request issued by the client. This is consistent with other SDKs. Fixes #576 Updates #580 --- mcp/event.go | 10 +- mcp/streamable.go | 699 +++++++++++++++++++--------------- mcp/streamable_client_test.go | 2 +- mcp/streamable_test.go | 56 ++- mcp/transport.go | 2 +- 5 files changed, 441 insertions(+), 328 deletions(-) diff --git a/mcp/event.go b/mcp/event.go index 1dd36f4..281f592 100644 --- a/mcp/event.go +++ b/mcp/event.go @@ -153,11 +153,9 @@ func scanEvents(r io.Reader) iter.Seq2[Event, error] { // // All of an EventStore's methods must be safe for use by multiple goroutines. type EventStore interface { - // Open prepares the event store for a given stream. It ensures that the - // underlying data structure for the stream is initialized, making it - // ready to store event streams. - // - // streamIDs must be globally unique. + // Open is called when a new stream is created. It may be used to ensure that + // the underlying data structure for the stream is initialized, making it + // ready to store and replay event streams. Open(_ context.Context, sessionID, streamID string) error // Append appends data for an outgoing event to given stream, which is part of the @@ -166,6 +164,7 @@ type EventStore interface { // After returns an iterator over the data for the given session and stream, beginning // just after the given index. + // // Once the iterator yields a non-nil error, it will stop. // After's iterator must return an error immediately if any data after index was // dropped; it must not return partial results. @@ -174,6 +173,7 @@ type EventStore interface { // SessionClosed informs the store that the given session is finished, along // with all of its streams. + // // A store cannot rely on this method being called for cleanup. It should institute // additional mechanisms, such as timeouts, to reclaim storage. SessionClosed(_ context.Context, sessionID string) error diff --git a/mcp/streamable.go b/mcp/streamable.go index 20eb13d..b58e20b 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -11,7 +11,6 @@ import ( "errors" "fmt" "io" - "iter" "log/slog" "math" "math/rand/v2" @@ -72,6 +71,9 @@ type StreamableHTTPOptions struct { // Logger specifies the logger to use. // If nil, do not log. Logger *slog.Logger + + // TODO(rfindley): file a proposal to export this option, or something equivalent. + configureTransport func(req *http.Request, transport *StreamableServerTransport) } // NewStreamableHTTPHandler returns a new [StreamableHTTPHandler]. @@ -238,6 +240,9 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque jsonResponse: h.opts.JSONResponse, logger: h.opts.Logger, } + if h.opts.configureTransport != nil { + h.opts.configureTransport(req, transport) + } // To support stateless mode, we initialize the session with a default // state, so that it doesn't reject subsequent requests. @@ -335,16 +340,16 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque // // Reads from the streamable server connection receive messages from http POST // requests from the client. Writes to the streamable server connection are -// sent either to the hanging POST response, or to the hanging GET, according -// to the following rules: +// sent either to the related stream, or to the standalone SSE stream, +// according to the following rules: // - JSON-RPC responses to incoming requests are always routed to the // appropriate HTTP response. // - Requests or notifications made with a context.Context value derived from // an incoming request handler, are routed to the HTTP response // corresponding to that request, unless it has already terminated, in -// which case they are routed to the hanging GET. +// which case they are routed to the standalone SSE stream. // - Requests or notifications made with a detached context.Context value are -// routed to the hanging GET. +// routed to the standalone SSE stream. type StreamableServerTransport struct { // SessionID is the ID of this session. // @@ -372,15 +377,16 @@ type StreamableServerTransport struct { // Specifically, responses will be application/json whenever incoming POST // request contain only a single message. In this case, notifications or // requests made within the context of a server request will be sent to the - // hanging GET request, if any. + // standalone SSE stream, if any. // // TODO(rfindley): jsonResponse should be exported, since - // StreamableHTTPOptions.JSONResponse is exported. + // StreamableHTTPOptions.JSONResponse is exported, and we want to allow users + // to write their own streamable HTTP handler. jsonResponse bool // optional logger provided through the [StreamableHTTPOptions.Logger]. // - // TODO(rfindley): logger should be exported, since we want to allow people + // TODO(rfindley): logger should be exported, since we want to allow users // to write their own streamable HTTP handler. logger *slog.Logger @@ -404,15 +410,12 @@ func (t *StreamableServerTransport) Connect(ctx context.Context) (Connection, er streams: make(map[string]*stream), requestStreams: make(map[jsonrpc.ID]string), } - if t.connection.eventStore == nil { - t.connection.eventStore = NewMemoryEventStore(nil) - } - // Stream 0 corresponds to the hanging 'GET'. + // Stream 0 corresponds to the standalone SSE stream. // // It is always text/event-stream, since it must carry arbitrarily many // messages. var err error - t.connection.streams[""], err = t.connection.newStream(ctx, "", false, false) + t.connection.streams[""], err = t.connection.newStream(ctx, nil, "") if err != nil { return nil, err } @@ -449,13 +452,18 @@ type streamableServerConn struct { // bound. If we deleted a stream when the response is sent, we would lose the ability // to replay if there was a cut just before the response was transmitted. // Perhaps we could have a TTL for streams that starts just after the response. + // + // TODO(rfindley): Once all responses have been received for a stream, we can + // remove it as it is no longer necessary, even if the client wants to replay + // messages. streams map[string]*stream // requestStreams maps incoming requests to their logical stream ID. // // Lifecycle: requestStreams persist for the duration of the session. // - // TODO: clean up once requests are handled. See the TODO for streams above. + // TODO(rfindley): clean up once requests are handled. See the TODO for + // streams above. requestStreams map[jsonrpc.ID]string } @@ -472,56 +480,43 @@ func (c *streamableServerConn) SessionID() string { // at any time. type stream struct { // id is the logical ID for the stream, unique within a session. - // an empty string is used for messages that don't correlate with an incoming request. + // + // The standalone SSE stream has id "". id string - // If isInitialize is set, the stream is in response to an initialize request, - // and therefore should include the session ID header. - isInitialize bool + // mu guards the fields below, as well as storage of new messages in the + // connection's event store (if any). + mu sync.Mutex - // jsonResponse records whether this stream should respond with application/json - // instead of text/event-stream. + // If non-nil, deliver writes data directly to the HTTP response. // - // See [StreamableServerTransportOptions.JSONResponse]. - jsonResponse bool + // Only one HTTP response may receive messages at a given time. An active + // HTTP connection acquires ownership of the stream by setting this field. + deliver func(data []byte, final bool) error - // signal is a 1-buffered channel, owned by an incoming HTTP request, that signals - // that there are messages available to write into the HTTP response. - // In addition, the presence of a channel guarantees that at most one HTTP response - // can receive messages for a logical stream. After claiming the stream, incoming - // requests should read from the event store, to ensure that no new messages are missed. + // streamRequests is the set of unanswered incoming requests for the stream. // - // To simplify locking, signal is an atomic. We need an atomic.Pointer, because - // you can't set an atomic.Value to nil. - // - // Lifecycle: each channel value persists for the duration of an HTTP POST or - // GET request for the given streamID. - signal atomic.Pointer[chan struct{}] - - // The following mutable fields are protected by the mutex of the containing - // StreamableServerTransport. - - // streamRequests is the set of unanswered incoming RPCs for the stream. - // - // Requests persist until their response data has been added to the event store. + // Requests are removed when their response has been received. requests map[jsonrpc.ID]struct{} } -func (c *streamableServerConn) newStream(ctx context.Context, id string, isInitialize, jsonResponse bool) (*stream, error) { - if err := c.eventStore.Open(ctx, c.sessionID, id); err != nil { - return nil, err - } - return &stream{ - id: id, - isInitialize: isInitialize, - jsonResponse: jsonResponse, - requests: make(map[jsonrpc.ID]struct{}), - }, nil +// doneLocked reports whether the stream is logically complete. +// +// s.mu must be held while calling this function. +func (s *stream) doneLocked() bool { + return len(s.requests) == 0 && s.id != "" } -func signalChanPtr() *chan struct{} { - c := make(chan struct{}, 1) - return &c +func (c *streamableServerConn) newStream(ctx context.Context, requests map[jsonrpc.ID]struct{}, id string) (*stream, error) { + if c.eventStore != nil { + if err := c.eventStore.Open(ctx, c.sessionID, id); err != nil { + return nil, err + } + } + return &stream{ + id: id, + requests: requests, + }, nil } // We track the incoming request ID inside the handler context using @@ -571,37 +566,157 @@ func (t *StreamableServerTransport) ServeHTTP(w http.ResponseWriter, req *http.R // // It returns an HTTP status code and error message. func (c *streamableServerConn) serveGET(w http.ResponseWriter, req *http.Request) { - // connID 0 corresponds to the default GET request. - id := "" + // streamID "" corresponds to the default GET request. + streamID := "" // By default, we haven't seen a last index. Since indices start at 0, we represent - // that by -1. This is incremented just before each event is written, in streamResponse - // around L407. + // that by -1. This is incremented just before each event is written. lastIdx := -1 if len(req.Header.Values("Last-Event-ID")) > 0 { eid := req.Header.Get("Last-Event-ID") var ok bool - id, lastIdx, ok = parseEventID(eid) + streamID, lastIdx, ok = parseEventID(eid) if !ok { http.Error(w, fmt.Sprintf("malformed Last-Event-ID %q", eid), http.StatusBadRequest) return } + if c.eventStore == nil { + http.Error(w, "stream replay unsupported", http.StatusBadRequest) + return + } } + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + + stream, done := c.acquireStream(ctx, w, streamID, &lastIdx) + if stream == nil { + return + } + // Release the stream when we're done. + defer func() { + stream.mu.Lock() + stream.deliver = nil + stream.mu.Unlock() + }() + + select { + case <-ctx.Done(): + // request cancelled + case <-done: + // request complete + case <-c.done: + // session closed + } +} + +// writeEvent writes an SSE event to w corresponding to the given stream, data, and index. +// lastIdx is incremented before writing, so that it continues to point to the index of the +// last event written to the stream. +func (c *streamableServerConn) writeEvent(w http.ResponseWriter, stream *stream, data []byte, lastIdx *int) error { + *lastIdx++ + e := Event{ + Name: "message", + Data: data, + } + if c.eventStore != nil { + e.ID = formatEventID(stream.id, *lastIdx) + } + if _, err := writeEvent(w, e); err != nil { + return err + } + return nil +} + +// acquireStream acquires the stream and replays all events since lastIdx, if +// any, updating lastIdx accordingly. If non-nil, the resulting stream will be +// registered for receiving new messages, and the resulting done channel will +// be closed when all related messages have been delivered. +// +// If any errors occur, they will be written to w and the resulting stream will +// be nil. The resulting stream may also be nil if the stream is complete. +// +// Importantly, this function must hold the stream mutex until done replaying +// all messages, so that no delivery or storage of new messages occurs while +// the stream is still replaying. +func (c *streamableServerConn) acquireStream(ctx context.Context, w http.ResponseWriter, streamID string, lastIdx *int) (*stream, chan struct{}) { c.mu.Lock() - stream, ok := c.streams[id] + stream, ok := c.streams[streamID] c.mu.Unlock() if !ok { http.Error(w, "unknown stream", http.StatusBadRequest) - return + return nil, nil } - if !stream.signal.CompareAndSwap(nil, signalChanPtr()) { - // The CAS returned false, meaning that the comparison failed: stream.signal is not nil. + + stream.mu.Lock() + defer stream.mu.Unlock() + if stream.deliver != nil { http.Error(w, "stream ID conflicts with ongoing stream", http.StatusConflict) - return + return nil, nil } - defer stream.signal.Store(nil) - persistent := id == "" // Only the special stream "" is a hanging get. - c.respondSSE(stream, w, req, lastIdx, persistent) + + // Collect events to replay. Collect them all before writing, so that we + // have an opportunity to set the HTTP status code on an error. + // + // As indicated above, we must do that while holding stream.mu, so that no + // new messages are added to the eventstore until we've replayed all previous + // messages, and registered our delivery function. + var toReplay [][]byte + if c.eventStore != nil { + for data, err := range c.eventStore.After(ctx, c.SessionID(), stream.id, *lastIdx) { + if err != nil { + // We can't replay events, perhaps because the underlying event store + // has garbage collected its storage. + // + // We must be careful here: any 404 will signal to the client that the + // *session* is not found, rather than the stream. + // + // 400 is not really accurate, but should at least have no side effects. + // Other SDKs (typescript) do not have a mechanism for events to be purged. + http.Error(w, "failed to replay events", http.StatusBadRequest) + return nil, nil + } + toReplay = append(toReplay, data) + } + } + + w.Header().Set("Cache-Control", "no-cache, no-transform") + w.Header().Set("Content-Type", "text/event-stream") // Accept checked in [StreamableHTTPHandler] + w.Header().Set("Connection", "keep-alive") + + if stream.id == "" { + // Issue #410: the standalone SSE stream is likely not to receive messages + // for a long time. Ensure that headers are flushed. + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + } + + for _, data := range toReplay { + if err := c.writeEvent(w, stream, data, lastIdx); err != nil { + return nil, nil + } + } + + if stream.doneLocked() { + // Nothing more to do. + return nil, nil + } + + // Finally register a delivery function and unlock the stream, allowing the + // connection to write new events. + done := make(chan struct{}) + stream.deliver = func(data []byte, final bool) error { + if err := ctx.Err(); err != nil { + return err + } + err := c.writeEvent(w, stream, data, lastIdx) + if final { + close(done) + } + return err + } + return stream, done } // servePOST handles an incoming message, and replies with either an outgoing @@ -641,7 +756,14 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques return } - requests := make(map[jsonrpc.ID]struct{}) + // TODO(rfindley): no tests fail if we reject batch JSON requests entirely. + // We need to test this with older protocol versions. + // if isBatch && c.jsonResponse { + // http.Error(w, "server does not support batch requests", http.StatusBadRequest) + // return + // } + + calls := make(map[jsonrpc.ID]struct{}) tokenInfo := auth.TokenInfoFromContext(req.Context()) isInitialize := false for _, msg := range incoming { @@ -661,204 +783,134 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques Header: req.Header, } if jreq.IsCall() { - requests[jreq.ID] = struct{}{} + calls[jreq.ID] = struct{}{} } } } - var stream *stream // if non-nil, used to handle requests - - // If we have requests, we need to handle responses along with any - // notifications or server->client requests made in the course of handling. - // Update accounting for this incoming payload. - if len(requests) > 0 { - stream, err = c.newStream(req.Context(), randText(), isInitialize, c.jsonResponse) - if err != nil { - http.Error(w, fmt.Sprintf("storing stream: %v", err), http.StatusInternalServerError) - return + // If we don't have any calls, we can just publish the incoming messages and return. + // No need to track a logical stream. + if len(calls) == 0 { + for _, msg := range incoming { + select { + case c.incoming <- msg: + case <-c.done: + // The session is closing. Since we haven't yet written any data to the + // response, we can signal to the client that the session is gone. + http.Error(w, "session is closing", http.StatusNotFound) + return + } } - c.mu.Lock() - c.streams[stream.id] = stream - stream.requests = requests - for reqID := range requests { - c.requestStreams[reqID] = stream.id - } - c.mu.Unlock() - stream.signal.Store(signalChanPtr()) - defer stream.signal.Store(nil) - } - - // Publish incoming messages. - for _, msg := range incoming { - c.incoming <- msg - } - - if stream == nil { w.WriteHeader(http.StatusAccepted) return } - if stream.jsonResponse { - c.respondJSON(stream, w, req) - } else { - c.respondSSE(stream, w, req, -1, false) + // Invariant: we have at least one call. + // + // Create a logical stream to track its responses. + // Important: don't publish the incoming messages until the stream is + // registered, as the server may attempt to respond to imcoming messages as + // soon as they're published. + stream, err := c.newStream(req.Context(), calls, randText()) + if err != nil { + http.Error(w, fmt.Sprintf("storing stream: %v", err), http.StatusInternalServerError) + return } -} -func (c *streamableServerConn) respondJSON(stream *stream, w http.ResponseWriter, req *http.Request) { + // Set response headers. Accept was checked in [StreamableHTTPHandler]. w.Header().Set("Cache-Control", "no-cache, no-transform") - w.Header().Set("Content-Type", "application/json") - if c.sessionID != "" && stream.isInitialize { + if c.jsonResponse { + w.Header().Set("Content-Type", "application/json") + } else { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Connection", "keep-alive") + } + if c.sessionID != "" && isInitialize { w.Header().Set(sessionIDHeader, c.sessionID) } - var msgs []json.RawMessage - ctx := req.Context() - for msg, err := range c.messages(ctx, stream, false, -1) { - if err != nil { - if ctx.Err() != nil { - w.WriteHeader(http.StatusNoContent) - return - } else { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return + // Message delivery has two paths, depending on whether we're responding with JSON or + // event stream. + done := make(chan struct{}) // closed after the final response is written + if c.jsonResponse { + var msgs []json.RawMessage + stream.deliver = func(data []byte, final bool) error { + // Collect messages until we've received the final response. + // + // In recent protocol versions, there should only be one message as + // batching is disabled, as checked above. + msgs = append(msgs, data) + if !final { + return nil } - } - msgs = append(msgs, msg) - } - var data []byte - if len(msgs) == 1 { - data = []byte(msgs[0]) - } else { - // TODO: add tests for batch responses, or disallow them entirely. - var err error - data, err = json.Marshal(msgs) - if err != nil { - http.Error(w, fmt.Sprintf("internal error marshalling response: %v", err), http.StatusInternalServerError) - return - } - } - _, _ = w.Write(data) // ignore error: client disconnected -} + defer close(done) // final response -// lastIndex is the index of the last seen event if resuming, else -1. -func (c *streamableServerConn) respondSSE(stream *stream, w http.ResponseWriter, req *http.Request, lastIndex int, persistent bool) { - // Accept was checked in [StreamableHTTPHandler] - w.Header().Set("Cache-Control", "no-cache, no-transform") - w.Header().Set("Content-Type", "text/event-stream") // Accept checked in [StreamableHTTPHandler] - w.Header().Set("Connection", "keep-alive") - if c.sessionID != "" && stream.isInitialize { - w.Header().Set(sessionIDHeader, c.sessionID) - } - if persistent { - // Issue #410: the hanging GET is likely not to receive messages for a long - // time. Ensure that headers are flushed. - // - // For non-persistent requests, delay the writing of the header in case we - // may want to set an error status. - // (see the TODO: this probably isn't worth it). - w.WriteHeader(http.StatusOK) - if f, ok := w.(http.Flusher); ok { - f.Flush() - } - } - - // write one event containing data. - writes := 0 - write := func(data []byte) bool { - lastIndex++ - e := Event{ - Name: "message", - ID: formatEventID(stream.id, lastIndex), - Data: data, - } - if _, err := writeEvent(w, e); err != nil { - // Connection closed or broken. - c.logger.Warn("error writing event", "error", err) - return false - } - writes++ - return true - } - - // Repeatedly collect pending outgoing events and send them. - ctx := req.Context() - for msg, err := range c.messages(ctx, stream, persistent, lastIndex) { - if err != nil { - if ctx.Err() == nil && writes == 0 && !persistent { - // If we haven't yet written the header, we have an opportunity to - // promote an error to an HTTP error. - // - // TODO: This may not matter in practice, in which case we should - // simplify. - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + // Write either the JSON object corresponding to the one response, or a + // JSON array corresponding to the batch response. + var toWrite []byte + if len(msgs) == 1 { + toWrite = []byte(msgs[0]) } else { - if ctx.Err() != nil { - // Client disconnected or cancelled the request. - c.logger.Error("stream context done", "error", ctx.Err()) - } else { - // Some other error. - c.logger.Error("error receiving message", "error", err) - } - } - return - } - if !write(msg) { - return - } - } -} - -// messages iterates over messages sent to the current stream. -// -// persistent indicates if it is the main GET listener, which should never be -// terminated. -// lastIndex is the index of the last seen event, iteration begins at lastIndex+1. -// -// The first iterated value is the received JSON message. The second iterated -// value is an error value indicating whether the stream terminated normally. -// Iteration ends at the first non-nil error. -// -// If the stream did not terminate normally, it is either because ctx was -// cancelled, or the connection is closed: check the ctx.Err() to differentiate -// these cases. -func (c *streamableServerConn) messages(ctx context.Context, stream *stream, persistent bool, lastIndex int) iter.Seq2[json.RawMessage, error] { - return func(yield func(json.RawMessage, error) bool) { - for { - c.mu.Lock() - nOutstanding := len(stream.requests) - c.mu.Unlock() - for data, err := range c.eventStore.After(ctx, c.SessionID(), stream.id, lastIndex) { + var err error + toWrite, err = json.Marshal(msgs) if err != nil { - yield(nil, err) - return + return err } - if !yield(data, nil) { - return - } - lastIndex++ - } - // If all requests have been handled and replied to, we should terminate this connection. - // "After the JSON-RPC response has been sent, the server SHOULD close the SSE stream." - // §6.4, https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#sending-messages-to-the-server - // We only want to terminate POSTs, and GETs that are replaying. The general-purpose GET - // (stream ID 0) will never have requests, and should remain open indefinitely. - if nOutstanding == 0 && !persistent { - return - } - - select { - case <-*stream.signal.Load(): // there are new outgoing messages - // return to top of loop - case <-c.done: // session is closed - yield(nil, errors.New("session is closed")) - return - case <-ctx.Done(): - yield(nil, ctx.Err()) - return } + _, err = w.Write(toWrite) + return err } + } else { + // Write events in the order we receive them. + lastIndex := -1 + stream.deliver = func(data []byte, final bool) error { + if final { + defer close(done) + } + return c.writeEvent(w, stream, data, &lastIndex) + } + } + // Release ownership of the stream by unsetting deliver. + defer func() { + stream.mu.Lock() + // TODO(rfindley): if we have no event store, we should really cancel all + // remaining requests here, since the client will never get the results. + stream.deliver = nil + stream.mu.Unlock() + }() + + // The stream is now set up to deliver messages. + // + // Register it before publishing incoming messages. + c.mu.Lock() + c.streams[stream.id] = stream + for reqID := range calls { + c.requestStreams[reqID] = stream.id + } + c.mu.Unlock() + + // Publish incoming messages. + for _, msg := range incoming { + select { + case c.incoming <- msg: + // Note: don't select on req.Context().Done() here, since we've already + // received the requests and may have already published a response message + // or notification. The client could resume the stream. + case <-c.done: + // Session closed: we don't know if any data has been written, so it's + // too late to write a status code here. + return + } + } + + select { + case <-req.Context().Done(): + // request cancelled + case <-done: + // request complete + case <-c.done: + // session is closed } } @@ -907,80 +959,93 @@ func (c *streamableServerConn) Read(ctx context.Context) (jsonrpc.Message, error // Write implements the [Connection] interface. func (c *streamableServerConn) Write(ctx context.Context, msg jsonrpc.Message) error { - if req, ok := msg.(*jsonrpc.Request); ok && req.ID.IsValid() && (c.stateless || c.sessionID == "") { - // Requests aren't possible with stateless servers, or when there's no session ID. - return fmt.Errorf("%w: stateless servers cannot make requests", jsonrpc2.ErrRejected) - } - // Find the incoming request that this write relates to, if any. - var forRequest jsonrpc.ID - isResponse := false - if resp, ok := msg.(*jsonrpc.Response); ok { - // If the message is a response, it relates to its request (of course). - forRequest = resp.ID - isResponse = true - } else { - // Otherwise, we check to see if it request was made in the context of an - // ongoing request. This may not be the case if the request was made with - // an unrelated context. - if v := ctx.Value(idContextKey{}); v != nil { - forRequest = v.(jsonrpc.ID) - } - } - - // Find the logical connection corresponding to this request. + // Throughout this function, note that any error that wraps ErrRejected + // indicates a does not cause the connection to break. // - // For messages sent outside of a request context, this is the default - // connection "". - var forStream string - if forRequest.IsValid() { - c.mu.Lock() - forStream = c.requestStreams[forRequest] - c.mu.Unlock() - } - + // Most errors don't break the connection: unlike a true bidirectional + // stream, a failure to deliver to a stream is not an indication that the + // logical session is broken. data, err := jsonrpc2.EncodeMessage(msg) if err != nil { return err } + if req, ok := msg.(*jsonrpc.Request); ok && req.ID.IsValid() && (c.stateless || c.sessionID == "") { + // Requests aren't possible with stateless servers, or when there's no session ID. + return fmt.Errorf("%w: stateless servers cannot make requests", jsonrpc2.ErrRejected) + } + + // Find the incoming request that this write relates to, if any. + var ( + relatedRequest jsonrpc.ID + responseTo jsonrpc.ID // if valid, the message is a response to this request + ) + if resp, ok := msg.(*jsonrpc.Response); ok { + // If the message is a response, it relates to its request (of course). + relatedRequest = resp.ID + responseTo = resp.ID + } else { + // Otherwise, we check to see if it request was made in the context of an + // ongoing request. This may not be the case if the request was made with + // an unrelated context. + if v := ctx.Value(idContextKey{}); v != nil { + relatedRequest = v.(jsonrpc.ID) + } + } + + // If the stream is application/json, but the message is not a response, we + // must send it out of band to the standalone SSE stream. + if c.jsonResponse && !responseTo.IsValid() { + relatedRequest = jsonrpc.ID{} + } + + // Write the message to the stream. + var s *stream c.mu.Lock() - defer c.mu.Unlock() - if c.isDone { + if relatedRequest.IsValid() { + if streamID, ok := c.requestStreams[relatedRequest]; ok { + s = c.streams[streamID] + } + } else { + s = c.streams[""] + } + sessionClosed := c.isDone + c.mu.Unlock() + + if s == nil { + return fmt.Errorf("%w: no stream for request", jsonrpc2.ErrRejected) + } + if sessionClosed { return errors.New("session is closed") } - stream := c.streams[forStream] - if stream == nil { - return fmt.Errorf("no stream with ID %s", forStream) + s.mu.Lock() + defer s.mu.Unlock() + if s.doneLocked() { + return fmt.Errorf("%w: write to closed stream", jsonrpc2.ErrRejected) + } + if responseTo.IsValid() { + delete(s.requests, responseTo) } - // Special case a few conditions where we fall back on stream 0 (the hanging GET): - // - // - if forStream is known, but the associated stream is logically complete - // - if the stream is application/json, but the message is not a response - // - // TODO(rfindley): either of these, particularly the first, might be - // considered a bug in the server. Report it through a side-channel? - if len(stream.requests) == 0 && forStream != "" || stream.jsonResponse && !isResponse { - stream = c.streams[""] - } - - if err := c.eventStore.Append(ctx, c.SessionID(), stream.id, data); err != nil { - return fmt.Errorf("error storing event: %w", err) - } - if isResponse { - // Once we've put the reply on the queue, it's no longer outstanding. - delete(stream.requests, forRequest) - } - - // Signal streamResponse that new work is available. - signalp := stream.signal.Load() - if signalp != nil { - select { - case *signalp <- struct{}{}: - default: + delivered := false + if c.eventStore != nil { + if err := c.eventStore.Append(ctx, c.sessionID, s.id, data); err != nil { + // TODO: report a side-channel error. + } else { + delivered = true } } + if s.deliver != nil { + if err := s.deliver(data, s.doneLocked()); err != nil { + // TODO: report a side-channel error. + } else { + delivered = true + } + } + if !delivered { + return fmt.Errorf("%w: undelivered message", jsonrpc2.ErrRejected) + } return nil } @@ -991,9 +1056,11 @@ func (c *streamableServerConn) Close() error { if !c.isDone { c.isDone = true close(c.done) - // TODO: find a way to plumb a context here, or an event store with a long-running - // close operation can take arbitrary time. Alternative: impose a fixed timeout here. - return c.eventStore.SessionClosed(context.TODO(), c.sessionID) + if c.eventStore != nil { + // TODO: find a way to plumb a context here, or an event store with a long-running + // close operation can take arbitrary time. Alternative: impose a fixed timeout here. + return c.eventStore.SessionClosed(context.TODO(), c.sessionID) + } } return nil } @@ -1118,7 +1185,7 @@ func (c *streamableClientConn) sessionUpdated(state clientSessionState) { c.initializedResult = state.InitializeResult c.mu.Unlock() - // Start the persistent SSE listener as soon as we have the initialized + // Start the standalone SSE stream as soon as we have the initialized // result. // // § 2.2: The client MAY issue an HTTP GET to the MCP endpoint. This can be @@ -1131,7 +1198,7 @@ func (c *streamableClientConn) sessionUpdated(state clientSessionState) { // § 2.5: A server using the Streamable HTTP transport MAY assign a session // ID at initialization time, by including it in an Mcp-Session-Id header // on the HTTP response containing the InitializeResult. - go c.handleSSE("hanging GET", nil, true, nil) + go c.handleSSE("standalone SSE stream", nil, true, nil) } // fail handles an asynchronous error while reading. @@ -1272,6 +1339,7 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e case "text/event-stream": jsonReq, _ := msg.(*jsonrpc.Request) + // TODO: should we cancel this logical SSE request if/when jsonReq is canceled? go c.handleSSE(requestSummary, resp, false, jsonReq) default: @@ -1328,6 +1396,11 @@ func (c *streamableClientConn) handleSSE(requestSummary string, initialResp *htt resp := initialResp var lastEventID string for { + // TODO: we should set a reasonable limit on the number of times we'll try + // getting a response for a given request. + // + // Eventually, if we don't get the response, we should stop trying and + // fail the request. if resp != nil { eventID, clientClosed := c.processStream(requestSummary, resp, forReq) lastEventID = eventID @@ -1359,17 +1432,17 @@ func (c *streamableClientConn) handleSSE(requestSummary string, initialResp *htt // // [§2.2.3]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#listening-for-messages-from-the-server - // The server doesn't support the hanging GET. + // The server doesn't support the standalone SSE stream. resp.Body.Close() return } if resp.StatusCode == http.StatusNotFound && persistent && !c.strict { // modelcontextprotocol/gosdk#393: some servers return NotFound instead - // of MethodNotAllowed for the persistent GET. + // of MethodNotAllowed for the standalone SSE stream. // // Treat this like MethodNotAllowed in non-strict mode. if c.logger != nil { - c.logger.Warn("got 404 instead of 405 for hanging GET") + c.logger.Warn("got 404 instead of 405 for standalonw SSE stream") } resp.Body.Close() return @@ -1412,8 +1485,8 @@ func (c *streamableClientConn) processStream(requestSummary string, resp *http.R select { case c.incoming <- msg: if jsonResp, ok := msg.(*jsonrpc.Response); ok && forReq != nil { - // TODO: we should never get a response when forReq is nil (the hanging GET). - // We should detect this case, and eliminate the 'persistent' flag arguments. + // TODO: we should never get a response when forReq is nil (the standalone SSE request). + // We should detect this case. if jsonResp.ID == forReq.ID { return "", true } @@ -1433,7 +1506,7 @@ func (c *streamableClientConn) processStream(requestSummary string, resp *http.R func (c *streamableClientConn) reconnect(lastEventID string) (*http.Response, error) { var finalErr error - // We can reach the 'reconnect' path through the hanging GET, in which case + // We can reach the 'reconnect' path through the standlone SSE request, in which case // lastEventID will be "". // // In this case, we need an initial attempt. diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 9116677..faca04c 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -246,7 +246,7 @@ func TestStreamableClientGETHandling(t *testing.T) { }{ {http.StatusOK, ""}, {http.StatusMethodNotAllowed, ""}, - {http.StatusBadRequest, "hanging GET"}, + {http.StatusBadRequest, "standalone SSE"}, } for _, test := range tests { diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 3c0b8be..a089368 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -41,8 +41,18 @@ func TestStreamableTransports(t *testing.T) { ctx := context.Background() - for _, useJSON := range []bool{false, true} { - t.Run(fmt.Sprintf("JSONResponse=%v", useJSON), func(t *testing.T) { + tests := []struct { + useJSON bool + replay bool + }{ + {false, false}, + {false, true}, + {true, false}, + {true, true}, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("JSONResponse=%v;replay=%v", test.useJSON, test.replay), func(t *testing.T) { // Create a server with some simple tools. server := NewServer(testImpl, nil) AddTool(server, &Tool{Name: "greet", Description: "say hi"}, sayHi) @@ -86,7 +96,12 @@ func TestStreamableTransports(t *testing.T) { // Start an httptest.Server with the StreamableHTTPHandler, wrapped in a // cookie-checking middleware. handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{ - JSONResponse: useJSON, + JSONResponse: test.useJSON, + configureTransport: func(_ *http.Request, transport *StreamableServerTransport) { + if test.replay { + transport.EventStore = NewMemoryEventStore(nil) + } + }, }) var ( @@ -370,7 +385,11 @@ func testClientReplay(t *testing.T, test clientReplayTest) { return new(CallToolResult), nil, nil }) - realServer := httptest.NewServer(mustNotPanic(t, NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil))) + realServer := httptest.NewServer(mustNotPanic(t, NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{ + configureTransport: func(_ *http.Request, t *StreamableServerTransport) { + t.EventStore = NewMemoryEventStore(nil) // necessary for replay + }, + }))) t.Cleanup(func() { t.Log("Closing real HTTP server") realServer.Close() @@ -543,7 +562,16 @@ func TestServerInitiatedSSE(t *testing.T) { notifications := make(chan string) server := NewServer(testImpl, nil) - httpServer := httptest.NewServer(mustNotPanic(t, NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil))) + opts := &StreamableHTTPOptions{ + // TODO(#583): for now, this is required for guaranteed message delivery. + // However, it shouldn't be necessary to use replay here, as we should be + // guaranteed that the standalone SSE stream is started by the time the + // client is connected. + configureTransport: func(_ *http.Request, transport *StreamableServerTransport) { + transport.EventStore = NewMemoryEventStore(nil) + }, + } + httpServer := httptest.NewServer(mustNotPanic(t, NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, opts))) defer httpServer.Close() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -640,6 +668,7 @@ func TestStreamableServerTransport(t *testing.T) { tests := []struct { name string + replay bool // if set, use a MemoryEventStore to enable stream replay tool func(*testing.T, context.Context, *ServerSession) requests []streamableRequest // http requests }{ @@ -804,10 +833,15 @@ func TestStreamableServerTransport(t *testing.T) { }, { name: "background", + // Enabling replay is necessary here because the standalone "GET" request + // is fully asynronous. Replay is needed to guarantee message delivery. + replay: true, tool: func(t *testing.T, _ context.Context, ss *ServerSession) { // Perform operations on a background context, and ensure the client // receives it. - ctx := context.Background() + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + if err := ss.NotifyProgress(ctx, &ProgressNotificationParams{}); err != nil { t.Errorf("Notify failed: %v", err) } @@ -816,7 +850,7 @@ func TestStreamableServerTransport(t *testing.T) { // t.Errorf("Logging failed: %v", err) // } if _, err := ss.ListRoots(ctx, &ListRootsParams{}); err != nil { - t.Errorf("Notify failed: %v", err) + t.Errorf("ListRoots failed: %v", err) } }, requests: []streamableRequest{ @@ -906,8 +940,14 @@ func TestStreamableServerTransport(t *testing.T) { return &CallToolResult{}, nil }) + opts := &StreamableHTTPOptions{} + if test.replay { + opts.configureTransport = func(_ *http.Request, t *StreamableServerTransport) { + t.EventStore = NewMemoryEventStore(nil) + } + } // Start the streamable handler. - handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, opts) defer handler.closeAll() testStreamableHandler(t, handler, test.requests) diff --git a/mcp/transport.go b/mcp/transport.go index 1beab47..cacd65f 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -46,7 +46,7 @@ type Connection interface { // Write writes a new message to the connection. // - // Write may be called concurrently, as calls or reponses may occur + // Write may be called concurrently, as calls or responses may occur // concurrently in user code. Write(context.Context, jsonrpc.Message) error From 8fe64fc3898ae2ec411ac4ab2f3fd8318507369f Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Thu, 16 Oct 2025 13:48:39 -0400 Subject: [PATCH 108/418] mcp: fix stale comment on StreamableServerTransport.EventStore (#588) This was missed in #585. Updates #580 --- mcp/streamable.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index b58e20b..7204e2b 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -367,8 +367,10 @@ type StreamableServerTransport struct { // See also [StreamableHTTPOptions.Stateless]. Stateless bool - // Storage for events, to enable stream resumption. - // If nil, a [MemoryEventStore] with the default maximum size will be used. + // EventStore enables stream resumption. + // + // If set, EventStore will be used to persist stream events and replay them + // upon stream resumption. EventStore EventStore // jsonResponse, if set, tells the server to prefer to respond to requests From 8aee8d39bd5894a5314c900657f1a530adfa5255 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Mon, 20 Oct 2025 14:57:56 -0400 Subject: [PATCH 109/418] mcp: clean up stream metadata after completion (#592) Address longstanding TODOs to clean up stream metadata after streams are complete: - Interpret a missing stream as a complete stream. - Remove the request->stream mapping when the request completes. Any writes after that point will fail as there can be no recipient. Tested using examples/client/loadtest, and updating TestStreamableTransports to check in-band and out-of-band behavior. --- examples/client/loadtest/main.go | 5 +- mcp/streamable.go | 102 ++++++++++++++++++++++--------- mcp/streamable_test.go | 58 ++++++++++++++---- 3 files changed, 122 insertions(+), 43 deletions(-) diff --git a/examples/client/loadtest/main.go b/examples/client/loadtest/main.go index d5a04c2..cbf7cd6 100644 --- a/examples/client/loadtest/main.go +++ b/examples/client/loadtest/main.go @@ -34,6 +34,7 @@ var ( timeout = flag.Duration("timeout", 1*time.Second, "request timeout") qps = flag.Int("qps", 100, "tool calls per second, per worker") verbose = flag.Bool("v", false, "if set, enable verbose logging") + cleanup = flag.Bool("cleanup", true, "whether to clean up sessions at the end of the test") ) func main() { @@ -76,7 +77,9 @@ func main() { if err != nil { log.Fatal(err) } - defer cs.Close() + if *cleanup { + defer cs.Close() + } ticker := time.NewTicker(1 * time.Second / time.Duration(*qps)) defer ticker.Stop() diff --git a/mcp/streamable.go b/mcp/streamable.go index 7204e2b..994ebd6 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -450,22 +450,14 @@ type streamableServerConn struct { // handled. // streams holds the logical streams for this session, keyed by their ID. - // TODO: streams are never deleted, so the memory for a connection grows without - // bound. If we deleted a stream when the response is sent, we would lose the ability - // to replay if there was a cut just before the response was transmitted. - // Perhaps we could have a TTL for streams that starts just after the response. // - // TODO(rfindley): Once all responses have been received for a stream, we can - // remove it as it is no longer necessary, even if the client wants to replay - // messages. + // Lifecycle: streams persist until all of their responses are received from + // the server. streams map[string]*stream // requestStreams maps incoming requests to their logical stream ID. // - // Lifecycle: requestStreams persist for the duration of the session. - // - // TODO(rfindley): clean up once requests are handled. See the TODO for - // streams above. + // Lifecycle: requestStreams persist until their response is received. requestStreams map[jsonrpc.ID]string } @@ -641,17 +633,39 @@ func (c *streamableServerConn) writeEvent(w http.ResponseWriter, stream *stream, // all messages, so that no delivery or storage of new messages occurs while // the stream is still replaying. func (c *streamableServerConn) acquireStream(ctx context.Context, w http.ResponseWriter, streamID string, lastIdx *int) (*stream, chan struct{}) { + // if tempStream is set, the stream is done and we're just replaying messages. + // + // We record a temporary stream to claim exclusive replay rights. + tempStream := false c.mu.Lock() - stream, ok := c.streams[streamID] - c.mu.Unlock() + s, ok := c.streams[streamID] if !ok { - http.Error(w, "unknown stream", http.StatusBadRequest) - return nil, nil - } + // The stream is logically done, but claim exclusive rights to replay it by + // adding a temporary entry in the streams map. + // + // We create this entry with a non-nil deliver function, to ensure it isn't + // claimed by another request before we lock it below. + tempStream = true + s = &stream{ + id: streamID, + deliver: func([]byte, bool) error { return nil }, + } + c.streams[streamID] = s - stream.mu.Lock() - defer stream.mu.Unlock() - if stream.deliver != nil { + // Since this stream is transient, we must clean up after replaying. + defer func() { + c.mu.Lock() + delete(c.streams, streamID) + c.mu.Unlock() + }() + } + c.mu.Unlock() + + s.mu.Lock() + defer s.mu.Unlock() + + // Check that this stream wasn't claimed by another request. + if !tempStream && s.deliver != nil { http.Error(w, "stream ID conflicts with ongoing stream", http.StatusConflict) return nil, nil } @@ -664,7 +678,7 @@ func (c *streamableServerConn) acquireStream(ctx context.Context, w http.Respons // messages, and registered our delivery function. var toReplay [][]byte if c.eventStore != nil { - for data, err := range c.eventStore.After(ctx, c.SessionID(), stream.id, *lastIdx) { + for data, err := range c.eventStore.After(ctx, c.SessionID(), s.id, *lastIdx) { if err != nil { // We can't replay events, perhaps because the underlying event store // has garbage collected its storage. @@ -685,7 +699,7 @@ func (c *streamableServerConn) acquireStream(ctx context.Context, w http.Respons w.Header().Set("Content-Type", "text/event-stream") // Accept checked in [StreamableHTTPHandler] w.Header().Set("Connection", "keep-alive") - if stream.id == "" { + if s.id == "" { // Issue #410: the standalone SSE stream is likely not to receive messages // for a long time. Ensure that headers are flushed. w.WriteHeader(http.StatusOK) @@ -695,30 +709,30 @@ func (c *streamableServerConn) acquireStream(ctx context.Context, w http.Respons } for _, data := range toReplay { - if err := c.writeEvent(w, stream, data, lastIdx); err != nil { + if err := c.writeEvent(w, s, data, lastIdx); err != nil { return nil, nil } } - if stream.doneLocked() { + if tempStream || s.doneLocked() { // Nothing more to do. return nil, nil } - // Finally register a delivery function and unlock the stream, allowing the - // connection to write new events. + // The stream is not done: register a delivery function before the stream is + // unlocked, allowing the connection to write new events. done := make(chan struct{}) - stream.deliver = func(data []byte, final bool) error { + s.deliver = func(data []byte, final bool) error { if err := ctx.Err(); err != nil { return err } - err := c.writeEvent(w, stream, data, lastIdx) + err := c.writeEvent(w, s, data, lastIdx) if final { close(done) } return err } - return stream, done + return s, done } // servePOST handles an incoming message, and replies with either an outgoing @@ -1009,13 +1023,23 @@ func (c *streamableServerConn) Write(ctx context.Context, msg jsonrpc.Message) e s = c.streams[streamID] } } else { - s = c.streams[""] + s = c.streams[""] // standalone SSE stream + } + if responseTo.IsValid() { + // Once we've responded to a request, disallow related messages by removing + // the stream association. This also releases memory. + delete(c.requestStreams, responseTo) } sessionClosed := c.isDone c.mu.Unlock() if s == nil { - return fmt.Errorf("%w: no stream for request", jsonrpc2.ErrRejected) + // The request was made in the context of an ongoing request, but that + // request is complete. + // + // In the future, we could be less strict and allow the request to land on + // the standalone SSE stream. + return fmt.Errorf("%w: write to closed stream", jsonrpc2.ErrRejected) } if sessionClosed { return errors.New("session is closed") @@ -1024,10 +1048,28 @@ func (c *streamableServerConn) Write(ctx context.Context, msg jsonrpc.Message) e s.mu.Lock() defer s.mu.Unlock() if s.doneLocked() { + // It's possible that the stream was completed in between getting s above, + // and acquiring the stream lock. In order to avoid acquiring s.mu while + // holding c.mu, we check the terminal condition again. return fmt.Errorf("%w: write to closed stream", jsonrpc2.ErrRejected) } + // Perform accounting on responses. if responseTo.IsValid() { + if _, ok := s.requests[responseTo]; !ok { + panic(fmt.Sprintf("internal error: stream %v: response to untracked request %v", s.id, responseTo)) + } + if s.id == "" { + // This should be guaranteed not to happen by the stream resolution logic + // above, but be defensive: we don't ever want to delete the standalone + // stream. + panic("internal error: response on standalone stream") + } delete(s.requests, responseTo) + if len(s.requests) == 0 { + c.mu.Lock() + delete(c.streams, s.id) + c.mu.Unlock() + } } delivered := false diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index a089368..436665f 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -73,23 +73,53 @@ func TestStreamableTransports(t *testing.T) { return nil, nil, nil } AddTool(server, &Tool{Name: "hang"}, hang) + // We use sampling to test server->client requests, both before and after + // the related client->server request completes. + sampleDone := make(chan struct{}) + var sampleWG sync.WaitGroup AddTool(server, &Tool{Name: "sample"}, func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { + type testCase struct { + label string + ctx context.Context + wantSuccess bool + } + testSample := func(tc testCase) { + res, err := req.Session.CreateMessage(tc.ctx, &CreateMessageParams{}) + if gotSuccess := err == nil; gotSuccess != tc.wantSuccess { + t.Errorf("%s: CreateMessage success=%v, want %v", tc.label, gotSuccess, tc.wantSuccess) + } + if err != nil { + return + } + if g, w := res.Model, "aModel"; g != w { + t.Errorf("%s: got model %q, want %q", tc.label, g, w) + } + } // Test that we can make sampling requests during tool handling. // // Try this on both the request context and a background context, so // that messages may be delivered on either the POST or GET connection. - for _, ctx := range map[string]context.Context{ - "request context": ctx, - "background context": context.Background(), + for _, test := range []testCase{ + {"request context", ctx, true}, + {"background context", context.Background(), true}, } { - res, err := req.Session.CreateMessage(ctx, &CreateMessageParams{}) - if err != nil { - return nil, nil, err - } - if g, w := res.Model, "aModel"; g != w { - return nil, nil, fmt.Errorf("got %q, want %q", g, w) - } + testSample(test) } + // Now, spin off a goroutine that runs after the sampling request, to + // check behavior when the client request has completed. + sampleWG.Add(1) + go func() { + defer sampleWG.Done() + <-sampleDone + // Test that sampling requests in the tool context fail outside of + // tool handling, but succeed on the background context. + for _, test := range []testCase{ + {"request context", ctx, false}, + {"background context", context.Background(), true}, + } { + testSample(test) + } + }() return &CallToolResult{}, nil, nil }) @@ -191,8 +221,8 @@ func TestStreamableTransports(t *testing.T) { t.Fatal("timeout waiting for cancellation") } - // The "sampling" tool should be able to issue sampling requests during - // tool operation. + // The "sampling" tool checks the validity of server->client requests + // both within and without the tool context. result, err := session.CallTool(ctx, &CallToolParams{ Name: "sample", Arguments: map[string]any{}, @@ -200,6 +230,10 @@ func TestStreamableTransports(t *testing.T) { if err != nil { t.Fatal(err) } + // Run the out-of-band checks. + close(sampleDone) + sampleWG.Wait() + if result.IsError { t.Fatalf("tool failed: %s", result.Content[0].(*TextContent).Text) } From cfa7a515a9bc712a39754380aebb37f4da68bb6c Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Mon, 20 Oct 2025 14:58:24 -0400 Subject: [PATCH 110/418] mcp: add StreamableHTTPOptions.EventStore (#593) Allow users to enable streamable replay by setting a non-nil event store. Updates #587 --- mcp/streamable.go | 11 ++++++----- mcp/streamable_test.go | 25 +++++++++---------------- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 994ebd6..8773ea7 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -72,8 +72,11 @@ type StreamableHTTPOptions struct { // If nil, do not log. Logger *slog.Logger - // TODO(rfindley): file a proposal to export this option, or something equivalent. - configureTransport func(req *http.Request, transport *StreamableServerTransport) + // EventStore enables stream resumption. + // + // If set, EventStore will be used to persist stream events and replay them + // upon stream resumption. + EventStore EventStore } // NewStreamableHTTPHandler returns a new [StreamableHTTPHandler]. @@ -237,12 +240,10 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque transport = &StreamableServerTransport{ SessionID: sessionID, Stateless: h.opts.Stateless, + EventStore: h.opts.EventStore, jsonResponse: h.opts.JSONResponse, logger: h.opts.Logger, } - if h.opts.configureTransport != nil { - h.opts.configureTransport(req, transport) - } // To support stateless mode, we initialize the session with a default // state, so that it doesn't reject subsequent requests. diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 436665f..a2e5c73 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -125,14 +125,13 @@ func TestStreamableTransports(t *testing.T) { // Start an httptest.Server with the StreamableHTTPHandler, wrapped in a // cookie-checking middleware. - handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{ + opts := &StreamableHTTPOptions{ JSONResponse: test.useJSON, - configureTransport: func(_ *http.Request, transport *StreamableServerTransport) { - if test.replay { - transport.EventStore = NewMemoryEventStore(nil) - } - }, - }) + } + if test.replay { + opts.EventStore = NewMemoryEventStore(nil) + } + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, opts) var ( headerMu sync.Mutex @@ -420,9 +419,7 @@ func testClientReplay(t *testing.T, test clientReplayTest) { }) realServer := httptest.NewServer(mustNotPanic(t, NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{ - configureTransport: func(_ *http.Request, t *StreamableServerTransport) { - t.EventStore = NewMemoryEventStore(nil) // necessary for replay - }, + EventStore: NewMemoryEventStore(nil), // necessary for replay }))) t.Cleanup(func() { t.Log("Closing real HTTP server") @@ -601,9 +598,7 @@ func TestServerInitiatedSSE(t *testing.T) { // However, it shouldn't be necessary to use replay here, as we should be // guaranteed that the standalone SSE stream is started by the time the // client is connected. - configureTransport: func(_ *http.Request, transport *StreamableServerTransport) { - transport.EventStore = NewMemoryEventStore(nil) - }, + EventStore: NewMemoryEventStore(nil), } httpServer := httptest.NewServer(mustNotPanic(t, NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, opts))) defer httpServer.Close() @@ -976,9 +971,7 @@ func TestStreamableServerTransport(t *testing.T) { opts := &StreamableHTTPOptions{} if test.replay { - opts.configureTransport = func(_ *http.Request, t *StreamableServerTransport) { - t.EventStore = NewMemoryEventStore(nil) - } + opts.EventStore = NewMemoryEventStore(nil) } // Start the streamable handler. handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, opts) From a5dae3e05481836b2dbf172c88e29f46e20f9679 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Wed, 22 Oct 2025 14:34:51 -0400 Subject: [PATCH 111/418] .github: fix staticcheck linting (#599) Update the test.yml lint job to run linting with Go 1.25, and use the ambient Go command to run staticcheck. This seems to fix a mysterious bug where staticcheck was failing, presumably in the Go installation step. Using the ambient Go command resulted in staticcheck failures, since some symbols are detected as unused at Go 1.23 due to build tags. Upgrading the Go version for this job avoids those errors. Also, use a pinned version of staticcheck (as recommended), to avoid future failures when new versions are released. --- .github/workflows/test.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0366246..68a3c42 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 with: - go-version: "^1.23" + go-version: "^1.25" - name: Check formatting run: | unformatted=$(gofmt -l .) @@ -33,7 +33,8 @@ jobs: - name: Run staticcheck uses: dominikh/staticcheck-action@024238d2898c874f26d723e7d0ff4308c35589a2 # v1 with: - version: "latest" + install-go: false + version: "v0.6.1" test: runs-on: ubuntu-latest From f01e7faaf5d3bc947bb0e9f5b6033acfd9112098 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Thu, 23 Oct 2025 09:17:20 -0400 Subject: [PATCH 112/418] mcp: add StreamableHTTPOptions.SessionTimeout (#594) Add a timeout option for the streamable HTTP handler that automatically cleans up idle sessions. Also, fix a bug in the streamable client, where we hang on a request even though the client can never get a response (because the HTTP request terminated without a response or Last-Event-Id). Fixes #499 --- mcp/client.go | 11 +- mcp/server.go | 13 ++- mcp/streamable.go | 199 +++++++++++++++++++++++++++------- mcp/streamable_client_test.go | 39 ++++++- mcp/streamable_test.go | 91 +++++++++++++++- 5 files changed, 302 insertions(+), 51 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index fb77b3f..d7e3ae5 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -11,6 +11,7 @@ import ( "iter" "slices" "sync" + "sync/atomic" "time" "github.com/google/jsonschema-go/jsonschema" @@ -177,7 +178,11 @@ func (c *Client) Connect(ctx context.Context, t Transport, _ *ClientSessionOptio // Call [ClientSession.Close] to close the connection, or await server // termination with [ClientSession.Wait]. type ClientSession struct { - onClose func() + // Ensure that onClose is called at most once. + // We defensively use an atomic CompareAndSwap rather than a sync.Once, in case the + // onClose callback triggers a re-entrant call to Close. + calledOnClose atomic.Bool + onClose func() conn *jsonrpc2.Connection client *Client @@ -205,6 +210,8 @@ func (cs *ClientSession) ID() string { // Close performs a graceful close of the connection, preventing new requests // from being handled, and waiting for ongoing requests to return. Close then // terminates the connection. +// +// Close is idempotent and concurrency safe. func (cs *ClientSession) Close() error { // Note: keepaliveCancel access is safe without a mutex because: // 1. keepaliveCancel is only written once during startKeepalive (happens-before all Close calls) @@ -216,7 +223,7 @@ func (cs *ClientSession) Close() error { } err := cs.conn.Close() - if cs.onClose != nil { + if cs.onClose != nil && cs.calledOnClose.CompareAndSwap(false, true) { cs.onClose() } diff --git a/mcp/server.go b/mcp/server.go index 17b68c4..29be8ff 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -19,6 +19,7 @@ import ( "reflect" "slices" "sync" + "sync/atomic" "time" "github.com/google/jsonschema-go/jsonschema" @@ -825,7 +826,7 @@ func (s *Server) disconnect(cc *ServerSession) { type ServerSessionOptions struct { State *ServerSessionState - onClose func() + onClose func() // used to clean up associated resources } // Connect connects the MCP server over the given transport and starts handling @@ -920,7 +921,11 @@ func newServerRequest[P Params](ss *ServerSession, params P) *ServerRequest[P] { // Call [ServerSession.Close] to close the connection, or await client // termination with [ServerSession.Wait]. type ServerSession struct { - onClose func() + // Ensure that onClose is called at most once. + // We defensively use an atomic CompareAndSwap rather than a sync.Once, in case the + // onClose callback triggers a re-entrant call to Close. + calledOnClose atomic.Bool + onClose func() server *Server conn *jsonrpc2.Connection @@ -1185,6 +1190,8 @@ func (ss *ServerSession) setLevel(_ context.Context, params *SetLoggingLevelPara // Close performs a graceful shutdown of the connection, preventing new // requests from being handled, and waiting for ongoing requests to return. // Close then terminates the connection. +// +// Close is idempotent and concurrency safe. func (ss *ServerSession) Close() error { if ss.keepaliveCancel != nil { // Note: keepaliveCancel access is safe without a mutex because: @@ -1196,7 +1203,7 @@ func (ss *ServerSession) Close() error { } err := ss.conn.Close() - if ss.onClose != nil { + if ss.onClose != nil && ss.calledOnClose.CompareAndSwap(false, true) { ss.onClose() } diff --git a/mcp/streamable.go b/mcp/streamable.go index 8773ea7..d8ce45e 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -12,6 +12,7 @@ import ( "fmt" "io" "log/slog" + "maps" "math" "math/rand/v2" "net/http" @@ -40,12 +41,76 @@ type StreamableHTTPHandler struct { getServer func(*http.Request) *Server opts StreamableHTTPOptions - onTransportDeletion func(sessionID string) // for testing only + onTransportDeletion func(sessionID string) // for testing - mu sync.Mutex - // TODO: we should store the ServerSession along with the transport, because - // we need to cancel keepalive requests when closing the transport. - transports map[string]*StreamableServerTransport // keyed by IDs (from Mcp-Session-Id header) + mu sync.Mutex + sessions map[string]*sessionInfo // keyed by session ID +} + +type sessionInfo struct { + session *ServerSession + transport *StreamableServerTransport + + // If timeout is set, automatically close the session after an idle period. + timeout time.Duration + timerMu sync.Mutex + refs int // reference count + timer *time.Timer +} + +// startPOST signals that a POST request for this session is starting (which +// carries a client->server message), pausing the session timeout if it was +// running. +// +// TODO: we may want to also pause the timer when resuming non-standalone SSE +// streams, but that is tricy to implement. Clients should generally make +// keepalive pings if they want to keep the session live. +func (i *sessionInfo) startPOST() { + if i.timeout <= 0 { + return + } + + i.timerMu.Lock() + defer i.timerMu.Unlock() + + if i.timer == nil { + return // timer stopped permanently + } + if i.refs == 0 { + i.timer.Stop() + } + i.refs++ +} + +// endPOST sigals that a request for this session is ending, starting the +// timeout if there are no other requests running. +func (i *sessionInfo) endPOST() { + if i.timeout <= 0 { + return + } + + i.timerMu.Lock() + defer i.timerMu.Unlock() + + if i.timer == nil { + return // timer stopped permanently + } + + i.refs-- + assert(i.refs >= 0, "negative ref count") + if i.refs == 0 { + i.timer.Reset(i.timeout) + } +} + +// stopTimer stops the inactivity timer permanently. +func (i *sessionInfo) stopTimer() { + i.timerMu.Lock() + defer i.timerMu.Unlock() + if i.timer != nil { + i.timer.Stop() + i.timer = nil + } } // StreamableHTTPOptions configures the StreamableHTTPHandler. @@ -77,6 +142,14 @@ type StreamableHTTPOptions struct { // If set, EventStore will be used to persist stream events and replay them // upon stream resumption. EventStore EventStore + + // SessionTimeout configures a timeout for idle sessions. + // + // When sessions receive no new HTTP requests from the client for this + // duration, they are automatically closed. + // + // If SessionTimeout is the zero value, idle sessions are never closed. + SessionTimeout time.Duration } // NewStreamableHTTPHandler returns a new [StreamableHTTPHandler]. @@ -86,8 +159,8 @@ type StreamableHTTPOptions struct { // If getServer returns nil, a 400 Bad Request will be served. func NewStreamableHTTPHandler(getServer func(*http.Request) *Server, opts *StreamableHTTPOptions) *StreamableHTTPHandler { h := &StreamableHTTPHandler{ - getServer: getServer, - transports: make(map[string]*StreamableServerTransport), + getServer: getServer, + sessions: make(map[string]*sessionInfo), } if opts != nil { h.opts = *opts @@ -100,7 +173,7 @@ func NewStreamableHTTPHandler(getServer func(*http.Request) *Server, opts *Strea return h } -// closeAll closes all ongoing sessions. +// closeAll closes all ongoing sessions, for tests. // // TODO(rfindley): investigate the best API for callers to configure their // session lifecycle. (?) @@ -108,12 +181,19 @@ func NewStreamableHTTPHandler(getServer func(*http.Request) *Server, opts *Strea // Should we allow passing in a session store? That would allow the handler to // be stateless. func (h *StreamableHTTPHandler) closeAll() { + // TODO: if we ever expose this outside of tests, we'll need to do better + // than simply collecting sessions while holding the lock: we need to prevent + // new sessions from being added. + // + // Currently, sessions remove themselves from h.sessions when closed, so we + // can't call Close while holding the lock. h.mu.Lock() - defer h.mu.Unlock() - for _, s := range h.transports { - s.connection.Close() + sessionInfos := slices.Collect(maps.Values(h.sessions)) + h.sessions = nil + h.mu.Unlock() + for _, s := range sessionInfos { + s.session.Close() } - h.transports = nil } func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { @@ -144,12 +224,12 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque } sessionID := req.Header.Get(sessionIDHeader) - var transport *StreamableServerTransport + var sessInfo *sessionInfo if sessionID != "" { h.mu.Lock() - transport = h.transports[sessionID] + sessInfo = h.sessions[sessionID] h.mu.Unlock() - if transport == nil && !h.opts.Stateless { + if sessInfo == nil && !h.opts.Stateless { // Unless we're in 'stateless' mode, which doesn't perform any Session-ID // validation, we require that the session ID matches a known session. // @@ -164,11 +244,10 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque http.Error(w, "Bad Request: DELETE requires an Mcp-Session-Id header", http.StatusBadRequest) return } - if transport != nil { // transport may be nil in stateless mode - h.mu.Lock() - delete(h.transports, transport.SessionID) - h.mu.Unlock() - transport.connection.Close() + if sessInfo != nil { // sessInfo may be nil in stateless mode + // Closing the session also removes it from h.sessions, due to the + // onClose callback. + sessInfo.session.Close() } w.WriteHeader(http.StatusNoContent) return @@ -225,7 +304,7 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque return } - if transport == nil { + if sessInfo == nil { server := h.getServer(req) if server == nil { // The getServer argument to NewStreamableHTTPHandler returned nil. @@ -237,7 +316,7 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque // existing transport. sessionID = server.opts.GetSessionID() } - transport = &StreamableServerTransport{ + transport := &StreamableServerTransport{ SessionID: sessionID, Stateless: h.opts.Stateless, EventStore: h.opts.EventStore, @@ -301,10 +380,13 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque connectOpts = &ServerSessionOptions{ onClose: func() { h.mu.Lock() - delete(h.transports, transport.SessionID) - h.mu.Unlock() - if h.onTransportDeletion != nil { - h.onTransportDeletion(transport.SessionID) + defer h.mu.Unlock() + if info, ok := h.sessions[transport.SessionID]; ok { + info.stopTimer() + delete(h.sessions, transport.SessionID) + if h.onTransportDeletion != nil { + h.onTransportDeletion(transport.SessionID) + } } }, } @@ -313,23 +395,44 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque // Pass req.Context() here, to allow middleware to add context values. // The context is detached in the jsonrpc2 library when handling the // long-running stream. - ss, err := server.Connect(req.Context(), transport, connectOpts) + session, err := server.Connect(req.Context(), transport, connectOpts) if err != nil { http.Error(w, "failed connection", http.StatusInternalServerError) return } + sessInfo = &sessionInfo{ + session: session, + transport: transport, + } + if h.opts.Stateless { // Stateless mode: close the session when the request exits. - defer ss.Close() // close the fake session after handling the request + defer session.Close() // close the fake session after handling the request } else { // Otherwise, save the transport so that it can be reused + + // Clean up the session when it times out. + // + // Note that the timer here may fire multiple times, but + // sessInfo.session.Close is idempotent. + if h.opts.SessionTimeout > 0 { + sessInfo.timeout = h.opts.SessionTimeout + sessInfo.timer = time.AfterFunc(sessInfo.timeout, func() { + sessInfo.session.Close() + }) + } h.mu.Lock() - h.transports[transport.SessionID] = transport + h.sessions[transport.SessionID] = sessInfo h.mu.Unlock() } } - transport.ServeHTTP(w, req) + if req.Method == http.MethodPost { + sessInfo.startPOST() + defer sessInfo.endPOST() + } + + sessInfo.transport.ServeHTTP(w, req) } // A StreamableServerTransport implements the server side of the MCP streamable @@ -1383,9 +1486,12 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e go c.handleJSON(requestSummary, resp) case "text/event-stream": - jsonReq, _ := msg.(*jsonrpc.Request) + var forCall *jsonrpc.Request + if jsonReq, ok := msg.(*jsonrpc.Request); ok && jsonReq.IsCall() { + forCall = jsonReq + } // TODO: should we cancel this logical SSE request if/when jsonReq is canceled? - go c.handleSSE(requestSummary, resp, false, jsonReq) + go c.handleSSE(requestSummary, resp, false, forCall) default: resp.Body.Close() @@ -1435,9 +1541,9 @@ func (c *streamableClientConn) handleJSON(requestSummary string, resp *http.Resp // handleSSE manages the lifecycle of an SSE connection. It can be either // persistent (for the main GET listener) or temporary (for a POST response). // -// If forReq is set, it is the request that initiated the stream, and the +// If forCall is set, it is the call that initiated the stream, and the // stream is complete when we receive its response. -func (c *streamableClientConn) handleSSE(requestSummary string, initialResp *http.Response, persistent bool, forReq *jsonrpc2.Request) { +func (c *streamableClientConn) handleSSE(requestSummary string, initialResp *http.Response, persistent bool, forCall *jsonrpc2.Request) { resp := initialResp var lastEventID string for { @@ -1447,7 +1553,7 @@ func (c *streamableClientConn) handleSSE(requestSummary string, initialResp *htt // Eventually, if we don't get the response, we should stop trying and // fail the request. if resp != nil { - eventID, clientClosed := c.processStream(requestSummary, resp, forReq) + eventID, clientClosed := c.processStream(requestSummary, resp, forCall) lastEventID = eventID // If the connection was closed by the client, we're done. @@ -1510,11 +1616,11 @@ func (c *streamableClientConn) handleSSE(requestSummary string, initialResp *htt // incoming channel. It returns the ID of the last processed event and a flag // indicating if the connection was closed by the client. If resp is nil, it // returns "", false. -func (c *streamableClientConn) processStream(requestSummary string, resp *http.Response, forReq *jsonrpc.Request) (lastEventID string, clientClosed bool) { +func (c *streamableClientConn) processStream(requestSummary string, resp *http.Response, forCall *jsonrpc.Request) (lastEventID string, clientClosed bool) { defer resp.Body.Close() for evt, err := range scanEvents(resp.Body) { if err != nil { - return lastEventID, false + break } if evt.ID != "" { @@ -1529,10 +1635,10 @@ func (c *streamableClientConn) processStream(requestSummary string, resp *http.R select { case c.incoming <- msg: - if jsonResp, ok := msg.(*jsonrpc.Response); ok && forReq != nil { + if jsonResp, ok := msg.(*jsonrpc.Response); ok && forCall != nil { // TODO: we should never get a response when forReq is nil (the standalone SSE request). // We should detect this case. - if jsonResp.ID == forReq.ID { + if jsonResp.ID == forCall.ID { return "", true } } @@ -1542,7 +1648,20 @@ func (c *streamableClientConn) processStream(requestSummary string, resp *http.R } } // The loop finished without an error, indicating the server closed the stream. - return "", false + // + // If the lastEventID is "", the stream is not retryable and we should + // report a synthetic error for the call. + if lastEventID == "" && forCall != nil { + errmsg := &jsonrpc2.Response{ + ID: forCall.ID, + Error: fmt.Errorf("request terminated without response"), + } + select { + case c.incoming <- errmsg: + case <-c.done: + } + } + return lastEventID, false } // reconnect handles the logic of retrying a connection with an exponential diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index faca04c..4a4f5c6 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -34,7 +34,6 @@ type streamableResponse struct { body string // or "" optional bool // if set, request need not be sent wantProtocolVersion string // if "", unchecked - callback func() // if set, called after the request is handled } type fakeResponses map[streamableRequestKey]*streamableResponse @@ -96,9 +95,6 @@ func (s *fakeStreamableServer) ServeHTTP(w http.ResponseWriter, req *http.Reques http.Error(w, "no response", http.StatusInternalServerError) return } - if resp.callback != nil { - defer resp.callback() - } for k, v := range resp.header { w.Header().Set(k, v) } @@ -411,3 +407,38 @@ func TestStreamableClientStrictness(t *testing.T) { }) } } + +func TestStreamableClientUnresumableRequest(t *testing.T) { + // This test verifies that the client fails fast when making a request that + // is unresumable, because it does not contain any events. + ctx := context.Background() + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodInitialize}: { + header: header{ + "Content-Type": "text/event-stream", + sessionIDHeader: "123", + }, + body: "", + }, + {"DELETE", "123", ""}: {optional: true}, + }, + } + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, nil) + cs, err := client.Connect(ctx, transport, nil) + if err == nil { + cs.Close() + t.Fatalf("Connect succeeded unexpectedly") + } + // This may be a bit of a change detector, but for now check that we're + // actually exercising the early failure codepath. + msg := "terminated without response" + if !strings.Contains(err.Error(), msg) { + t.Errorf("Connect: got error %v, want containing %q", err, msg) + } +} diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index a2e5c73..0f38a0f 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -20,6 +20,7 @@ import ( "net/url" "os" "runtime" + "slices" "sort" "strings" "sync" @@ -581,8 +582,8 @@ func TestServerTransportCleanup(t *testing.T) { } handler.mu.Lock() - if len(handler.transports) != 0 { - t.Errorf("want empty transports map, find %v entries from handler's transports map", len(handler.transports)) + if len(handler.sessions) != 0 { + t.Errorf("want empty transports map, find %v entries from handler's transports map", len(handler.sessions)) } handler.mu.Unlock() } @@ -1623,7 +1624,93 @@ func TestStreamableClientContextPropagation(t *testing.T) { case <-time.After(100 * time.Millisecond): t.Error("Connection context was not cancelled when parent was cancelled") } +} +func TestStreamableSessionTimeout(t *testing.T) { + // TODO: this test relies on timing and may be flaky. + // Fixing with testing/synctest is challenging because it uses real I/O (via + // httptest.NewServer). + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + server := NewServer(testImpl, nil) + + deleted := make(chan string, 1) + handler := NewStreamableHTTPHandler( + func(req *http.Request) *Server { return server }, + &StreamableHTTPOptions{ + SessionTimeout: 50 * time.Millisecond, + }, + ) + handler.onTransportDeletion = func(sessionID string) { + deleted <- sessionID + } + + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + defer httpServer.Close() + + // Connect a client to create a session. + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, nil) + if err != nil { + t.Fatalf("client.Connect() failed: %v", err) + } + defer session.Close() + + sessionID := session.ID() + if sessionID == "" { + t.Fatal("client session has empty ID") + } + + // Verify the session exists on the server. + serverSessions := slices.Collect(server.Sessions()) + if len(serverSessions) != 1 { + t.Fatalf("got %d sessions, want 1", len(serverSessions)) + } + if got := serverSessions[0].ID(); got != sessionID { + t.Fatalf("server session is %q, want %q", got, sessionID) + } + + // Test that (possibly concurrent) requests keep the session alive. + // + // Spin up two goroutines, each making a request every 10ms. These requests + // should keep the server from timing out. + var wg sync.WaitGroup + wg.Add(2) + for range 2 { + go func() { + defer wg.Done() + + for range 20 { + if _, err := session.ListTools(ctx, nil); err != nil { + t.Errorf("ListTools failed: %v", err) + } + time.Sleep(10 * time.Millisecond) + } + }() + } + + wg.Wait() + + // Wait for the session to be cleaned up. + select { + case deletedID := <-deleted: + if deletedID != sessionID { + t.Errorf("deleted session ID = %q, want %q", deletedID, sessionID) + } + case <-ctx.Done(): + t.Fatal("timed out waiting for session cleanup") + } + + // Verify the session is gone from both handler and server. + handler.mu.Lock() + if len(handler.sessions) != 0 { + t.Errorf("handler.sessions is not empty; length %d", len(handler.sessions)) + } + if ss := slices.Collect(server.Sessions()); len(ss) != 0 { + t.Errorf("server.Sessions() is not empty; length %d", len(ss)) + } + handler.mu.Unlock() } // mustNotPanic is a helper to enforce that test handlers do not panic (see From 1a907bcd6fddbac1116145f81fa7bb9f76d59b1a Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Thu, 23 Oct 2025 09:29:53 -0400 Subject: [PATCH 113/418] auth: clone the client request body before roundtripping (#597) RoundTrippers may read and close the body, so be careful to clone before roundtripping during client oauth, as the request may be issued multiple times. Fixes #590 --- auth/client.go | 34 +++++++++++++++++++++++++++++- auth/client_test.go | 50 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/auth/client.go b/auth/client.go index 7c65e9d..3ed6b69 100644 --- a/auth/client.go +++ b/auth/client.go @@ -7,8 +7,10 @@ package auth import ( + "bytes" "context" "errors" + "io" "net/http" "sync" @@ -67,6 +69,28 @@ func (t *HTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { base := t.opts.Base t.mu.Unlock() + var ( + // If haveBody is set, the request has a nontrivial body, and we need avoid + // reading (or closing) it multiple times. In that case, bodyBytes is its + // content. + haveBody bool + bodyBytes []byte + ) + if req.Body != nil && req.Body != http.NoBody { + // if we're setting Body, we must mutate first. + req = req.Clone(req.Context()) + haveBody = true + var err error + bodyBytes, err = io.ReadAll(req.Body) + if err != nil { + return nil, err + } + // Now that we've read the request body, http.RoundTripper requires that we + // close it. + req.Body.Close() // ignore error + req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + } + resp, err := base.RoundTrip(req) if err != nil { return nil, err @@ -97,7 +121,15 @@ func (t *HTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { } t.opts.Base = &oauth2.Transport{Base: t.opts.Base, Source: ts} } - return t.opts.Base.RoundTrip(req.Clone(req.Context())) + + // If we don't have a body, the request is reusable, though it will be cloned + // by the base. However, if we've had to read the body, we must clone. + if haveBody { + req = req.Clone(req.Context()) + req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + } + + return t.opts.Base.RoundTrip(req) } func extractResourceMetadataURL(authHeaders []string) string { diff --git a/auth/client_test.go b/auth/client_test.go index 310fc56..e1b7dc7 100644 --- a/auth/client_test.go +++ b/auth/client_test.go @@ -10,13 +10,24 @@ import ( "context" "errors" "fmt" + "io" "net/http" "net/http/httptest" + "strings" "testing" "golang.org/x/oauth2" ) +// A basicReader is an io.Reader to be used as a non-rereadable request body. +// +// net/http has special handling for strings.Reader that we want to avoid. +type basicReader struct { + r *strings.Reader +} + +func (r *basicReader) Read(p []byte) (n int, err error) { return r.r.Read(p) } + // TestHTTPTransport validates the OAuth HTTPTransport. func TestHTTPTransport(t *testing.T) { const testToken = "test-token-123" @@ -27,6 +38,20 @@ func TestHTTPTransport(t *testing.T) { // authServer simulates a resource that requires OAuth. authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + // Ensure that the body was properly cloned, by reading it completely. + // If the body is not cloned, reading it the second time may yield no + // bytes. + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if len(body) == 0 { + http.Error(w, "empty body", http.StatusBadRequest) + return + } + } authHeader := r.Header.Get("Authorization") if authHeader == fmt.Sprintf("Bearer %s", testToken) { w.WriteHeader(http.StatusOK) @@ -82,6 +107,31 @@ func TestHTTPTransport(t *testing.T) { } }) + t.Run("request body is cloned", func(t *testing.T) { + handler := func(ctx context.Context, args OAuthHandlerArgs) (oauth2.TokenSource, error) { + if args.ResourceMetadataURL != "http://metadata.example.com" { + t.Errorf("handler got metadata URL %q, want %q", args.ResourceMetadataURL, "http://metadata.example.com") + } + return fakeTokenSource, nil + } + + transport, err := NewHTTPTransport(handler, nil) + if err != nil { + t.Fatalf("NewHTTPTransport() failed: %v", err) + } + client := &http.Client{Transport: transport} + + resp, err := client.Post(authServer.URL, "application/json", &basicReader{strings.NewReader("{}")}) + if err != nil { + t.Fatalf("client.Post() failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("got status %d, want %d", resp.StatusCode, http.StatusOK) + } + }) + t.Run("handler returns error", func(t *testing.T) { handlerErr := errors.New("user rejected auth") handler := func(ctx context.Context, args OAuthHandlerArgs) (oauth2.TokenSource, error) { From 80abbe61715b278958f67a61423f60e5d12f9208 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Mon, 27 Oct 2025 14:00:58 -0400 Subject: [PATCH 114/418] docs: document support for resumability and redelivery (#606) Add missing documentation on resumability and redelivery in the streamable transport. Indirectly, also update stale transcluded examples. Example changes weren't causing CI failure as we only trigger the docs check on changes to the raw docs. --- docs/client.md | 4 +++- docs/protocol.md | 14 ++++++++++++++ docs/troubleshooting.md | 4 ++-- internal/docs/protocol.src.md | 13 +++++++++++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/docs/client.md b/docs/client.md index 8fc7544..6ce20c9 100644 --- a/docs/client.md +++ b/docs/client.md @@ -53,9 +53,11 @@ func Example_roots() { // Connect the server and client... t1, t2 := mcp.NewInMemoryTransports() - if _, err := s.Connect(ctx, t1, nil); err != nil { + serverSession, err := s.Connect(ctx, t1, nil) + if err != nil { log.Fatal(err) } + defer serverSession.Close() clientSession, err := c.Connect(ctx, t2, nil) if err != nil { diff --git a/docs/protocol.md b/docs/protocol.md index 3a031af..6d22c4b 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -148,6 +148,7 @@ To create a streamable MCP server, you create a `StreamableHTTPHandler` and pass it an `mcp.Server`: ```go +// TODO: Until we have a way to clean up abandoned sessions, this test will leak goroutines (see #499) func ExampleStreamableHTTPHandler() { // Create a new streamable handler, using the same MCP server for every request. // @@ -185,6 +186,19 @@ client, err := mcp.Connect(ctx, transport, &mcp.ClientOptions{...}) The `StreamableClientTransport` handles the HTTP requests and communicates with the server using the streamable transport protocol. +#### Resumability and Redelivery + +By default, the streamable server does not support [resumability or +redelivery](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#resumability-and-redelivery) +of messages, because doing so requires either a persistent storage solution or +unbounded memory usage (see also +[#580](https://github.com/modelcontextprotocol/go-sdk/issues/580)). + +To enable resumability, set `StreamableHTTPOptions.EventStore` to a non-nil +value. The SDK provides a `MemoryEventStore` for testing or simple use cases; +for production use it is generally advisable to use a more sophisticated +implementation. + #### Stateless Mode The streamable server supports a _stateless mode_ by setting diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 38410ad..c22a279 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -33,7 +33,7 @@ func ExampleLoggingTransport() { if err != nil { log.Fatal(err) } - defer serverSession.Wait() + defer serverSession.Close() client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) var b bytes.Buffer @@ -71,7 +71,7 @@ func ExampleStreamableHTTPHandler_middleware() { server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.1.0"}, nil) handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { return server - }, nil) + }, &mcp.StreamableHTTPOptions{Stateless: true}) loggingHandler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { // Example debugging; you could also capture the response. body, err := io.ReadAll(req.Body) diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index e85374a..b0874cc 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -112,6 +112,19 @@ client, err := mcp.Connect(ctx, transport, &mcp.ClientOptions{...}) The `StreamableClientTransport` handles the HTTP requests and communicates with the server using the streamable transport protocol. +#### Resumability and Redelivery + +By default, the streamable server does not support [resumability or +redelivery](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#resumability-and-redelivery) +of messages, because doing so requires either a persistent storage solution or +unbounded memory usage (see also +[#580](https://github.com/modelcontextprotocol/go-sdk/issues/580)). + +To enable resumability, set `StreamableHTTPOptions.EventStore` to a non-nil +value. The SDK provides a `MemoryEventStore` for testing or simple use cases; +for production use it is generally advisable to use a more sophisticated +implementation. + #### Stateless Mode The streamable server supports a _stateless mode_ by setting From ae6bda692bc21f629cee464f54527235fe3a2c6d Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Mon, 27 Oct 2025 14:03:22 -0400 Subject: [PATCH 115/418] mcp: establish the streamable client standalone SSE stream in Connect (#604) When Connect returns, client should be guaranteed that the streamable SSE stream is connected. Fixes #583 --- mcp/streamable.go | 194 ++++++++++++++++++---------------- mcp/streamable_client_test.go | 78 +++----------- mcp/streamable_test.go | 9 +- 3 files changed, 120 insertions(+), 161 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index d8ce45e..190b582 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1346,7 +1346,44 @@ func (c *streamableClientConn) sessionUpdated(state clientSessionState) { // § 2.5: A server using the Streamable HTTP transport MAY assign a session // ID at initialization time, by including it in an Mcp-Session-Id header // on the HTTP response containing the InitializeResult. - go c.handleSSE("standalone SSE stream", nil, true, nil) + c.connectStandaloneSSE() +} + +func (c *streamableClientConn) connectStandaloneSSE() { + resp, err := c.connectSSE("") + if err != nil { + c.fail(fmt.Errorf("standalone SSE request failed (session ID: %v): %v", c.sessionID, err)) + return + } + + // [§2.2.3]: "The server MUST either return Content-Type: + // text/event-stream in response to this HTTP GET, or else return HTTP + // 405 Method Not Allowed, indicating that the server does not offer an + // SSE stream at this endpoint." + // + // [§2.2.3]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#listening-for-messages-from-the-server + if resp.StatusCode == http.StatusMethodNotAllowed { + // The server doesn't support the standalone SSE stream. + resp.Body.Close() + return + } + if resp.StatusCode == http.StatusNotFound && !c.strict { + // modelcontextprotocol/gosdk#393: some servers return NotFound instead + // of MethodNotAllowed for the standalone SSE stream. + // + // Treat this like MethodNotAllowed in non-strict mode. + if c.logger != nil { + c.logger.Warn("got 404 instead of 405 for standalone SSE stream") + } + resp.Body.Close() + return + } + summary := "standalone SSE stream" + if err := c.checkResponse(summary, resp); err != nil { + c.fail(err) + return + } + go c.handleSSE(summary, resp, true, nil) } // fail handles an asynchronous error while reading. @@ -1434,22 +1471,10 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e return fmt.Errorf("%s: %v", requestSummary, err) } - // §2.5.3: "The server MAY terminate the session at any time, after - // which it MUST respond to requests containing that session ID with HTTP - // 404 Not Found." - if resp.StatusCode == http.StatusNotFound { - // Fail the session immediately, rather than relying on jsonrpc2 to fail - // (and close) it, because we want the call to Close to know that this - // session is missing (and therefore not send the DELETE). - err := fmt.Errorf("%s: failed to send: %w", requestSummary, errSessionMissing) + if err := c.checkResponse(requestSummary, resp); err != nil { c.fail(err) - resp.Body.Close() return err } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - resp.Body.Close() - return fmt.Errorf("broken session: %v", resp.Status) - } if sessionID := resp.Header.Get(sessionIDHeader); sessionID != "" { c.mu.Lock() @@ -1463,6 +1488,8 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e return fmt.Errorf("mismatching session IDs %q and %q", hadSessionID, sessionID) } } + // TODO(rfindley): this logic isn't quite right. + // We should keep going even if the server returns 202, if we have a call. if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusAccepted { // [§2.1.4]: "If the input is a JSON-RPC response or notification: // If the server accepts the input, the server MUST return HTTP status code 202 Accepted with no body." @@ -1543,75 +1570,65 @@ func (c *streamableClientConn) handleJSON(requestSummary string, resp *http.Resp // // If forCall is set, it is the call that initiated the stream, and the // stream is complete when we receive its response. -func (c *streamableClientConn) handleSSE(requestSummary string, initialResp *http.Response, persistent bool, forCall *jsonrpc2.Request) { - resp := initialResp - var lastEventID string +func (c *streamableClientConn) handleSSE(requestSummary string, resp *http.Response, persistent bool, forCall *jsonrpc2.Request) { for { + // Connection was successful. Continue the loop with the new response. // TODO: we should set a reasonable limit on the number of times we'll try // getting a response for a given request. // // Eventually, if we don't get the response, we should stop trying and // fail the request. - if resp != nil { - eventID, clientClosed := c.processStream(requestSummary, resp, forCall) - lastEventID = eventID + lastEventID, clientClosed := c.processStream(requestSummary, resp, forCall) - // If the connection was closed by the client, we're done. - if clientClosed { - return - } - // If the stream has ended, then do not reconnect if the stream is - // temporary (POST initiated SSE). - if lastEventID == "" && !persistent { - return - } + // If the connection was closed by the client, we're done. + if clientClosed { + return + } + // If the stream has ended, then do not reconnect if the stream is + // temporary (POST initiated SSE). + if lastEventID == "" && !persistent { + return } // The stream was interrupted or ended by the server. Attempt to reconnect. - newResp, err := c.reconnect(lastEventID) + newResp, err := c.connectSSE(lastEventID) if err != nil { // All reconnection attempts failed: fail the connection. c.fail(fmt.Errorf("%s: failed to reconnect (session ID: %v): %v", requestSummary, c.sessionID, err)) return } resp = newResp - if resp.StatusCode == http.StatusMethodNotAllowed && persistent { - // [§2.2.3]: "The server MUST either return Content-Type: - // text/event-stream in response to this HTTP GET, or else return HTTP - // 405 Method Not Allowed, indicating that the server does not offer an - // SSE stream at this endpoint." - // - // [§2.2.3]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#listening-for-messages-from-the-server - - // The server doesn't support the standalone SSE stream. - resp.Body.Close() + if err := c.checkResponse(requestSummary, resp); err != nil { + c.fail(err) return } - if resp.StatusCode == http.StatusNotFound && persistent && !c.strict { - // modelcontextprotocol/gosdk#393: some servers return NotFound instead - // of MethodNotAllowed for the standalone SSE stream. - // - // Treat this like MethodNotAllowed in non-strict mode. - if c.logger != nil { - c.logger.Warn("got 404 instead of 405 for standalonw SSE stream") - } - resp.Body.Close() - return - } - // (see equivalent handling in [streamableClientConn.Write]). - if resp.StatusCode == http.StatusNotFound { - c.fail(fmt.Errorf("%s: failed to reconnect (session ID: %v): %w", requestSummary, c.sessionID, errSessionMissing)) - return - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - resp.Body.Close() - c.fail(fmt.Errorf("%s: failed to reconnect: %v", requestSummary, http.StatusText(resp.StatusCode))) - return - } - // Reconnection was successful. Continue the loop with the new response. } } +// checkResponse checks the status code of the provided response, and +// translates it into an error if the request was unsuccessful. +// +// The response body is close if a non-nil error is returned. +func (c *streamableClientConn) checkResponse(requestSummary string, resp *http.Response) (err error) { + defer func() { + if err != nil { + resp.Body.Close() + } + }() + // §2.5.3: "The server MAY terminate the session at any time, after + // which it MUST respond to requests containing that session ID with HTTP + // 404 Not Found." + if resp.StatusCode == http.StatusNotFound { + // Return an errSessionMissing to avoid sending a redundant DELETE when the + // session is already gone. + return fmt.Errorf("%s: failed to connect (session ID: %v): %w", requestSummary, c.sessionID, errSessionMissing) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("%s: failed to connect: %v", requestSummary, http.StatusText(resp.StatusCode)) + } + return nil +} + // processStream reads from a single response body, sending events to the // incoming channel. It returns the ID of the last processed event and a flag // indicating if the connection was closed by the client. If resp is nil, it @@ -1620,6 +1637,7 @@ func (c *streamableClientConn) processStream(requestSummary string, resp *http.R defer resp.Body.Close() for evt, err := range scanEvents(resp.Body) { if err != nil { + // TODO: we should differentiate EOF from other errors here. break } @@ -1664,27 +1682,36 @@ func (c *streamableClientConn) processStream(requestSummary string, resp *http.R return lastEventID, false } -// reconnect handles the logic of retrying a connection with an exponential -// backoff strategy. It returns a new, valid HTTP response if successful, or -// an error if all retries are exhausted. -func (c *streamableClientConn) reconnect(lastEventID string) (*http.Response, error) { +// connectSSE handles the logic of connecting a text/event-stream connection. +// +// If lastEventID is set, it is the last-event ID of a stream being resumed. +// +// If connection fails, connectSSE retries with an exponential backoff +// strategy. It returns a new, valid HTTP response if successful, or an error +// if all retries are exhausted. +func (c *streamableClientConn) connectSSE(lastEventID string) (*http.Response, error) { var finalErr error - - // We can reach the 'reconnect' path through the standlone SSE request, in which case - // lastEventID will be "". - // - // In this case, we need an initial attempt. + // If lastEventID is set, we've already connected successfully once, so + // consider that to be the first attempt. attempt := 0 if lastEventID != "" { attempt = 1 } - for ; attempt <= c.maxRetries; attempt++ { select { case <-c.done: return nil, fmt.Errorf("connection closed by client during reconnect") case <-time.After(calculateReconnectDelay(attempt)): - resp, err := c.establishSSE(lastEventID) + req, err := http.NewRequestWithContext(c.ctx, http.MethodGet, c.url, nil) + if err != nil { + return nil, err + } + c.setMCPHeaders(req) + if lastEventID != "" { + req.Header.Set("Last-Event-ID", lastEventID) + } + req.Header.Set("Accept", "text/event-stream") + resp, err := c.client.Do(req) if err != nil { finalErr = err // Store the error and try again. continue @@ -1692,11 +1719,11 @@ func (c *streamableClientConn) reconnect(lastEventID string) (*http.Response, er return resp, nil } } - // If the loop completes, all retries have failed. + // If the loop completes, all retries have failed, or the client is closing. if finalErr != nil { return nil, fmt.Errorf("connection failed after %d attempts: %w", c.maxRetries, finalErr) } - return nil, fmt.Errorf("connection failed after %d attempts", c.maxRetries) + return nil, fmt.Errorf("connection aborted after %d attempts", c.maxRetries) } // Close implements the [Connection] interface. @@ -1723,23 +1750,6 @@ func (c *streamableClientConn) Close() error { return c.closeErr } -// establishSSE establishes the persistent SSE listening stream. -// It is used for reconnect attempts using the Last-Event-ID header to -// resume a broken stream where it left off. -func (c *streamableClientConn) establishSSE(lastEventID string) (*http.Response, error) { - req, err := http.NewRequestWithContext(c.ctx, http.MethodGet, c.url, nil) - if err != nil { - return nil, err - } - c.setMCPHeaders(req) - if lastEventID != "" { - req.Header.Set("Last-Event-ID", lastEventID) - } - req.Header.Set("Accept", "text/event-stream") - - return c.client.Do(req) -} - // calculateReconnectDelay calculates a delay using exponential backoff with full jitter. func calculateReconnectDelay(attempt int) time.Duration { if attempt == 0 { diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 4a4f5c6..42472c5 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -13,7 +13,6 @@ import ( "strings" "sync" "testing" - "time" "github.com/google/go-cmp/cmp" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" @@ -156,7 +155,6 @@ func TestStreamableClientTransportLifecycle(t *testing.T) { header: header{ "Content-Type": "text/event-stream", }, - optional: true, wantProtocolVersion: latestProtocolVersion, }, {"DELETE", "123", ""}: {}, @@ -205,8 +203,7 @@ func TestStreamableClientRedundantDelete(t *testing.T) { wantProtocolVersion: latestProtocolVersion, }, {"GET", "123", ""}: { - status: http.StatusMethodNotAllowed, - optional: true, + status: http.StatusMethodNotAllowed, }, {"POST", "123", methodListTools}: { status: http.StatusNotFound, @@ -268,14 +265,6 @@ func TestStreamableClientGETHandling(t *testing.T) { status: test.status, wantProtocolVersion: latestProtocolVersion, }, - {"POST", "123", methodListTools}: { - header: header{ - "Content-Type": "application/json", - sessionIDHeader: "123", - }, - body: jsonBody(t, resp(2, &ListToolsResult{Tools: []*Tool{}}, nil)), - optional: true, - }, {"DELETE", "123", ""}: {optional: true}, }, } @@ -285,36 +274,18 @@ func TestStreamableClientGETHandling(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) session, err := client.Connect(ctx, transport, nil) - if err != nil { - t.Fatalf("client.Connect() failed: %v", err) + if err == nil { + defer session.Close() } - - // Since we need the client to observe the result of the hanging GET, - // wait for all requests to be handled. - start := time.Now() - delay := 1 * time.Millisecond - for range 10 { - if len(fake.missingRequests()) == 0 { - break + if test.wantErrorContaining != "" { + if err == nil { + t.Fatalf("Connect succeeded unexpectedly, want error containing %q", test.wantErrorContaining) + } + if got := err.Error(); !strings.Contains(got, test.wantErrorContaining) { + t.Errorf("Connect error = %q, want containing %q", got, test.wantErrorContaining) } - time.Sleep(delay) - delay *= 2 - } - if missing := fake.missingRequests(); len(missing) > 0 { - t.Errorf("did not receive expected requests after %s: %v", time.Since(start), missing) - } - - _, err = session.ListTools(ctx, nil) - if (err != nil) != (test.wantErrorContaining != "") { - t.Errorf("After initialization, got error %v, want containing %q", err, test.wantErrorContaining) } else if err != nil { - if !strings.Contains(err.Error(), test.wantErrorContaining) { - t.Errorf("After initialization, got error %s, want containing %q", err, test.wantErrorContaining) - } - } - - if err := session.Close(); err != nil { - t.Errorf("closing session: %v", err) + t.Fatalf("Connect failed: %v", err) } }) } @@ -329,13 +300,12 @@ func TestStreamableClientStrictness(t *testing.T) { initializedStatus int getStatus int wantConnectError bool - wantListError bool }{ - {"conformant server", true, http.StatusAccepted, http.StatusMethodNotAllowed, false, false}, - {"strict initialized", true, http.StatusOK, http.StatusMethodNotAllowed, true, false}, - {"unstrict initialized", false, http.StatusOK, http.StatusMethodNotAllowed, false, false}, - {"strict GET", true, http.StatusAccepted, http.StatusNotFound, false, true}, - {"unstrict GET", false, http.StatusOK, http.StatusNotFound, false, false}, + {"conformant server", true, http.StatusAccepted, http.StatusMethodNotAllowed, false}, + {"strict initialized", true, http.StatusOK, http.StatusMethodNotAllowed, true}, + {"unstrict initialized", false, http.StatusOK, http.StatusMethodNotAllowed, false}, + {"strict GET", true, http.StatusAccepted, http.StatusNotFound, true}, + {"unstrict GET", false, http.StatusOK, http.StatusNotFound, false}, } for _, test := range tests { t.Run(test.label, func(t *testing.T) { @@ -383,23 +353,9 @@ func TestStreamableClientStrictness(t *testing.T) { if err != nil { return } - // Since we need the client to observe the result of the hanging GET, - // wait for all requests to be handled. - start := time.Now() - delay := 1 * time.Millisecond - for range 10 { - if len(fake.missingRequests()) == 0 { - break - } - time.Sleep(delay) - delay *= 2 - } - if missing := fake.missingRequests(); len(missing) > 0 { - t.Errorf("did not receive expected requests after %s: %v", time.Since(start), missing) - } _, err = session.ListTools(ctx, nil) - if (err != nil) != test.wantListError { - t.Errorf("ListTools returned error %v; want error: %t", err, test.wantListError) + if err != nil { + t.Errorf("ListTools failed: %v", err) } if err := session.Close(); err != nil { t.Errorf("closing session: %v", err) diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 0f38a0f..bd5d2c5 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -594,14 +594,7 @@ func TestServerInitiatedSSE(t *testing.T) { notifications := make(chan string) server := NewServer(testImpl, nil) - opts := &StreamableHTTPOptions{ - // TODO(#583): for now, this is required for guaranteed message delivery. - // However, it shouldn't be necessary to use replay here, as we should be - // guaranteed that the standalone SSE stream is started by the time the - // client is connected. - EventStore: NewMemoryEventStore(nil), - } - httpServer := httptest.NewServer(mustNotPanic(t, NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, opts))) + httpServer := httptest.NewServer(mustNotPanic(t, NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil))) defer httpServer.Close() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) From d256a9c1094ee4044dc3fa6e55d3559df0047107 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Mon, 27 Oct 2025 16:29:16 -0400 Subject: [PATCH 116/418] mcp: don't persist failed streamable sessions (#605) If a stateful streamable session fails to initialize, it is unusable. Avoid allocating resources by closing the session immediately. Also: - Fix a bug where InitializeParams is not guarded with its mutex. - Fix a resource leak where sessions are persisted with session id "", even though they are unaddressable. - Add a relevant benchmark, and update tests. Fixes #578 --- mcp/server.go | 8 ++++- mcp/streamable.go | 14 ++++++-- mcp/streamable_bench_test.go | 65 ++++++++++++++++++++++++++++++++++++ mcp/streamable_test.go | 33 +++++++++++++++--- 4 files changed, 113 insertions(+), 7 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index 29be8ff..4a7bc89 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1145,7 +1145,13 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, return handleReceive(ctx, ss, req) } -func (ss *ServerSession) InitializeParams() *InitializeParams { return ss.state.InitializeParams } +// InitializeParams returns the InitializeParams provided during the client's +// initial connection. +func (ss *ServerSession) InitializeParams() *InitializeParams { + ss.mu.Lock() + defer ss.mu.Unlock() + return ss.state.InitializeParams +} func (ss *ServerSession) initialize(ctx context.Context, params *InitializeParams) (*InitializeResult, error) { if params == nil { diff --git a/mcp/streamable.go b/mcp/streamable.go index 190b582..178b246 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -324,10 +324,13 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque logger: h.opts.Logger, } + // Sessions without a session ID are also stateless: there's no way to + // address them. + stateless := h.opts.Stateless || sessionID == "" // To support stateless mode, we initialize the session with a default // state, so that it doesn't reject subsequent requests. var connectOpts *ServerSessionOptions - if h.opts.Stateless { + if stateless { // Peek at the body to see if it is initialize or initialized. // We want those to be handled as usual. var hasInitialize, hasInitialized bool @@ -405,7 +408,7 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque transport: transport, } - if h.opts.Stateless { + if stateless { // Stateless mode: close the session when the request exits. defer session.Close() // close the fake session after handling the request } else { @@ -424,6 +427,13 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque h.mu.Lock() h.sessions[transport.SessionID] = sessInfo h.mu.Unlock() + defer func() { + // If initialization failed, clean up the session (#578). + if session.InitializeParams() == nil { + // Initialization failed. + session.Close() + } + }() } } diff --git a/mcp/streamable_bench_test.go b/mcp/streamable_bench_test.go index bf82ee5..6157e98 100644 --- a/mcp/streamable_bench_test.go +++ b/mcp/streamable_bench_test.go @@ -6,9 +6,15 @@ package mcp_test import ( "context" + "flag" + "log" "net/http" "net/http/httptest" + "os" "reflect" + "runtime" + "runtime/pprof" + "strings" "testing" "github.com/google/jsonschema-go/jsonschema" @@ -65,3 +71,62 @@ func BenchmarkStreamableServing(b *testing.B) { } } } + +var streamableHeap = flag.String("streamable_heap", "", "if set, write streamable heap profiles with this prefix") + +func BenchmarkStreamableServing_BadSessions(b *testing.B) { + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + + handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { + return server + }, &mcp.StreamableHTTPOptions{JSONResponse: true}) + + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if *streamableHeap != "" { + writeHeap := func(file string) { + // GC a couple times to ensure accurate heap. + runtime.GC() + runtime.GC() + f, err := os.Create(file) + if err != nil { + log.Fatal("could not create memory profile: ", err) + } + defer func() { + if err := f.Close(); err != nil { + b.Errorf("writing heap file %q: %v", file, err) + } + }() + if err := pprof.Lookup("heap").WriteTo(f, 0); err != nil { + b.Errorf("could not write heap profile: %v", err) + } + } + writeHeap(*streamableHeap + ".before") + defer writeHeap(*streamableHeap + ".after") + } + + b.ResetTimer() + for range b.N { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, httpServer.URL, strings.NewReader("{}")) + if err != nil { + b.Fatal(err) + } + req.Header.Add("Accept", "application/json") + req.Header.Add("Accept", "text/event-stream") + resp, err := http.DefaultClient.Do(req) + if err != nil { + b.Fatal(err) + } + if got, want := resp.StatusCode, http.StatusBadRequest; got != want { + b.Fatalf("POST got status %d, want %d", got, want) + } + if got := resp.Header.Get("Mcp-Session-Id"); got != "" { + b.Fatalf("POST got unexpected session ID") + } + resp.Body.Close() + } +} diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index bd5d2c5..0579f0c 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -690,10 +690,11 @@ func TestStreamableServerTransport(t *testing.T) { } tests := []struct { - name string - replay bool // if set, use a MemoryEventStore to enable stream replay - tool func(*testing.T, context.Context, *ServerSession) - requests []streamableRequest // http requests + name string + replay bool // if set, use a MemoryEventStore to enable stream replay + tool func(*testing.T, context.Context, *ServerSession) + requests []streamableRequest // http requests + wantSessions int // number of sessions expected after the test }{ { name: "basic", @@ -707,6 +708,19 @@ func TestStreamableServerTransport(t *testing.T) { wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)}, }, }, + wantSessions: 1, + }, + { + name: "uninitialized", + requests: []streamableRequest{ + { + method: "POST", + messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})}, + wantStatusCode: http.StatusOK, + wantBodyContaining: "invalid during session initialization", + }, + }, + wantSessions: 0, }, { name: "accept headers", @@ -741,6 +755,7 @@ func TestStreamableServerTransport(t *testing.T) { wantMessages: []jsonrpc.Message{resp(4, &CallToolResult{Content: []Content{}}, nil)}, }, }, + wantSessions: 1, }, { name: "protocol version headers", @@ -756,6 +771,7 @@ func TestStreamableServerTransport(t *testing.T) { wantSessionID: false, // could be true, but shouldn't matter }, }, + wantSessions: 1, }, { name: "batch rejected on 2025-06-18", @@ -775,6 +791,7 @@ func TestStreamableServerTransport(t *testing.T) { wantBodyContaining: "batch", }, }, + wantSessions: 1, }, { name: "batch accepted on 2025-03-26", @@ -797,6 +814,7 @@ func TestStreamableServerTransport(t *testing.T) { }, }, }, + wantSessions: 1, }, { name: "tool notification", @@ -821,6 +839,7 @@ func TestStreamableServerTransport(t *testing.T) { }, }, }, + wantSessions: 1, }, { name: "tool upcall", @@ -853,6 +872,7 @@ func TestStreamableServerTransport(t *testing.T) { }, }, }, + wantSessions: 1, }, { name: "background", @@ -915,6 +935,7 @@ func TestStreamableServerTransport(t *testing.T) { headers: map[string][]string{"Accept": nil}, }, }, + wantSessions: 0, // session deleted }, { name: "errors", @@ -946,6 +967,7 @@ func TestStreamableServerTransport(t *testing.T) { })}, }, }, + wantSessions: 0, }, } @@ -972,6 +994,9 @@ func TestStreamableServerTransport(t *testing.T) { defer handler.closeAll() testStreamableHandler(t, handler, test.requests) + if got := len(slices.Collect(server.Sessions())); got != test.wantSessions { + t.Errorf("after test, got %d sessions, want %d", got, test.wantSessions) + } }) } } From 72afbc91bd0d3b6b9fd2d2b6a21556a28c668b5b Mon Sep 17 00:00:00 2001 From: Ruturaj Dattatray Kadam <112586620+rutu1717@users.noreply.github.com> Date: Wed, 29 Oct 2025 19:38:40 +0530 Subject: [PATCH 117/418] auth: change OAuthHandler to take http Request and Response (#603) Change OAuthHandler signature from func(context.Context, OAuthHandlerArgs) to func(req *http.Request, res *http.Response). - Remove OAuthHandlerArgs struct - Update HTTPTransport to pass req and resp to handler - Update tests to use new signature - Handler can now call oauthex.GetProtectedResourceMetadataFromHeader with proper validation against request URL This change fixes an impedance mismatch between OAuthHandler and the protected resource metadata functions of the oauthex package. The new signature allows handlers to properly validate resource metadata against the request URL, as required by RFC 9728. Fixes #600 --- auth/client.go | 26 ++++---------------------- auth/client_test.go | 17 +++++++++-------- 2 files changed, 13 insertions(+), 30 deletions(-) diff --git a/auth/client.go b/auth/client.go index 3ed6b69..acadc51 100644 --- a/auth/client.go +++ b/auth/client.go @@ -8,26 +8,19 @@ package auth import ( "bytes" - "context" "errors" "io" "net/http" "sync" - "github.com/modelcontextprotocol/go-sdk/oauthex" "golang.org/x/oauth2" ) // An OAuthHandler conducts an OAuth flow and returns a [oauth2.TokenSource] if the authorization // is approved, or an error if not. -type OAuthHandler func(context.Context, OAuthHandlerArgs) (oauth2.TokenSource, error) - -// OAuthHandlerArgs are arguments to an [OAuthHandler]. -type OAuthHandlerArgs struct { - // The URL to fetch protected resource metadata, extracted from the WWW-Authenticate header. - // Empty if not present or there was an error obtaining it. - ResourceMetadataURL string -} +// The handler receives the HTTP request and response that triggered the authentication flow. +// To obtain the protected resource metadata, call [oauthex.GetProtectedResourceMetadataFromHeader]. +type OAuthHandler func(req *http.Request, res *http.Response) (oauth2.TokenSource, error) // HTTPTransport is an [http.RoundTripper] that follows the MCP // OAuth protocol when it encounters a 401 Unauthorized response. @@ -112,10 +105,7 @@ func (t *HTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { // TODO: We hold the lock for the entire OAuth flow. This could be a long // time. Is there a better way? if _, ok := t.opts.Base.(*oauth2.Transport); !ok { - authHeaders := resp.Header[http.CanonicalHeaderKey("WWW-Authenticate")] - ts, err := t.handler(req.Context(), OAuthHandlerArgs{ - ResourceMetadataURL: extractResourceMetadataURL(authHeaders), - }) + ts, err := t.handler(req, resp) if err != nil { return nil, err } @@ -131,11 +121,3 @@ func (t *HTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { return t.opts.Base.RoundTrip(req) } - -func extractResourceMetadataURL(authHeaders []string) string { - cs, err := oauthex.ParseWWWAuthenticate(authHeaders) - if err != nil { - return "" - } - return oauthex.ResourceMetadataURL(cs) -} diff --git a/auth/client_test.go b/auth/client_test.go index e1b7dc7..4f3172d 100644 --- a/auth/client_test.go +++ b/auth/client_test.go @@ -7,7 +7,6 @@ package auth import ( - "context" "errors" "fmt" "io" @@ -65,10 +64,11 @@ func TestHTTPTransport(t *testing.T) { t.Run("successful auth flow", func(t *testing.T) { var handlerCalls int - handler := func(ctx context.Context, args OAuthHandlerArgs) (oauth2.TokenSource, error) { + handler := func(req *http.Request, res *http.Response) (oauth2.TokenSource, error) { handlerCalls++ - if args.ResourceMetadataURL != "http://metadata.example.com" { - t.Errorf("handler got metadata URL %q, want %q", args.ResourceMetadataURL, "http://metadata.example.com") + // Verify that the response has the expected WWW-Authenticate header + if res.Header.Get("WWW-Authenticate") != `Bearer resource_metadata="http://metadata.example.com"` { + t.Errorf("handler got WWW-Authenticate header %q", res.Header.Get("WWW-Authenticate")) } return fakeTokenSource, nil } @@ -108,9 +108,10 @@ func TestHTTPTransport(t *testing.T) { }) t.Run("request body is cloned", func(t *testing.T) { - handler := func(ctx context.Context, args OAuthHandlerArgs) (oauth2.TokenSource, error) { - if args.ResourceMetadataURL != "http://metadata.example.com" { - t.Errorf("handler got metadata URL %q, want %q", args.ResourceMetadataURL, "http://metadata.example.com") + handler := func(req *http.Request, res *http.Response) (oauth2.TokenSource, error) { + // Verify that the response has the expected WWW-Authenticate header + if res.Header.Get("WWW-Authenticate") != `Bearer resource_metadata="http://metadata.example.com"` { + t.Errorf("handler got WWW-Authenticate header %q", res.Header.Get("WWW-Authenticate")) } return fakeTokenSource, nil } @@ -134,7 +135,7 @@ func TestHTTPTransport(t *testing.T) { t.Run("handler returns error", func(t *testing.T) { handlerErr := errors.New("user rejected auth") - handler := func(ctx context.Context, args OAuthHandlerArgs) (oauth2.TokenSource, error) { + handler := func(req *http.Request, res *http.Response) (oauth2.TokenSource, error) { return nil, handlerErr } From 32d84d8421b315633f070246a0833531489cf77f Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Wed, 29 Oct 2025 10:57:45 -0400 Subject: [PATCH 118/418] docs: document known rough edges (#609) Start keeping a log of rough edges we've discovered post v1.0.0, so that they're discoverable (along with their workarounds), and so that we can fix them in v2. --- docs/README.md | 6 ++++++ docs/rough_edges.md | 12 ++++++++++++ internal/docs/README.src.md | 6 ++++++ internal/docs/doc.go | 1 + internal/docs/rough_edges.src.md | 11 +++++++++++ 5 files changed, 36 insertions(+) create mode 100644 docs/rough_edges.md create mode 100644 internal/docs/rough_edges.src.md diff --git a/docs/README.md b/docs/README.md index 9f32507..0e8259c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -38,3 +38,9 @@ protocol. # TroubleShooting See [troubleshooting.md](troubleshooting.md) for a troubleshooting guide. + +# Rough edges + +See [rough_edges.md](rough_edges.md) for a list of rough edges or API +oversights that can't be addressed due to our compatibility promise. We'll +revisit these if/when we move to a v2 of the SDK. diff --git a/docs/rough_edges.md b/docs/rough_edges.md new file mode 100644 index 0000000..7bb7732 --- /dev/null +++ b/docs/rough_edges.md @@ -0,0 +1,12 @@ + +# Rough edges: API decisions to reconsider for v2 + +This file collects a list of API oversights or rough edges that we've uncovered +post v1.0.0, along with their current workarounds. These issues can't be +addressed without breaking backward compatibility, but we'll revisit them for +v2. + +- `EventStore.Open` is unnecessary. This was an artifact of an earlier version + of the SDK where event persistence and delivery were combined. + + **Workaround**: `Open` may be implemented as a no-op. diff --git a/internal/docs/README.src.md b/internal/docs/README.src.md index 7f7b224..e70596b 100644 --- a/internal/docs/README.src.md +++ b/internal/docs/README.src.md @@ -37,3 +37,9 @@ protocol. # TroubleShooting See [troubleshooting.md](troubleshooting.md) for a troubleshooting guide. + +# Rough edges + +See [rough_edges.md](rough_edges.md) for a list of rough edges or API +oversights that can't be addressed due to our compatibility promise. We'll +revisit these if/when we move to a v2 of the SDK. diff --git a/internal/docs/doc.go b/internal/docs/doc.go index 7b23ad6..3d87686 100644 --- a/internal/docs/doc.go +++ b/internal/docs/doc.go @@ -8,6 +8,7 @@ //go:generate weave -o ../../docs/client.md ./client.src.md //go:generate weave -o ../../docs/server.md ./server.src.md //go:generate weave -o ../../docs/troubleshooting.md ./troubleshooting.src.md +//go:generate weave -o ../../docs/rough_edges.md ./rough_edges.src.md // The doc package generates the documentation at /doc, via go:generate. // diff --git a/internal/docs/rough_edges.src.md b/internal/docs/rough_edges.src.md new file mode 100644 index 0000000..150509b --- /dev/null +++ b/internal/docs/rough_edges.src.md @@ -0,0 +1,11 @@ +# Rough edges: API decisions to reconsider for v2 + +This file collects a list of API oversights or rough edges that we've uncovered +post v1.0.0, along with their current workarounds. These issues can't be +addressed without breaking backward compatibility, but we'll revisit them for +v2. + +- `EventStore.Open` is unnecessary. This was an artifact of an earlier version + of the SDK where event persistence and delivery were combined. + + **Workaround**: `Open` may be implemented as a no-op. From d297272705950555ccfac1772fcd9ff30bc4a0a1 Mon Sep 17 00:00:00 2001 From: Xie Zhihao Date: Mon, 3 Nov 2025 23:12:28 +0800 Subject: [PATCH 119/418] mcp: relax SSE connection response handling in non-strict mode (#611) Some MCP servers do not support SSE, but when receiving a request, they return 400 Bad Request or 404 Not Found instead of the standard 405 Method Not Allowed. The TypeScript and Python SDK ignore this error, while the Go SDK terminates the session. This patch addresses the issue in the non-strict mode. Fixes #610 --------- Signed-off-by: Xie Zhihao --- mcp/streamable.go | 9 +++++---- mcp/streamable_client_test.go | 12 ++++++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 178b246..12e24ff 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1377,13 +1377,14 @@ func (c *streamableClientConn) connectStandaloneSSE() { resp.Body.Close() return } - if resp.StatusCode == http.StatusNotFound && !c.strict { - // modelcontextprotocol/gosdk#393: some servers return NotFound instead - // of MethodNotAllowed for the standalone SSE stream. + if resp.StatusCode >= 400 && resp.StatusCode < 500 && !c.strict { + // modelcontextprotocol/go-sdk#393,#610: some servers return NotFound or + // other status codes instead of MethodNotAllowed for the standalone SSE + // stream. // // Treat this like MethodNotAllowed in non-strict mode. if c.logger != nil { - c.logger.Warn("got 404 instead of 405 for standalone SSE stream") + c.logger.Warn(fmt.Sprintf("got %d instead of 405 for standalone SSE stream", resp.StatusCode)) } resp.Body.Close() return diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 42472c5..6d3d83b 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -239,7 +239,11 @@ func TestStreamableClientGETHandling(t *testing.T) { }{ {http.StatusOK, ""}, {http.StatusMethodNotAllowed, ""}, - {http.StatusBadRequest, "standalone SSE"}, + // The client error status code is not treated as an error in non-strict + // mode. + {http.StatusNotFound, ""}, + {http.StatusBadRequest, ""}, + {http.StatusInternalServerError, "standalone SSE"}, } for _, test := range tests { @@ -305,7 +309,11 @@ func TestStreamableClientStrictness(t *testing.T) { {"strict initialized", true, http.StatusOK, http.StatusMethodNotAllowed, true}, {"unstrict initialized", false, http.StatusOK, http.StatusMethodNotAllowed, false}, {"strict GET", true, http.StatusAccepted, http.StatusNotFound, true}, - {"unstrict GET", false, http.StatusOK, http.StatusNotFound, false}, + // The client error status code is not treated as an error in non-strict + // mode. + {"unstrict GET on StatusNotFound", false, http.StatusOK, http.StatusNotFound, false}, + {"unstrict GET on StatusBadRequest", false, http.StatusOK, http.StatusBadRequest, false}, + {"GET on InternlServerError", false, http.StatusOK, http.StatusInternalServerError, true}, } for _, test := range tests { t.Run(test.label, func(t *testing.T) { From 35dfcf21c9880b0c7b25fadb512ddd2828778694 Mon Sep 17 00:00:00 2001 From: Spring_MT Date: Wed, 12 Nov 2025 01:56:52 +0900 Subject: [PATCH 120/418] examples: Fix SSE streaming issue by moving logging from HTTP to MCP middlewarre (#614) In `examples/http`, previous HTTP-level logging middleware blocked SSE streaming because `handler.ServeHTTP()` does not return until the connection closes for GET requests (SSE streams). This prevented response headers from being sent to the client. Changes: - Replace HTTP-level logging handler with MCP middleware pattern - Implement logging at the MCP protocol layer using mcp.Middleware - Add middleware to server using AddReceivingMiddleware() - Simplify main.go by removing HTTP handler wrapping This allows SSE streams to work properly while still providing detailed logging for all MCP protocol-level method calls. Fixes #632 --- examples/http/logging_middleware.go | 70 +++++++++++++++-------------- examples/http/main.go | 9 ++-- 2 files changed, 41 insertions(+), 38 deletions(-) diff --git a/examples/http/logging_middleware.go b/examples/http/logging_middleware.go index 4266012..97d8ad3 100644 --- a/examples/http/logging_middleware.go +++ b/examples/http/logging_middleware.go @@ -5,47 +5,49 @@ package main import ( + "context" "log" - "net/http" "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" ) -// responseWriter wraps http.ResponseWriter to capture the status code. -type responseWriter struct { - http.ResponseWriter - statusCode int -} +// createLoggingMiddleware creates an MCP middleware that logs method calls. +func createLoggingMiddleware() mcp.Middleware { + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func( + ctx context.Context, + method string, + req mcp.Request, + ) (mcp.Result, error) { + start := time.Now() + sessionID := req.GetSession().ID() -func (rw *responseWriter) WriteHeader(code int) { - rw.statusCode = code - rw.ResponseWriter.WriteHeader(code) -} + // Log request details. + log.Printf("[REQUEST] Session: %s | Method: %s", + sessionID, + method) -func loggingHandler(handler http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() + // Call the actual handler. + result, err := next(ctx, method, req) - // Create a response writer wrapper to capture status code. - wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} + // Log response details. + duration := time.Since(start) - // Log request details. - log.Printf("[REQUEST] %s | %s | %s %s", - start.Format(time.RFC3339), - r.RemoteAddr, - r.Method, - r.URL.Path) + if err != nil { + log.Printf("[RESPONSE] Session: %s | Method: %s | Status: ERROR | Duration: %v | Error: %v", + sessionID, + method, + duration, + err) + } else { + log.Printf("[RESPONSE] Session: %s | Method: %s | Status: OK | Duration: %v", + sessionID, + method, + duration) + } - // Call the actual handler. - handler.ServeHTTP(wrapped, r) - - // Log response details. - duration := time.Since(start) - log.Printf("[RESPONSE] %s | %s | %s %s | Status: %d | Duration: %v", - time.Now().Format(time.RFC3339), - r.RemoteAddr, - r.Method, - r.URL.Path, - wrapped.statusCode, - duration) - }) + return result, err + } + } } diff --git a/examples/http/main.go b/examples/http/main.go index 188674a..ced4871 100644 --- a/examples/http/main.go +++ b/examples/http/main.go @@ -117,6 +117,9 @@ func runServer(url string) { Version: "1.0.0", }, nil) + // Add MCP-level logging middleware. + server.AddReceivingMiddleware(createLoggingMiddleware()) + // Add the cityTime tool. mcp.AddTool(server, &mcp.Tool{ Name: "cityTime", @@ -128,13 +131,11 @@ func runServer(url string) { return server }, nil) - handlerWithLogging := loggingHandler(handler) - log.Printf("MCP server listening on %s", url) log.Printf("Available tool: cityTime (cities: nyc, sf, boston)") - // Start the HTTP server with logging handler. - if err := http.ListenAndServe(url, handlerWithLogging); err != nil { + // Start the HTTP server. + if err := http.ListenAndServe(url, handler); err != nil { log.Fatalf("Server failed: %v", err) } } From faddd764aaf3d404e39899d5c31c7632e5046e07 Mon Sep 17 00:00:00 2001 From: Suraj Bobade Date: Thu, 13 Nov 2025 03:22:44 +0530 Subject: [PATCH 121/418] Implement SEP-973 (#570) mcp/protocol: Implement SEP-973 - Define Icon structure, which includes source, mimeType and sizes. - sizes is any array of strings. refer https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1531 - Support setting websiteUrl, icons for mcp.Implementation - Support setting icons for mcp.Prompt - Support setting icons for mcp.Tool - Support setting icons for mcp.Resource Fixes #552 --- examples/server/everything/main.go | 68 +++++- examples/server/everything/mcp.png | Bin 0 -> 2586 bytes mcp/conformance_test.go | 55 ++++- mcp/content.go | 5 + mcp/content_test.go | 53 ++++- mcp/protocol.go | 29 +++ .../spec-sep-973-additional-metadata.txtar | 216 ++++++++++++++++++ 7 files changed, 417 insertions(+), 9 deletions(-) create mode 100644 examples/server/everything/mcp.png create mode 100644 mcp/testdata/conformance/server/spec-sep-973-additional-metadata.txtar diff --git a/examples/server/everything/main.go b/examples/server/everything/main.go index 0b81919..f255ab4 100644 --- a/examples/server/everything/main.go +++ b/examples/server/everything/main.go @@ -7,6 +7,7 @@ package main import ( "context" + "encoding/base64" "flag" "fmt" "log" @@ -50,19 +51,28 @@ func main() { CompletionHandler: complete, // support completions by setting this handler } - server := mcp.NewServer(&mcp.Implementation{Name: "everything"}, opts) + // Optionally add an icon to the server implementation. + icons, err := iconToBase64DataURL("./mcp.png") + if err != nil { + log.Fatalf("failed to read icon: %v", err) + } + + server := mcp.NewServer(&mcp.Implementation{Name: "everything", WebsiteURL: "https://example.com", Icons: icons}, opts) // Add tools that exercise different features of the protocol. mcp.AddTool(server, &mcp.Tool{Name: "greet", Description: "say hi"}, contentTool) - mcp.AddTool(server, &mcp.Tool{Name: "greet (structured)"}, structuredTool) // returns structured output - mcp.AddTool(server, &mcp.Tool{Name: "ping"}, pingingTool) // performs a ping - mcp.AddTool(server, &mcp.Tool{Name: "log"}, loggingTool) // performs a log - mcp.AddTool(server, &mcp.Tool{Name: "sample"}, samplingTool) // performs sampling - mcp.AddTool(server, &mcp.Tool{Name: "elicit"}, elicitingTool) // performs elicitation - mcp.AddTool(server, &mcp.Tool{Name: "roots"}, rootsTool) // lists roots + mcp.AddTool(server, &mcp.Tool{Name: "greet (structured)"}, structuredTool) // returns structured output + mcp.AddTool(server, &mcp.Tool{Name: "greet (with Icons)", Icons: icons}, structuredTool) // tool with icons + mcp.AddTool(server, &mcp.Tool{Name: "greet (content with ResourceLink)"}, resourceLinkContentTool(icons)) // tool that returns content with a resource link + mcp.AddTool(server, &mcp.Tool{Name: "ping"}, pingingTool) // performs a ping + mcp.AddTool(server, &mcp.Tool{Name: "log"}, loggingTool) // performs a log + mcp.AddTool(server, &mcp.Tool{Name: "sample"}, samplingTool) // performs sampling + mcp.AddTool(server, &mcp.Tool{Name: "elicit"}, elicitingTool) // performs elicitation + mcp.AddTool(server, &mcp.Tool{Name: "roots"}, rootsTool) // lists roots // Add a basic prompt. server.AddPrompt(&mcp.Prompt{Name: "greet"}, prompt) + server.AddPrompt(&mcp.Prompt{Name: "greet (with Icons)", Icons: icons}, prompt) // greet prompt with icons // Add an embedded resource. server.AddResource(&mcp.Resource{ @@ -70,6 +80,20 @@ func main() { MIMEType: "text/plain", URI: "embedded:info", }, embeddedResource) + server.AddResource(&mcp.Resource{ // text resource with icons + Name: "info (with Icons)", + MIMEType: "text/plain", + URI: "embedded:info", + Icons: icons, + }, embeddedResource) + + // Add a resource template. + server.AddResourceTemplate(&mcp.ResourceTemplate{ + Name: "Resource template (with Icon)", + MIMEType: "text/plain", + URITemplate: "http://example.com/~{resource_name}/", + Icons: icons, + }, embeddedResource) // Serve over stdio, or streamable HTTP if -http is set. if *httpAddr != "" { @@ -140,6 +164,23 @@ func contentTool(ctx context.Context, req *mcp.CallToolRequest, args args) (*mcp }, nil, nil } +// resourceLinkContentTool returns a ResourceLink content with icons. +func resourceLinkContentTool(icons []mcp.Icon) func(ctx context.Context, req *mcp.CallToolRequest, args args) (*mcp.CallToolResult, any, error) { + return func(ctx context.Context, req *mcp.CallToolRequest, args args) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.ResourceLink{ + Name: "greeting", + Title: "A friendly greeting", + MIMEType: "text/plain", + URI: "data:text/plain,Hi%20" + url.PathEscape(args.Name), + Icons: icons, + }, + }, + }, nil, nil + } +} + type result struct { Message string `json:"message" jsonschema:"the message to convey"` } @@ -222,3 +263,16 @@ func complete(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResul }, }, nil } + +func iconToBase64DataURL(path string) ([]mcp.Icon, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return []mcp.Icon{{ + Source: "data:image/png;base64," + base64.StdEncoding.EncodeToString(data), + MIMEType: "image/png", + Sizes: []string{"48x48"}, + Theme: "light", // or "dark" or empty + }}, nil +} diff --git a/examples/server/everything/mcp.png b/examples/server/everything/mcp.png new file mode 100644 index 0000000000000000000000000000000000000000..8e08571d326bba9110ea3ced50e6769d0e100b21 GIT binary patch literal 2586 zcmV+#3gz{QP)00001b5ch_0Itp) z=>Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAuFgh@m}RA>dgnsuyHI}pZkcXxMp z3xS})Ew~fh-Q5BSF2Nx<1PKr{xCR2jg1fuB^WHD-kTP%1F5BI^%l+eK&e^ZinRYt; zIz8e33H{%hGiSz(851Q+lyLw3#f}|&+O%mGFJ25v&*6fi#ha2%n>LjxQ^uI?INsK3#6(-0IT)%!jXU?3;xMt0ooj7sA*!Stvr$dJh)v8rX!6r?bq>Qt945kBDuU=(z zrCBWB`Sa&{vt!)2adIjWCrhk5wy>;xo@7ArG zY%*oalt1I$zXr#F^XJcJ%$QM{LY7>;dbR5B+qZ8&e*BOxpFe+|GG$8BkevJT=T8an zDF?xUvuDqyPoLfxfe<4`jJ0dms=gO3T9h|$-a>^6u{p?}+O=zE+9peu%-?hrRtHX< zI+Z3(8e;)M%$PAZY}g=8L?$*2CVu?*4<0-)fO+%gnV+IXi+1hWHS?2=5GDu6EvZta zGTkFU6E<(&tU8`BVS>aULoN)4bz**H(cidn!~A3;1n$7GW5<#wPj31~h#NQVwr$%~ zucR7@L9SS_BAL{{#*ZIw0K^_EXY1Cjd-m*sEPed=(ZIY30XlHx$dRN;lbVhZ2#lRO zcdBkij~*>C$fZk{e){yOgq1H}USXU^W`nc_(O{k>Ude$taPZ(ka<=R>L4pK~nNmG` z_;86qE?c(jix)2>?7)Eokt0V|F!hKhZQ8VX^5ltZXk@<~*tc(A!h{KBABhqr+P{Cl z(dY<6h76GyWFqI)t5*{C@Zm%Ewyj#3#~e9wknUu2NA}NwUAuM>Dn=%Vvs9WuKfD-YY8I}6)jp+!JJ2Szz7i{xPKzuxla*(IIv^Kj(G9nNxdxMqeqWQ zvyl7r=_4`7)v8r{`}VDb-Me>>4MD;3=g&WR@?;cVzI^%Q$&(8fEGSp5oWc|+P(Z1& zbzsYuEpg(+k?Pn%j~_oS&0-Vn(W8gNAVb;|-G^*rtAsJ$EO(DNXUUQ!BsU2jF=B-5 z)XD+&&RDTxNiC^Ur)H3(A~-^qE?p!Bxo+LMA3l7Lu$wn;DzXjJo;!E0#3-4WXLB@~ z$sdm&KQh+njvJ$lWTS^Raixw@ueEA|_ zknPNwGfS99vJEdH>pL(OwA4_&dUeyB1Yq3r>cG;aOX#E}euyc-F03Co#y=r%zY8pP`W$8Kv2?XE$Ty!2u3|WCqz~?%cV_9#S2vvtGS= z5`)~SQzurlgt^H!+00G0i57XbL={C1Nx2D_V#SJ??jTgA7wy38*|X(5(k5TNe7A4k zHVqKS>vihXG3p$F!=XBD1x&V!7cZ_3#|(sw=bk-#8hifdz>FC))I1W1ckkXcXoPp~ z-cbTk*7xew%d9E~95C4~S+b09?<*IbM`#TB+h{CRs+7u@P%cFq-DI19?%lh$QQ`<}v5Fy+<6E_ArC`mQH+Nf) zZU1w?qv_7eWy_XHjjFa|Nw(E;L?N@S$Tn7DtLzIIf6UIMjI?z?9{`-5_%|cdIjRh;O3Y;2i<7tpb`_3ztp`HgMoTqsOOU z>i~)v71>jX5+x+#l|5k=8AgJU$wDfi85DP2ap=&YQk75H4+k(;P69S@!d$Up1ui;5*> zjq?aK!dqnr`-Uos!jLCUoTy;-kAXOVb`F`Of9ld6O{WOSk(^?LvIC~5N}?P9r%s)! zV17M@2kp@ao0lwWs_m3xIr78FpsN&+ixersJ%GBjL>os_mdfeVr;TPpkLZ-cq;ISl1_1vbdPfW-i;QIqOk_-llhl>|4_Gy_V zAhZrpiVWcU1548#SO^_pw^Ef3O2w;It@6u0pWqNWK+Qr%4mryG{ee&Wd;&t{0IXBq zBXKq8E{Xay&IaJ&GEfd%z%qvcs#K;qhj;fEPUhFJ2%&O-aF-RXjT<+@Qo~}Xx3-~j zfL&Iev!MQ5Cg6rQI|6@i6K8>8C^B=YBP|QXIu6w$c6{bSO_8FYXVTC*fEymarE`Cc z7T6@yD&!8(hnr3CsSZcVfQ^<7|Aqtfr}!NAMe5p0+T@q~mmI(*7_~X Date: Fri, 14 Nov 2025 09:48:23 +0800 Subject: [PATCH 122/418] Fix: Skip non-message SSE events in processStream (#637) Fixes #636 ## Problem The StreamableClientTransport tries to JSON-decode ALL SSE events, including ping events sent by servers for keep-alive purposes. This causes errors like 'invalid character 'p' looking for beginning of value' when encountering non-JSON event data. ## Solution Modified processStream to check evt.Name and skip events that are not 'message' events (or unnamed events, which default to 'message' per SSE spec). ## Changes - Added event name check in processStream before attempting JSON decode - Added test case for ping event filtering - Added test for scanning multiple events including ping events ## Testing - All existing tests pass - New tests verify ping events are properly filtered - Tested with DeepWiki MCP server which sends ping events --- mcp/streamable.go | 6 ++ mcp/streamable_test.go | 150 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/mcp/streamable.go b/mcp/streamable.go index 12e24ff..67cdc39 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1656,6 +1656,12 @@ func (c *streamableClientConn) processStream(requestSummary string, resp *http.R lastEventID = evt.ID } + // Skip non-message events (e.g., "ping" events used for keep-alive) + // According to SSE spec, events with no name default to "message" + if evt.Name != "" && evt.Name != "message" { + continue + } + msg, err := jsonrpc.DecodeMessage(evt.Data) if err != nil { c.fail(fmt.Errorf("%s: failed to decode event: %v", requestSummary, err)) diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 0579f0c..e9c0cbd 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1746,3 +1746,153 @@ func mustNotPanic(t *testing.T, h http.Handler) http.Handler { h.ServeHTTP(w, req) }) } + +// TestPingEventFiltering verifies that the streamable client correctly filters +// out SSE "ping" events, which are used for keep-alive but should not be +// treated as JSON-RPC messages. +// +// This test addresses issue #636: the client should skip non-"message" events +// according to the SSE specification. It tests the fix in processStream where +// events with evt.Name != "" && evt.Name != "message" are skipped. +func TestPingEventFiltering(t *testing.T) { + // This test verifies the low-level processStream filtering. + // We create a mock response with ping and message events. + + sseData := `event: ping +data: ping + +event: message +id: 1 +data: {"jsonrpc":"2.0","id":1,"result":{}} + +event: ping +data: keepalive + +` + + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(bytes.NewReader([]byte(sseData))), + } + + // Create a minimal streamableClientConn for testing + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + incoming := make(chan jsonrpc.Message, 10) + done := make(chan struct{}) + + conn := &streamableClientConn{ + ctx: ctx, + done: done, + incoming: incoming, + } + + // Create a test request + testReq := &jsonrpc.Request{ + ID: jsonrpc2.Int64ID(1), + Method: "test", + } + + // Process the stream + go conn.processStream("test", resp, testReq) + + // Collect messages with timeout + var messages []jsonrpc.Message + timeout := time.After(1 * time.Second) + +collectLoop: + for { + select { + case msg := <-incoming: + messages = append(messages, msg) + // We expect only 1 message (the response), not the ping events + if len(messages) >= 1 { + break collectLoop + } + case <-timeout: + break collectLoop + } + } + + // Verify we only received the actual message, not the ping events + if len(messages) != 1 { + t.Errorf("got %d messages, want 1 (ping events should be filtered)", len(messages)) + for i, msg := range messages { + t.Logf("message %d: %T", i, msg) + } + } + + // Verify the message is the response + if len(messages) > 0 { + resp, ok := messages[0].(*jsonrpc.Response) + if !ok { + t.Errorf("first message is %T, want *jsonrpc.Response", messages[0]) + } else if resp.ID.Raw() != int64(1) { + t.Errorf("response ID is %v, want 1", resp.ID.Raw()) + } + } +} + +// TestScanEventsPingFiltering is a unit test for the low-level event scanning +// with ping events to verify scanEvents properly parses all event types. +func TestScanEventsPingFiltering(t *testing.T) { + // Create SSE stream with mixed events + sseData := `event: ping +data: ping + +event: message +data: {"jsonrpc":"2.0","method":"test","params":{}} + +event: ping +data: keepalive + +event: message +data: {"jsonrpc":"2.0","method":"test2","params":{}} + +` + + reader := strings.NewReader(sseData) + var events []Event + + // Scan all events + for evt, err := range scanEvents(reader) { + if err != nil { + if err != io.EOF { + t.Fatalf("scanEvents error: %v", err) + } + break + } + events = append(events, evt) + } + + // Verify we got all 4 events + if len(events) != 4 { + t.Fatalf("got %d events, want 4", len(events)) + } + + // Verify event types + expectedNames := []string{"ping", "message", "ping", "message"} + for i, evt := range events { + if evt.Name != expectedNames[i] { + t.Errorf("event %d: got name %q, want %q", i, evt.Name, expectedNames[i]) + } + } + + // Verify that we can decode the message events but would fail on ping events + for i, evt := range events { + if evt.Name == "message" { + _, err := jsonrpc.DecodeMessage(evt.Data) + if err != nil { + t.Errorf("event %d: failed to decode message event: %v", i, err) + } + } else if evt.Name == "ping" { + // Ping events have non-JSON data and should fail decoding + _, err := jsonrpc.DecodeMessage(evt.Data) + if err == nil { + t.Errorf("event %d: ping event unexpectedly decoded as valid JSON-RPC", i) + } + } + } +} From 1943780255521f98ef57f083ae95b92fe727ee96 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Wed, 19 Nov 2025 17:01:50 -0500 Subject: [PATCH 123/418] mcp: validate tool names (#640) Implement SEP 986, enforcing restrictions on tool names. Since we're already at v1 we couldn't turn this into a panic, so use an error log instead. This is consistent with the typescript SDK. A note is left in our 'rough edges' doc to panic in v2. Fixes #621 --- docs/rough_edges.md | 6 +++ internal/docs/rough_edges.src.md | 6 +++ mcp/server.go | 3 ++ mcp/server_test.go | 65 ++++++++++++++++++++++++++++++++ mcp/shared.go | 2 + mcp/tool.go | 36 ++++++++++++++++++ mcp/tool_test.go | 50 ++++++++++++++++++++++++ 7 files changed, 168 insertions(+) diff --git a/docs/rough_edges.md b/docs/rough_edges.md index 7bb7732..a99e00a 100644 --- a/docs/rough_edges.md +++ b/docs/rough_edges.md @@ -10,3 +10,9 @@ v2. of the SDK where event persistence and delivery were combined. **Workaround**: `Open` may be implemented as a no-op. + +- Enforcing valid tool names: with + [SEP-986](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/986) + landing after the SDK was at v1, we missed an opportunity to panic on invalid + tool names. Instead, we have to simply produce an error log. In v2, we should + panic. diff --git a/internal/docs/rough_edges.src.md b/internal/docs/rough_edges.src.md index 150509b..d8c2c99 100644 --- a/internal/docs/rough_edges.src.md +++ b/internal/docs/rough_edges.src.md @@ -9,3 +9,9 @@ v2. of the SDK where event persistence and delivery were combined. **Workaround**: `Open` may be implemented as a no-op. + +- Enforcing valid tool names: with + [SEP-986](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/986) + landing after the SDK was at v1, we missed an opportunity to panic on invalid + tool names. Instead, we have to simply produce an error log. In v2, we should + panic. diff --git a/mcp/server.go b/mcp/server.go index 4a7bc89..6a3368e 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -191,6 +191,9 @@ func (s *Server) RemovePrompts(names ...string) { // Most users should use the top-level function [AddTool], which handles all these // responsibilities. func (s *Server) AddTool(t *Tool, h ToolHandler) { + if err := validateToolName(t.Name); err != nil { + s.opts.Logger.Error(fmt.Sprintf("AddTool: invalid tool name %q: %v", t.Name, err)) + } if t.InputSchema == nil { // This prevents the tool author from forgetting to write a schema where // one should be provided. If we papered over this by supplying the empty diff --git a/mcp/server_test.go b/mcp/server_test.go index ec6114d..d8c0df6 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -5,9 +5,11 @@ package mcp import ( + "bytes" "context" "encoding/json" "log" + "log/slog" "slices" "strings" "testing" @@ -490,6 +492,69 @@ func TestAddTool(t *testing.T) { } } +func TestAddToolNameValidation(t *testing.T) { + tests := []struct { + label string + name string + wantLogContaining string + }{ + { + label: "empty name", + name: "", + wantLogContaining: `tool name cannot be empty`, + }, + { + label: "long name", + name: strings.Repeat("a", 129), + wantLogContaining: "exceeds maximum length of 128 characters", + }, + { + label: "name with spaces", + name: "get user profile", + wantLogContaining: `tool name contains invalid characters: \" \"`, + }, + { + label: "name with multiple invalid chars", + name: "user name@domain,com", + wantLogContaining: `tool name contains invalid characters: \" \", \"@\", \",\"`, + }, + { + label: "name with unicode", + name: "tool-ñame", + wantLogContaining: `tool name contains invalid characters: \"ñ\"`, + }, + { + label: "valid name", + name: "valid-tool_name.123", + wantLogContaining: "", // No log expected + }, + } + for _, test := range tests { + t.Run(test.label, func(t *testing.T) { + var buf bytes.Buffer + s := NewServer(testImpl, &ServerOptions{ + Logger: slog.New(slog.NewTextHandler(&buf, nil)), + }) + + // Use the generic AddTool as it also calls validateToolName. + AddTool(s, &Tool{Name: test.name}, func(context.Context, *CallToolRequest, any) (*CallToolResult, any, error) { + return nil, nil, nil + }) + + logOutput := buf.String() + if test.wantLogContaining != "" { + if !strings.Contains(logOutput, test.wantLogContaining) { + t.Errorf("log output =\n%s\nwant containing %q", logOutput, test.wantLogContaining) + } + } else { + if logOutput != "" { + t.Errorf("expected empty log output, got %q", logOutput) + } + } + }) + } +} + type schema = jsonschema.Schema func testToolForSchema[In, Out any](t *testing.T, tool *Tool, in string, out Out, wantIn, wantOut any, wantErrContaining string) { diff --git a/mcp/shared.go b/mcp/shared.go index e90bcbd..b710c95 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -34,12 +34,14 @@ const ( // It is the version that the client sends in the initialization request, and // the default version used by the server. latestProtocolVersion = protocolVersion20250618 + protocolVersion20251125 = "2025-11-25" // not yet released protocolVersion20250618 = "2025-06-18" protocolVersion20250326 = "2025-03-26" protocolVersion20241105 = "2024-11-05" ) var supportedProtocolVersions = []string{ + protocolVersion20251125, protocolVersion20250618, protocolVersion20250326, protocolVersion20241105, diff --git a/mcp/tool.go b/mcp/tool.go index 12b02b7..8aa7c3c 100644 --- a/mcp/tool.go +++ b/mcp/tool.go @@ -8,6 +8,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "github.com/google/jsonschema-go/jsonschema" ) @@ -101,3 +102,38 @@ func applySchema(data json.RawMessage, resolved *jsonschema.Resolved) (json.RawM } return data, nil } + +// validateToolName checks whether name is a valid tool name, reporting a +// non-nil error if not. +func validateToolName(name string) error { + if name == "" { + return fmt.Errorf("tool name cannot be empty") + } + if len(name) > 128 { + return fmt.Errorf("tool name exceeds maximum length of 128 characters (current: %d)", len(name)) + } + // For consistency with other SDKs, report characters in the order the appear + // in the name. + var invalidChars []string + seen := make(map[rune]bool) + for _, r := range name { + if !validToolNameRune(r) { + if !seen[r] { + invalidChars = append(invalidChars, fmt.Sprintf("%q", string(r))) + seen[r] = true + } + } + } + if len(invalidChars) > 0 { + return fmt.Errorf("tool name contains invalid characters: %s", strings.Join(invalidChars, ", ")) + } + return nil +} + +// validToolNameRune reports whether r is valid within tool names. +func validToolNameRune(r rune) bool { + return (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '_' || r == '-' || r == '.' +} diff --git a/mcp/tool_test.go b/mcp/tool_test.go index c440fcc..80661d3 100644 --- a/mcp/tool_test.go +++ b/mcp/tool_test.go @@ -145,3 +145,53 @@ func TestToolErrorHandling(t *testing.T) { } }) } + +func TestValidateToolName(t *testing.T) { + t.Run("valid", func(t *testing.T) { + validTests := []struct { + label string + toolName string + }{ + {"simple alphanumeric names", "getUser"}, + {"names with underscores", "get_user_profile"}, + {"names with dashes", "user-profile-update"}, + {"names with dots", "admin.tools.list"}, + {"mixed character names", "DATA_EXPORT_v2.1"}, + {"single character names", "a"}, + {"128 character names", strings.Repeat("a", 128)}, + } + for _, test := range validTests { + t.Run(test.label, func(t *testing.T) { + if err := validateToolName(test.toolName); err != nil { + t.Errorf("validateToolName(%q) = %v, want nil", test.toolName, err) + } + }) + } + }) + + t.Run("invalid", func(t *testing.T) { + invalidTests := []struct { + label string + toolName string + wantErrContaining string + }{ + {"empty names", "", "tool name cannot be empty"}, + {"names longer than 128 characters", strings.Repeat("a", 129), "tool name exceeds maximum length of 128 characters (current: 129)"}, + {"names with spaces", "get user profile", `tool name contains invalid characters: " "`}, + {"names with commas", "get,user,profile", `tool name contains invalid characters: ","`}, + {"names with forward slashes", "user/profile/update", `tool name contains invalid characters: "/"`}, + {"names with other special chars", "user@domain.com", `tool name contains invalid characters: "@"`}, + {"names with multiple invalid chars", "user name@domain,com", `tool name contains invalid characters: " ", "@", ","`}, + {"names with unicode characters", "user-ñame", `tool name contains invalid characters: "ñ"`}, + } + for _, test := range invalidTests { + t.Run(test.label, func(t *testing.T) { + if err := validateToolName(test.toolName); err == nil || !strings.Contains(err.Error(), test.wantErrContaining) { + t.Errorf("validateToolName(%q) = %v, want error containing %q", test.toolName, err, test.wantErrContaining) + } + }) + } + + }) + +} From d9263f7e47ba2fa85fb59c7a4c17a962f968c2db Mon Sep 17 00:00:00 2001 From: Markus Kusano Date: Fri, 21 Nov 2025 16:15:21 -0500 Subject: [PATCH 124/418] mcp: support elicitation defaults (#644) Fixes #617 --------- Co-authored-by: Markus Kusano --- mcp/client.go | 28 ++++++++--- mcp/mcp_test.go | 126 +++++++++++++++++++++++++++++++++++++++++++++++- mcp/server.go | 32 +++++++++++- 3 files changed, 177 insertions(+), 9 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index d7e3ae5..b19539c 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -321,10 +321,13 @@ func (c *Client) elicit(ctx context.Context, req *ElicitRequest) (*ElicitResult, if err != nil { return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("failed to resolve requested schema: %v", err)) } - if err := resolved.Validate(res.Content); err != nil { return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("elicitation result content does not match requested schema: %v", err)) } + err = resolved.ApplyDefaults(&res.Content) + if err != nil { + return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("failed to apply schema defalts to elicitation result: %v", err)) + } } return res, nil @@ -341,6 +344,9 @@ func validateElicitSchema(wireSchema any) (*jsonschema.Schema, error) { if err := remarshal(wireSchema, &schema); err != nil { return nil, err } + if schema == nil { + return nil, nil + } // The root schema must be of type "object" if specified if schema.Type != "" && schema.Type != "object" { @@ -369,7 +375,6 @@ func validateElicitProperty(propName string, propSchema *jsonschema.Schema) erro if len(propSchema.Properties) > 0 { return fmt.Errorf("elicit schema property %q contains nested properties, only primitive properties are allowed", propName) } - // Validate based on the property type - only primitives are supported switch propSchema.Type { case "string": @@ -439,7 +444,7 @@ func validateElicitStringProperty(propName string, propSchema *jsonschema.Schema } } - return nil + return validateDefaultProperty[string](propName, propSchema) } // validateElicitNumberProperty validates number and integer-type properties. @@ -450,19 +455,28 @@ func validateElicitNumberProperty(propName string, propSchema *jsonschema.Schema } } + intDefaultError := validateDefaultProperty[int](propName, propSchema) + floatDefaultError := validateDefaultProperty[float64](propName, propSchema) + if intDefaultError != nil && floatDefaultError != nil { + return fmt.Errorf("elicit schema property %q has default value that cannot be interpreted as an int or float", propName) + } + return nil } // validateElicitBooleanProperty validates boolean-type properties. func validateElicitBooleanProperty(propName string, propSchema *jsonschema.Schema) error { - // Validate default value if specified - must be a valid boolean + return validateDefaultProperty[bool](propName, propSchema) +} + +func validateDefaultProperty[T any](propName string, propSchema *jsonschema.Schema) error { + // Validate default value if specified - must be a valid T if propSchema.Default != nil { - var defaultValue bool + var defaultValue T if err := json.Unmarshal(propSchema.Default, &defaultValue); err != nil { - return fmt.Errorf("elicit schema property %q has invalid default value, must be a boolean: %v", propName, err) + return fmt.Errorf("elicit schema property %q has invalid default value, must be a %T: %v", propName, defaultValue, err) } } - return nil } diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index a78c152..ffaa43d 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -1254,7 +1254,37 @@ func TestElicitationSchemaValidation(t *testing.T) { "enabled": {Type: "boolean", Default: json.RawMessage(`"not-a-boolean"`)}, }, }, - expectedError: "elicit schema property \"enabled\" has invalid default value, must be a boolean", + expectedError: "elicit schema property \"enabled\" has invalid default value, must be a bool", + }, + { + name: "string with invalid default", + schema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "enabled": {Type: "string", Default: json.RawMessage("true")}, + }, + }, + expectedError: "elicit schema property \"enabled\" has invalid default value, must be a string", + }, + { + name: "integer with invalid default", + schema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "enabled": {Type: "integer", Default: json.RawMessage("true")}, + }, + }, + expectedError: "elicit schema property \"enabled\" has default value that cannot be interpreted as an int or float", + }, + { + name: "number with invalid default", + schema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "enabled": {Type: "number", Default: json.RawMessage("true")}, + }, + }, + expectedError: "elicit schema property \"enabled\" has default value that cannot be interpreted as an int or float", }, { name: "enum with mismatched enumNames length", @@ -1459,6 +1489,100 @@ func TestElicitationCapabilityDeclaration(t *testing.T) { }) } +func TestElicitationDefaultValues(t *testing.T) { + ctx := context.Background() + ct, st := NewInMemoryTransports() + + s := NewServer(testImpl, nil) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + c := NewClient(testImpl, &ClientOptions{ + ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept", Content: map[string]any{"default": "response"}}, nil + }, + }) + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + testcases := []struct { + name string + schema *jsonschema.Schema + expected map[string]any + }{ + { + name: "boolean with default", + schema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "key": {Type: "boolean", Default: json.RawMessage("true")}, + }, + }, + expected: map[string]any{"key": true, "default": "response"}, + }, + { + name: "string with default", + schema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "key": {Type: "string", Default: json.RawMessage("\"potato\"")}, + }, + }, + expected: map[string]any{"key": "potato", "default": "response"}, + }, + { + name: "integer with default", + schema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "key": {Type: "integer", Default: json.RawMessage("123")}, + }, + }, + expected: map[string]any{"key": float64(123), "default": "response"}, + }, + { + name: "number with default", + schema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "key": {Type: "number", Default: json.RawMessage("89.7")}, + }, + }, + expected: map[string]any{"key": float64(89.7), "default": "response"}, + }, + { + name: "enum with default", + schema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "key": {Type: "string", Enum: []any{"one", "two"}, Default: json.RawMessage("\"one\"")}, + }, + }, + expected: map[string]any{"key": "one", "default": "response"}, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + res, err := ss.Elicit(ctx, &ElicitParams{ + Message: "Test schema with defaults: " + tc.name, + RequestedSchema: tc.schema, + }) + if err != nil { + t.Fatalf("expected no error for default schema %q, got: %v", tc.name, err) + } + if diff := cmp.Diff(tc.expected, res.Content); diff != "" { + t.Errorf("%s: did not get expected value, -want +got:\n%s", tc.name, diff) + } + }) + } +} + func TestKeepAliveFailure(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/mcp/server.go b/mcp/server.go index 6a3368e..d1d6a1d 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1018,7 +1018,37 @@ func (ss *ServerSession) Elicit(ctx context.Context, params *ElicitParams) (*Eli if err := ss.checkInitialized(methodElicit); err != nil { return nil, err } - return handleSend[*ElicitResult](ctx, methodElicit, newServerRequest(ss, orZero[Params](params))) + + res, err := handleSend[*ElicitResult](ctx, methodElicit, newServerRequest(ss, orZero[Params](params))) + if err != nil { + return nil, err + } + + if params.RequestedSchema == nil { + return res, nil + } + schema, err := validateElicitSchema(params.RequestedSchema) + if err != nil { + return nil, err + } + if schema == nil { + return res, nil + } + + resolved, err := schema.Resolve(nil) + if err != nil { + fmt.Printf(" resolve err: %s", err) + return nil, err + } + if err := resolved.Validate(res.Content); err != nil { + return nil, fmt.Errorf("elicitation result content does not match requested schema: %v", err) + } + err = resolved.ApplyDefaults(&res.Content) + if err != nil { + return nil, fmt.Errorf("failed to apply schema defalts to elicitation result: %v", err) + } + + return res, nil } // Log sends a log message to the client. From 21fb03dd1ab987339fa35061e94c3e8cbdcf565f Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Sun, 23 Nov 2025 12:58:28 -0500 Subject: [PATCH 125/418] mcp: support url mode elicitation (#646) Add initial support for URL mode elicitation, by introducing the new types, as well as both server and client-side validation. A follow-up CL will add support for ErrURLElicitationRequired. For #623 --- docs/rough_edges.md | 7 ++ examples/server/everything/main.go | 48 +++++--- internal/docs/rough_edges.src.md | 7 ++ mcp/client.go | 87 +++++++++---- mcp/elicitation_test.go | 190 +++++++++++++++++++++++++++++ mcp/mcp_test.go | 18 +-- mcp/protocol.go | 44 ++++++- mcp/requests.go | 23 ++-- mcp/server.go | 31 +++++ mcp/shared.go | 3 + 10 files changed, 398 insertions(+), 60 deletions(-) create mode 100644 mcp/elicitation_test.go diff --git a/docs/rough_edges.md b/docs/rough_edges.md index a99e00a..e7f0a17 100644 --- a/docs/rough_edges.md +++ b/docs/rough_edges.md @@ -16,3 +16,10 @@ v2. landing after the SDK was at v1, we missed an opportunity to panic on invalid tool names. Instead, we have to simply produce an error log. In v2, we should panic. + +- Inconsistent naming. + - `ResourceUpdatedNotificationsParams` should probably have just been + `ResourceUpdatedParams`, as we don't include the word 'notification' in + other notification param types. + - Similarly, `ProgressNotificationParams` should probably have been + `ProgressParams`. diff --git a/examples/server/everything/main.go b/examples/server/everything/main.go index f255ab4..9bd6a7c 100644 --- a/examples/server/everything/main.go +++ b/examples/server/everything/main.go @@ -7,6 +7,7 @@ package main import ( "context" + _ "embed" "encoding/base64" "flag" "fmt" @@ -17,6 +18,7 @@ import ( "os" "runtime" "strings" + "sync/atomic" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -51,12 +53,8 @@ func main() { CompletionHandler: complete, // support completions by setting this handler } - // Optionally add an icon to the server implementation. - icons, err := iconToBase64DataURL("./mcp.png") - if err != nil { - log.Fatalf("failed to read icon: %v", err) - } - + // Add an icon to the server implementation. + icons := mcpIcons() server := mcp.NewServer(&mcp.Implementation{Name: "everything", WebsiteURL: "https://example.com", Icons: icons}, opts) // Add tools that exercise different features of the protocol. @@ -67,7 +65,8 @@ func main() { mcp.AddTool(server, &mcp.Tool{Name: "ping"}, pingingTool) // performs a ping mcp.AddTool(server, &mcp.Tool{Name: "log"}, loggingTool) // performs a log mcp.AddTool(server, &mcp.Tool{Name: "sample"}, samplingTool) // performs sampling - mcp.AddTool(server, &mcp.Tool{Name: "elicit"}, elicitingTool) // performs elicitation + mcp.AddTool(server, &mcp.Tool{Name: "elicit (form)"}, elicitFormTool) // performs form elicitation + mcp.AddTool(server, &mcp.Tool{Name: "elicit (url)"}, elicitURLTool) // performs url elicitation mcp.AddTool(server, &mcp.Tool{Name: "roots"}, rootsTool) // lists roots // Add a basic prompt. @@ -235,7 +234,7 @@ func samplingTool(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.Ca }, nil, nil } -func elicitingTool(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { +func elicitFormTool(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { res, err := req.Session.Elicit(ctx, &mcp.ElicitParams{ Message: "provide a random string", RequestedSchema: &jsonschema.Schema{ @@ -255,6 +254,26 @@ func elicitingTool(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.C }, nil, nil } +var elicitations atomic.Int32 + +func elicitURLTool(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + elicitID := fmt.Sprintf("%d", elicitations.Add(1)) + _, err := req.Session.Elicit(ctx, &mcp.ElicitParams{ + Message: "submit a string", + URL: fmt.Sprintf("http://localhost:6062?id=%s", elicitID), + ElicitationID: elicitID, + }) + if err != nil { + return nil, nil, fmt.Errorf("eliciting failed: %v", err) + } + // TODO: actually wait for the elicitation form to be submitted. + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "(elicitation pending)"}, + }, + }, nil, nil +} + func complete(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResult, error) { return &mcp.CompleteResult{ Completion: mcp.CompletionResultDetails{ @@ -264,15 +283,14 @@ func complete(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResul }, nil } -func iconToBase64DataURL(path string) ([]mcp.Icon, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } +//go:embed mcp.png +var mcpIconData []byte + +func mcpIcons() []mcp.Icon { return []mcp.Icon{{ - Source: "data:image/png;base64," + base64.StdEncoding.EncodeToString(data), + Source: "data:image/png;base64," + base64.StdEncoding.EncodeToString(mcpIconData), MIMEType: "image/png", Sizes: []string{"48x48"}, Theme: "light", // or "dark" or empty - }}, nil + }} } diff --git a/internal/docs/rough_edges.src.md b/internal/docs/rough_edges.src.md index d8c2c99..b5dbadc 100644 --- a/internal/docs/rough_edges.src.md +++ b/internal/docs/rough_edges.src.md @@ -15,3 +15,10 @@ v2. landing after the SDK was at v1, we missed an opportunity to panic on invalid tool names. Instead, we have to simply produce an error log. In v2, we should panic. + +- Inconsistent naming. + - `ResourceUpdatedNotificationsParams` should probably have just been + `ResourceUpdatedParams`, as we don't include the word 'notification' in + other notification param types. + - Similarly, `ProgressNotificationParams` should probably have been + `ProgressParams`. diff --git a/mcp/client.go b/mcp/client.go index b19539c..cd78401 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -66,6 +66,11 @@ type ClientOptions struct { // Setting ElicitationHandler to a non-nil value causes the client to // advertise the elicitation capability. ElicitationHandler func(context.Context, *ElicitRequest) (*ElicitResult, error) + // ElicitationModes specifies the elicitation modes supported by the client. + // If ElicitationHandler is set and ElicitationModes is empty, it defaults to ["form"]. + ElicitationModes []string + // ElicitationCompleteHandler handles incoming notifications for notifications/elicitation/complete. + ElicitationCompleteHandler func(context.Context, *ElicitationCompleteNotificationRequest) // Handlers for notifications from the server. ToolListChangedHandler func(context.Context, *ToolListChangedRequest) PromptListChangedHandler func(context.Context, *PromptListChangedRequest) @@ -123,6 +128,15 @@ func (c *Client) capabilities() *ClientCapabilities { } if c.opts.ElicitationHandler != nil { caps.Elicitation = &ElicitationCapabilities{} + modes := c.opts.ElicitationModes + if len(modes) == 0 || slices.Contains(modes, "form") { + // Technically, the empty ElicitationCapabilities value is equivalent to + // {"form":{}} for backward compatibility, but we explicitly set the form + // capability. + caps.Elicitation.Form = &FormElicitationCapabilities{} + } else if slices.Contains(modes, "url") { + caps.Elicitation.URL = &URLElicitationCapabilities{} + } } return caps } @@ -297,40 +311,55 @@ func (c *Client) createMessage(ctx context.Context, req *CreateMessageRequest) ( func (c *Client) elicit(ctx context.Context, req *ElicitRequest) (*ElicitResult, error) { if c.opts.ElicitationHandler == nil { - // TODO: wrap or annotate this error? Pick a standard code? - return nil, jsonrpc2.NewError(codeUnsupportedMethod, "client does not support elicitation") + return nil, jsonrpc2.NewError(codeInvalidParams, "client does not support elicitation") } - // Validate that the requested schema only contains top-level properties without nesting - schema, err := validateElicitSchema(req.Params.RequestedSchema) - if err != nil { - return nil, jsonrpc2.NewError(codeInvalidParams, err.Error()) + // Validate the elicitation parameters based on the mode. + mode := req.Params.Mode + if mode == "" { + mode = "form" } - res, err := c.opts.ElicitationHandler(ctx, req) - if err != nil { - return nil, err - } - - // Validate elicitation result content against requested schema - if schema != nil && res.Content != nil { - // TODO: is this the correct behavior if validation fails? - // It isn't the *server's* params that are invalid, so why would we return - // this code to the server? - resolved, err := schema.Resolve(nil) + switch mode { + case "form": + if req.Params.URL != "" { + return nil, jsonrpc2.NewError(codeInvalidParams, "URL must not be set for form elicitation") + } + schema, err := validateElicitSchema(req.Params.RequestedSchema) if err != nil { - return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("failed to resolve requested schema: %v", err)) + return nil, jsonrpc2.NewError(codeInvalidParams, err.Error()) } - if err := resolved.Validate(res.Content); err != nil { - return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("elicitation result content does not match requested schema: %v", err)) - } - err = resolved.ApplyDefaults(&res.Content) + res, err := c.opts.ElicitationHandler(ctx, req) if err != nil { - return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("failed to apply schema defalts to elicitation result: %v", err)) + return nil, err } + // Validate elicitation result content against requested schema. + if schema != nil && res.Content != nil { + resolved, err := schema.Resolve(nil) + if err != nil { + return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("failed to resolve requested schema: %v", err)) + } + if err := resolved.Validate(res.Content); err != nil { + return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("elicitation result content does not match requested schema: %v", err)) + } + err = resolved.ApplyDefaults(&res.Content) + if err != nil { + return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("failed to apply schema defalts to elicitation result: %v", err)) + } + } + return res, nil + case "url": + if req.Params.RequestedSchema != nil { + return nil, jsonrpc2.NewError(codeInvalidParams, "requestedSchema must not be set for URL elicitation") + } + if req.Params.URL == "" { + return nil, jsonrpc2.NewError(codeInvalidParams, "URL must be set for URL elicitation") + } + // No schema validation for URL mode, just pass through to handler. + return c.opts.ElicitationHandler(ctx, req) + default: + return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("unsupported elicitation mode: %q", mode)) } - - return res, nil } // validateElicitSchema validates that the schema conforms to MCP elicitation schema requirements. @@ -528,6 +557,7 @@ var clientMethodInfos = map[string]methodInfo{ notificationResourceUpdated: newClientMethodInfo(clientMethod((*Client).callResourceUpdatedHandler), notification|missingParamsOK), notificationLoggingMessage: newClientMethodInfo(clientMethod((*Client).callLoggingHandler), notification), notificationProgress: newClientMethodInfo(clientSessionMethod((*ClientSession).callProgressNotificationHandler), notification), + notificationElicitationComplete: newClientMethodInfo(clientMethod((*Client).callElicitationCompleteHandler), notification|missingParamsOK), } func (cs *ClientSession) sendingMethodInfos() map[string]methodInfo { @@ -692,6 +722,13 @@ func (cs *ClientSession) callProgressNotificationHandler(ctx context.Context, pa return nil, nil } +func (c *Client) callElicitationCompleteHandler(ctx context.Context, req *ElicitationCompleteNotificationRequest) (Result, error) { + if h := c.opts.ElicitationCompleteHandler; h != nil { + h(ctx, req) + } + return nil, nil +} + // NotifyProgress sends a progress notification from the client to the server // associated with this session. // This can be used if the client is performing a long-running task that was diff --git a/mcp/elicitation_test.go b/mcp/elicitation_test.go new file mode 100644 index 0000000..de5f65d --- /dev/null +++ b/mcp/elicitation_test.go @@ -0,0 +1,190 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/google/jsonschema-go/jsonschema" +) + +// TODO: migrate other elicitation tests here. + +func TestElicitationURLMode(t *testing.T) { + ctx := context.Background() + clientErr := errors.New("client failed to elicit") + + testCases := []struct { + name string + handler func(context.Context, *ElicitRequest) (*ElicitResult, error) + params *ElicitParams + wantResultAction string + wantErrMsg string + wantErrCode int64 + }{ + { + name: "success", + handler: func(ctx context.Context, req *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + params: &ElicitParams{ + Mode: "url", + Message: "Please provide information via URL", + URL: "https://example.com/form", + }, + wantResultAction: "accept", + }, + { + name: "decline", + handler: func(ctx context.Context, req *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "decline"}, nil + }, + params: &ElicitParams{ + Mode: "url", + Message: "Please provide information via URL", + URL: "https://example.com/form", + }, + wantResultAction: "decline", + }, + { + name: "client error", + handler: func(ctx context.Context, req *ElicitRequest) (*ElicitResult, error) { + return nil, clientErr + }, + params: &ElicitParams{ + Mode: "url", + Message: "This should fail", + URL: "https://example.com/form", + }, + wantErrMsg: clientErr.Error(), + }, + { + name: "missing url", + handler: func(ctx context.Context, req *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + params: &ElicitParams{ + Mode: "url", + Message: "URL is missing", + }, + wantErrMsg: "URL must be set for URL elicitation", + wantErrCode: codeInvalidParams, + }, + { + name: "schema not allowed", + handler: func(ctx context.Context, req *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + params: &ElicitParams{ + Mode: "url", + Message: "Schema is not allowed", + URL: "https://example.com/form", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + }, + }, + wantErrMsg: "requestedSchema must not be set for URL elicitation", + wantErrCode: codeInvalidParams, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ct, st := NewInMemoryTransports() + s := NewServer(testImpl, nil) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + c := NewClient(testImpl, &ClientOptions{ + ElicitationModes: []string{"url"}, + ElicitationHandler: tc.handler, + }) + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + result, err := ss.Elicit(ctx, tc.params) + + if tc.wantErrMsg != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErrMsg) { + t.Fatalf("Elicit(...): got error %v, want containing %q", err, tc.wantErrMsg) + } + if tc.wantErrCode != 0 { + if code := errorCode(err); code != tc.wantErrCode { + t.Errorf("Elicit(...): got error code %d, want %d", code, tc.wantErrCode) + } + } + } else { + if err != nil { + t.Fatalf("Elicit failed: %v", err) + } + if result.Action != tc.wantResultAction { + t.Errorf("Elicit(...): got action %q, want %q", result.Action, tc.wantResultAction) + } + } + }) + } +} + +func TestElicitationCompleteNotification(t *testing.T) { + ctx := context.Background() + + var elicitationCompleteCh = make(chan *ElicitationCompleteParams, 1) + + c := NewClient(testImpl, &ClientOptions{ + ElicitationModes: []string{"url"}, + ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + ElicitationCompleteHandler: func(_ context.Context, req *ElicitationCompleteNotificationRequest) { + elicitationCompleteCh <- req.Params + }, + }) + + cs, ss, cleanup := basicClientServerConnection(t, c, nil, nil) + _ = cs // Dummy usage to avoid "declared and not used" error. + defer cleanup() + + // 1. Server initiates a URL elicitation + elicitID := "testElicitationID-123" + resp, err := ss.Elicit(ctx, &ElicitParams{ + Mode: "url", + Message: "Please complete this form: ", + URL: "https://example.com/form?id=" + elicitID, + ElicitationID: elicitID, + }) + if err != nil { + t.Fatalf("Elicit failed: %v", err) + } + if resp.Action != "accept" { + t.Fatalf("Elicit action is %q, want %q", resp.Action, "accept") + } + + // 2. Server sends elicitation complete notification (simulating out-of-band completion) + err = handleNotify(ctx, notificationElicitationComplete, newServerRequest(ss, &ElicitationCompleteParams{ + ElicitationID: elicitID, + })) + if err != nil { + t.Fatalf("failed to send elicitation complete notification: %v", err) + } + + // 3. Client should receive the notification + select { + case gotParams := <-elicitationCompleteCh: + if gotParams.ElicitationID != elicitID { + t.Errorf("elicitationComplete notification ID mismatch: got %q, want %q", gotParams.ElicitationID, elicitID) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for elicitation complete notification") + } +} diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index ffaa43d..c2c949e 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -62,9 +62,10 @@ func TestEndToEnd(t *testing.T) { // Channels to check if notification callbacks happened. notificationChans := map[string]chan int{} - for _, name := range []string{"initialized", "roots", "tools", "prompts", "resources", "progress_server", "progress_client", "resource_updated", "subscribe", "unsubscribe"} { + for _, name := range []string{"initialized", "roots", "tools", "prompts", "resources", "progress_server", "progress_client", "resource_updated", "subscribe", "unsubscribe", "elicitation_complete"} { notificationChans[name] = make(chan int, 1) } + waitForNotification := func(t *testing.T, name string) { t.Helper() select { @@ -150,6 +151,9 @@ func TestEndToEnd(t *testing.T) { ResourceUpdatedHandler: func(context.Context, *ResourceUpdatedNotificationRequest) { notificationChans["resource_updated"] <- 0 }, + ElicitationCompleteHandler: func(_ context.Context, req *ElicitationCompleteNotificationRequest) { + notificationChans["elicitation_complete"] <- 0 + }, } c := NewClient(testImpl, opts) rootAbs, err := filepath.Abs(filepath.FromSlash("testdata/files")) @@ -985,8 +989,8 @@ func TestElicitationUnsupportedMethod(t *testing.T) { if err == nil { t.Error("expected error when ElicitationHandler is not provided, got nil") } - if code := errorCode(err); code != codeUnsupportedMethod { - t.Errorf("got error code %d, want %d (CodeUnsupportedMethod)", code, codeUnsupportedMethod) + if code := errorCode(err); code != -1 { + t.Errorf("got error code %d, want -1", code) } if !strings.Contains(err.Error(), "does not support elicitation") { t.Errorf("error should mention unsupported elicitation, got: %v", err) @@ -1414,7 +1418,7 @@ func TestElicitationProgressToken(t *testing.T) { func TestElicitationCapabilityDeclaration(t *testing.T) { ctx := context.Background() - t.Run("with_handler", func(t *testing.T) { + t.Run("with handler", func(t *testing.T) { ct, st := NewInMemoryTransports() // Client with ElicitationHandler should declare capability @@ -1451,7 +1455,7 @@ func TestElicitationCapabilityDeclaration(t *testing.T) { } }) - t.Run("without_handler", func(t *testing.T) { + t.Run("without handler", func(t *testing.T) { ct, st := NewInMemoryTransports() // Client without ElicitationHandler should not declare capability @@ -1483,8 +1487,8 @@ func TestElicitationCapabilityDeclaration(t *testing.T) { if err == nil { t.Error("expected UnsupportedMethod error when no capability declared") } - if code := errorCode(err); code != codeUnsupportedMethod { - t.Errorf("got error code %d, want %d (CodeUnsupportedMethod)", code, codeUnsupportedMethod) + if code := errorCode(err); code != -1 { + t.Errorf("got error code %d, want -1", code) } }) } diff --git a/mcp/protocol.go b/mcp/protocol.go index 54ccdd9..8a88f8e 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -879,7 +879,20 @@ func (x *RootsListChangedParams) SetProgressToken(t any) { setProgressToken(x, t type SamplingCapabilities struct{} // ElicitationCapabilities describes the capabilities for elicitation. -type ElicitationCapabilities struct{} +// +// If neither Form nor URL is set, the 'Form' capabilitiy is assumed. +type ElicitationCapabilities struct { + Form *FormElicitationCapabilities + URL *URLElicitationCapabilities +} + +// FormElicitationCapabilities describes capabilities for form elicitation. +type FormElicitationCapabilities struct { +} + +// URLElicitationCapabilities describes capabilities for url elicitation. +type URLElicitationCapabilities struct { +} // Describes a message issued to or received from an LLM API. type SamplingMessage struct { @@ -1067,6 +1080,10 @@ type ElicitParams struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` + // The mode of elicitation to use. + // + // If unset, will be inferred from the other fields. + Mode string `json:"mode"` // The message to present to the user. Message string `json:"message"` // A JSON schema object defining the requested elicitation schema. @@ -1080,7 +1097,17 @@ type ElicitParams struct { // map[string]any). // // Only top-level properties are allowed, without nesting. - RequestedSchema any `json:"requestedSchema"` + // + // This is only used for "form" elicitation. + RequestedSchema any `json:"requestedSchema,omitempty"` + // The URL to present to the user. + // + // This is only used for "url" elicitation. + URL string `json:"url,omitempty"` + // The ID of the elicitation. + // + // This is only used for "url" elicitation. + ElicitationID string `json:"elicitationId,omitempty"` } func (x *ElicitParams) isParams() {} @@ -1105,6 +1132,18 @@ type ElicitResult struct { func (*ElicitResult) isResult() {} +// ElicitationCompleteParams is sent from the server to the client, informing it that an out-of-band elicitation interaction has completed. +type ElicitationCompleteParams struct { + // This property is reserved by the protocol to allow clients and servers to + // attach additional metadata to their responses. + Meta `json:"_meta,omitempty"` + // The ID of the elicitation that has completed. This must correspond to the + // elicitationId from the original elicitation/create request. + ElicitationID string `json:"elicitationId"` +} + +func (*ElicitationCompleteParams) isParams() {} + // An Implementation describes the name and version of an MCP implementation, with an optional // title for UI representation. type Implementation struct { @@ -1171,6 +1210,7 @@ const ( methodComplete = "completion/complete" methodCreateMessage = "sampling/createMessage" methodElicit = "elicitation/create" + notificationElicitationComplete = "notifications/elicitation/complete" methodGetPrompt = "prompts/get" methodInitialize = "initialize" notificationInitialized = "notifications/initialized" diff --git a/mcp/requests.go b/mcp/requests.go index 82b700f..f64d6fb 100644 --- a/mcp/requests.go +++ b/mcp/requests.go @@ -23,15 +23,16 @@ type ( ) type ( - CreateMessageRequest = ClientRequest[*CreateMessageParams] - ElicitRequest = ClientRequest[*ElicitParams] - initializedClientRequest = ClientRequest[*InitializedParams] - InitializeRequest = ClientRequest[*InitializeParams] - ListRootsRequest = ClientRequest[*ListRootsParams] - LoggingMessageRequest = ClientRequest[*LoggingMessageParams] - ProgressNotificationClientRequest = ClientRequest[*ProgressNotificationParams] - PromptListChangedRequest = ClientRequest[*PromptListChangedParams] - ResourceListChangedRequest = ClientRequest[*ResourceListChangedParams] - ResourceUpdatedNotificationRequest = ClientRequest[*ResourceUpdatedNotificationParams] - ToolListChangedRequest = ClientRequest[*ToolListChangedParams] + CreateMessageRequest = ClientRequest[*CreateMessageParams] + ElicitRequest = ClientRequest[*ElicitParams] + initializedClientRequest = ClientRequest[*InitializedParams] + InitializeRequest = ClientRequest[*InitializeParams] + ListRootsRequest = ClientRequest[*ListRootsParams] + LoggingMessageRequest = ClientRequest[*LoggingMessageParams] + ProgressNotificationClientRequest = ClientRequest[*ProgressNotificationParams] + PromptListChangedRequest = ClientRequest[*PromptListChangedParams] + ResourceListChangedRequest = ClientRequest[*ResourceListChangedParams] + ResourceUpdatedNotificationRequest = ClientRequest[*ResourceUpdatedNotificationParams] + ToolListChangedRequest = ClientRequest[*ToolListChangedParams] + ElicitationCompleteNotificationRequest = ClientRequest[*ElicitationCompleteParams] ) diff --git a/mcp/server.go b/mcp/server.go index d1d6a1d..254c2d5 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -10,6 +10,7 @@ import ( "encoding/base64" "encoding/gob" "encoding/json" + "errors" "fmt" "iter" "log/slog" @@ -1018,6 +1019,36 @@ func (ss *ServerSession) Elicit(ctx context.Context, params *ElicitParams) (*Eli if err := ss.checkInitialized(methodElicit); err != nil { return nil, err } + if params == nil { + return nil, fmt.Errorf("%w: params cannot be nil", jsonrpc2.ErrInvalidParams) + } + + if params.Mode == "" { + params2 := *params + if params.URL != "" || params.ElicitationID != "" { + params2.Mode = "url" + } else { + params2.Mode = "form" + } + params = ¶ms2 + } + + if iparams := ss.InitializeParams(); iparams == nil || iparams.Capabilities == nil || iparams.Capabilities.Elicitation == nil { + return nil, fmt.Errorf("client does not support elicitation") + } + caps := ss.InitializeParams().Capabilities.Elicitation + switch params.Mode { + case "form": + if caps.Form == nil && caps.URL != nil { + // Note: if both 'Form' and 'URL' are nil, we assume the client supports + // form elicitation for backward compatibility. + return nil, errors.New(`client does not support "form" elicitation`) + } + case "url": + if caps.URL == nil { + return nil, errors.New(`client does not support "url" elicitation`) + } + } res, err := handleSend[*ElicitResult](ctx, methodElicit, newServerRequest(ss, orZero[Params](params))) if err != nil { diff --git a/mcp/shared.go b/mcp/shared.go index b710c95..3fac40b 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -335,6 +335,9 @@ func clientSessionMethod[P Params, R Result](f func(*ClientSession, context.Cont const ( codeResourceNotFound = -32002 // The error code if the method exists and was called properly, but the peer does not support it. + // + // TODO(rfindley): this code is wrong, and we should fix it to be + // consistent with other SDKs. codeUnsupportedMethod = -31001 // The error code for invalid parameters codeInvalidParams = -32602 From 96385fde3c84a5d8fe2882b7eeb7becc7231a82f Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Wed, 26 Nov 2025 10:27:32 -0500 Subject: [PATCH 126/418] docs: document non-pointer receiver for AudioContent (#660) AudioContent should have had a non-pointer receiver, to be consistent with other content types. Add this to our log of rough edges to reconsider for v2. Fixes #641 --- docs/rough_edges.md | 3 +++ internal/docs/rough_edges.src.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/docs/rough_edges.md b/docs/rough_edges.md index e7f0a17..41888f8 100644 --- a/docs/rough_edges.md +++ b/docs/rough_edges.md @@ -23,3 +23,6 @@ v2. other notification param types. - Similarly, `ProgressNotificationParams` should probably have been `ProgressParams`. + +- `AudioContent.MarshalJSON` should have had a pointer receiver, to be + consistent with other content types. diff --git a/internal/docs/rough_edges.src.md b/internal/docs/rough_edges.src.md index b5dbadc..fb52391 100644 --- a/internal/docs/rough_edges.src.md +++ b/internal/docs/rough_edges.src.md @@ -22,3 +22,6 @@ v2. other notification param types. - Similarly, `ProgressNotificationParams` should probably have been `ProgressParams`. + +- `AudioContent.MarshalJSON` should have had a pointer receiver, to be + consistent with other content types. From 2ce44d45e9f65c9e2bb8fee56d1a4663241aad11 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Mon, 1 Dec 2025 11:29:17 -0500 Subject: [PATCH 127/418] all: expose JSON-RPC error codes, and support URLElicitationRequired errors (#655) Expose the jsonrpc.Error type to allow access to underlying JSON-RPC error codes. Also, expose common JSON-RPC error codes in the jsonrpc package, and MCP error codes in the mcp package. Also, implement support for the MCP URL elicitation required error (-32042), enabling servers to request out-of-band user authorization and clients to automatically handle these requests with retry logic. Server-side: - Add CodeURLElicitationRequired constant - Add URLElicitationRequiredError() constructor. Client-side: - Add urlElicitationMiddleware to intercept URLElicitationRequired errors and retry calls. - Update callElicitationCompleteHandler to signal waiting operations. - Fix capability advertisement to support both form and URL modes. Fixes #452 Fixes #623 --- jsonrpc/jsonrpc.go | 17 ++ mcp/client.go | 189 +++++++++++++++++++-- mcp/client_test.go | 55 +++++++ mcp/elicitation_test.go | 5 +- mcp/error_test.go | 353 ++++++++++++++++++++++++++++++++++++++++ mcp/mcp_test.go | 10 +- mcp/resource.go | 6 +- mcp/server.go | 14 +- mcp/shared.go | 46 +++++- mcp/streamable_test.go | 2 +- mcp/tool_test.go | 14 +- mcp/transport.go | 3 +- 12 files changed, 670 insertions(+), 44 deletions(-) create mode 100644 mcp/error_test.go diff --git a/jsonrpc/jsonrpc.go b/jsonrpc/jsonrpc.go index 1633d4e..a9ea78f 100644 --- a/jsonrpc/jsonrpc.go +++ b/jsonrpc/jsonrpc.go @@ -17,6 +17,8 @@ type ( Request = jsonrpc2.Request // Response is a JSON-RPC response. Response = jsonrpc2.Response + // Error is a structured error in a JSON-RPC response. + Error = jsonrpc2.WireError ) // MakeID coerces the given Go value to an ID. The value should be the @@ -37,3 +39,18 @@ func EncodeMessage(msg Message) ([]byte, error) { func DecodeMessage(data []byte) (Message, error) { return jsonrpc2.DecodeMessage(data) } + +// Standard JSON-RPC 2.0 error codes. +// See https://www.jsonrpc.org/specification#error_object +const ( + // CodeParseError indicates invalid JSON was received by the server. + CodeParseError = -32700 + // CodeInvalidRequest indicates the JSON sent is not a valid Request object. + CodeInvalidRequest = -32600 + // CodeMethodNotFound indicates the method does not exist or is not available. + CodeMethodNotFound = -32601 + // CodeInvalidParams indicates invalid method parameter(s). + CodeInvalidParams = -32602 + // CodeInternalError indicates an internal JSON-RPC error. + CodeInternalError = -32603 +) diff --git a/mcp/client.go b/mcp/client.go index cd78401..a3d36b2 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -7,9 +7,11 @@ package mcp import ( "context" "encoding/json" + "errors" "fmt" "iter" "slices" + "strings" "sync" "sync/atomic" "time" @@ -45,7 +47,7 @@ func NewClient(impl *Implementation, opts *ClientOptions) *Client { c := &Client{ impl: impl, roots: newFeatureSet(func(r *Root) string { return r.URI }), - sendingMethodHandler_: defaultSendingMethodHandler[*ClientSession], + sendingMethodHandler_: defaultSendingMethodHandler, receivingMethodHandler_: defaultReceivingMethodHandler[*ClientSession], } if opts != nil { @@ -134,7 +136,8 @@ func (c *Client) capabilities() *ClientCapabilities { // {"form":{}} for backward compatibility, but we explicitly set the form // capability. caps.Elicitation.Form = &FormElicitationCapabilities{} - } else if slices.Contains(modes, "url") { + } + if slices.Contains(modes, "url") { caps.Elicitation.URL = &URLElicitationCapabilities{} } } @@ -206,6 +209,10 @@ type ClientSession struct { // No mutex is (currently) required to guard the session state, because it is // only set synchronously during Client.Connect. state clientSessionState + + // Pending URL elicitations waiting for completion notifications. + pendingElicitationsMu sync.Mutex + pendingElicitations map[string]chan struct{} } type clientSessionState struct { @@ -250,6 +257,46 @@ func (cs *ClientSession) Wait() error { return cs.conn.Wait() } +// registerElicitationWaiter registers a waiter for an elicitation complete +// notification with the given elicitation ID. It returns two functions: an await +// function that waits for the notification or context cancellation, and a cleanup +// function that must be called to unregister the waiter. This must be called before +// triggering the elicitation to avoid a race condition where the notification +// arrives before the waiter is registered. +// +// The cleanup function must be called even if the await function is never called, +// to prevent leaking the registration. +func (cs *ClientSession) registerElicitationWaiter(elicitationID string) (await func(context.Context) error, cleanup func()) { + // Create a channel for this elicitation. + ch := make(chan struct{}, 1) + + // Register the channel. + cs.pendingElicitationsMu.Lock() + if cs.pendingElicitations == nil { + cs.pendingElicitations = make(map[string]chan struct{}) + } + cs.pendingElicitations[elicitationID] = ch + cs.pendingElicitationsMu.Unlock() + + // Return await and cleanup functions. + await = func(ctx context.Context) error { + select { + case <-ctx.Done(): + return fmt.Errorf("context cancelled while waiting for elicitation completion: %w", ctx.Err()) + case <-ch: + return nil + } + } + + cleanup = func() { + cs.pendingElicitationsMu.Lock() + delete(cs.pendingElicitations, elicitationID) + cs.pendingElicitationsMu.Unlock() + } + + return await, cleanup +} + // startKeepalive starts the keepalive mechanism for this client session. func (cs *ClientSession) startKeepalive(interval time.Duration) { startKeepalive(cs, interval, &cs.keepaliveCancel) @@ -304,14 +351,118 @@ func (c *Client) listRoots(_ context.Context, req *ListRootsRequest) (*ListRoots func (c *Client) createMessage(ctx context.Context, req *CreateMessageRequest) (*CreateMessageResult, error) { if c.opts.CreateMessageHandler == nil { // TODO: wrap or annotate this error? Pick a standard code? - return nil, jsonrpc2.NewError(codeUnsupportedMethod, "client does not support CreateMessage") + return nil, &jsonrpc.Error{Code: codeUnsupportedMethod, Message: "client does not support CreateMessage"} } return c.opts.CreateMessageHandler(ctx, req) } +// urlElicitationMiddleware returns middleware that automatically handles URL elicitation +// required errors by executing the elicitation handler, waiting for completion notifications, +// and retrying the operation. +// +// This middleware should be added to clients that want automatic URL elicitation handling: +// +// client := mcp.NewClient(impl, opts) +// client.AddSendingMiddleware(mcp.urlElicitationMiddleware()) +// +// TODO(rfindley): this isn't strictly necessary for the SEP, but may be +// useful. Propose exporting it. +func urlElicitationMiddleware() Middleware { + return func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + // Call the underlying handler. + res, err := next(ctx, method, req) + if err == nil { + return res, nil + } + + // Check if this is a URL elicitation required error. + var rpcErr *jsonrpc.Error + if !errors.As(err, &rpcErr) || rpcErr.Code != CodeURLElicitationRequired { + return res, err + } + + // Notifications don't support retries. + if strings.HasPrefix(method, "notifications/") { + return res, err + } + + // Extract the client session. + cs, ok := req.GetSession().(*ClientSession) + if !ok { + return res, err + } + + // Check if the client has an elicitation handler. + if cs.client.opts.ElicitationHandler == nil { + return res, err + } + + // Parse the elicitations from the error data. + var errorData struct { + Elicitations []*ElicitParams `json:"elicitations"` + } + if rpcErr.Data != nil { + if err := json.Unmarshal(rpcErr.Data, &errorData); err != nil { + return nil, fmt.Errorf("failed to parse URL elicitation error data: %w", err) + } + } + + // Validate that all elicitations are URL mode. + for _, elicit := range errorData.Elicitations { + mode := elicit.Mode + if mode == "" { + mode = "form" // Default mode. + } + if mode != "url" { + return nil, fmt.Errorf("URLElicitationRequired error must only contain URL mode elicitations, got %q", mode) + } + } + + // Register waiters for all elicitations before executing handlers + // to avoid race condition where notification arrives before waiter is registered. + type waiter struct { + await func(context.Context) error + cleanup func() + } + waiters := make([]waiter, 0, len(errorData.Elicitations)) + for _, elicitParams := range errorData.Elicitations { + await, cleanup := cs.registerElicitationWaiter(elicitParams.ElicitationID) + waiters = append(waiters, waiter{await: await, cleanup: cleanup}) + } + + // Ensure cleanup happens even if we return early. + defer func() { + for _, w := range waiters { + w.cleanup() + } + }() + + // Execute the elicitation handler for each elicitation. + for _, elicitParams := range errorData.Elicitations { + elicitReq := newClientRequest(cs, elicitParams) + _, elicitErr := cs.client.elicit(ctx, elicitReq) + if elicitErr != nil { + return nil, fmt.Errorf("URL elicitation failed: %w", elicitErr) + } + } + + // Wait for all elicitations to complete. + for _, w := range waiters { + if err := w.await(ctx); err != nil { + return nil, err + } + } + + // All elicitations complete, retry the original operation. + return next(ctx, method, req) + } + } +} + func (c *Client) elicit(ctx context.Context, req *ElicitRequest) (*ElicitResult, error) { if c.opts.ElicitationHandler == nil { - return nil, jsonrpc2.NewError(codeInvalidParams, "client does not support elicitation") + return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "client does not support elicitation"} } // Validate the elicitation parameters based on the mode. @@ -323,11 +474,11 @@ func (c *Client) elicit(ctx context.Context, req *ElicitRequest) (*ElicitResult, switch mode { case "form": if req.Params.URL != "" { - return nil, jsonrpc2.NewError(codeInvalidParams, "URL must not be set for form elicitation") + return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "URL must not be set for form elicitation"} } schema, err := validateElicitSchema(req.Params.RequestedSchema) if err != nil { - return nil, jsonrpc2.NewError(codeInvalidParams, err.Error()) + return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: err.Error()} } res, err := c.opts.ElicitationHandler(ctx, req) if err != nil { @@ -337,28 +488,28 @@ func (c *Client) elicit(ctx context.Context, req *ElicitRequest) (*ElicitResult, if schema != nil && res.Content != nil { resolved, err := schema.Resolve(nil) if err != nil { - return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("failed to resolve requested schema: %v", err)) + return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: fmt.Sprintf("failed to resolve requested schema: %v", err)} } if err := resolved.Validate(res.Content); err != nil { - return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("elicitation result content does not match requested schema: %v", err)) + return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: fmt.Sprintf("elicitation result content does not match requested schema: %v", err)} } err = resolved.ApplyDefaults(&res.Content) if err != nil { - return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("failed to apply schema defalts to elicitation result: %v", err)) + return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: fmt.Sprintf("failed to apply schema defalts to elicitation result: %v", err)} } } return res, nil case "url": if req.Params.RequestedSchema != nil { - return nil, jsonrpc2.NewError(codeInvalidParams, "requestedSchema must not be set for URL elicitation") + return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "requestedSchema must not be set for URL elicitation"} } if req.Params.URL == "" { - return nil, jsonrpc2.NewError(codeInvalidParams, "URL must be set for URL elicitation") + return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "URL must be set for URL elicitation"} } // No schema validation for URL mode, just pass through to handler. return c.opts.ElicitationHandler(ctx, req) default: - return nil, jsonrpc2.NewError(codeInvalidParams, fmt.Sprintf("unsupported elicitation mode: %q", mode)) + return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: fmt.Sprintf("unsupported elicitation mode: %q", mode)} } } @@ -723,6 +874,20 @@ func (cs *ClientSession) callProgressNotificationHandler(ctx context.Context, pa } func (c *Client) callElicitationCompleteHandler(ctx context.Context, req *ElicitationCompleteNotificationRequest) (Result, error) { + // Check if there's a pending elicitation waiting for this notification. + if cs, ok := req.GetSession().(*ClientSession); ok { + cs.pendingElicitationsMu.Lock() + if ch, exists := cs.pendingElicitations[req.Params.ElicitationID]; exists { + select { + case ch <- struct{}{}: + default: + // Channel already signaled. + } + } + cs.pendingElicitationsMu.Unlock() + } + + // Call the user's handler if provided. if h := c.opts.ElicitationCompleteHandler; h != nil { h(ctx, req) } diff --git a/mcp/client_test.go b/mcp/client_test.go index eaeedc8..a7fb68d 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -222,6 +222,61 @@ func TestClientCapabilities(t *testing.T) { Sampling: &SamplingCapabilities{}, }, }, + { + name: "With form elicitation", + configureClient: func(s *Client) {}, + clientOpts: ClientOptions{ + ElicitationModes: []string{"form"}, + ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) { + return nil, nil + }, + }, + wantCapabilities: &ClientCapabilities{ + Roots: struct { + ListChanged bool "json:\"listChanged,omitempty\"" + }{ListChanged: true}, + Elicitation: &ElicitationCapabilities{ + Form: &FormElicitationCapabilities{}, + }, + }, + }, + { + name: "With URL elicitation", + configureClient: func(s *Client) {}, + clientOpts: ClientOptions{ + ElicitationModes: []string{"url"}, + ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) { + return nil, nil + }, + }, + wantCapabilities: &ClientCapabilities{ + Roots: struct { + ListChanged bool "json:\"listChanged,omitempty\"" + }{ListChanged: true}, + Elicitation: &ElicitationCapabilities{ + URL: &URLElicitationCapabilities{}, + }, + }, + }, + { + name: "With both form and URL elicitation", + configureClient: func(s *Client) {}, + clientOpts: ClientOptions{ + ElicitationModes: []string{"form", "url"}, + ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) { + return nil, nil + }, + }, + wantCapabilities: &ClientCapabilities{ + Roots: struct { + ListChanged bool "json:\"listChanged,omitempty\"" + }{ListChanged: true}, + Elicitation: &ElicitationCapabilities{ + Form: &FormElicitationCapabilities{}, + URL: &URLElicitationCapabilities{}, + }, + }, + }, } for _, tc := range testCases { diff --git a/mcp/elicitation_test.go b/mcp/elicitation_test.go index de5f65d..8da033e 100644 --- a/mcp/elicitation_test.go +++ b/mcp/elicitation_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) // TODO: migrate other elicitation tests here. @@ -74,7 +75,7 @@ func TestElicitationURLMode(t *testing.T) { Message: "URL is missing", }, wantErrMsg: "URL must be set for URL elicitation", - wantErrCode: codeInvalidParams, + wantErrCode: jsonrpc.CodeInvalidParams, }, { name: "schema not allowed", @@ -90,7 +91,7 @@ func TestElicitationURLMode(t *testing.T) { }, }, wantErrMsg: "requestedSchema must not be set for URL elicitation", - wantErrCode: codeInvalidParams, + wantErrCode: jsonrpc.CodeInvalidParams, }, } for _, tc := range testCases { diff --git a/mcp/error_test.go b/mcp/error_test.go new file mode 100644 index 0000000..694ef10 --- /dev/null +++ b/mcp/error_test.go @@ -0,0 +1,353 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +import ( + "context" + "encoding/json" + "errors" + "strings" + "sync/atomic" + "testing" + + "github.com/modelcontextprotocol/go-sdk/jsonrpc" +) + +// TestServerErrors validates that the server returns appropriate error codes +// for various invalid requests. +func TestServerErrors(t *testing.T) { + ctx := context.Background() + + // Set up a server with tools, prompts, and resources for testing + cs, _, cleanup := basicConnection(t, func(s *Server) { + // Add a tool with required parameters + type RequiredParams struct { + Name string `json:"name" jsonschema:"the name is required"` + } + handler := func(ctx context.Context, req *CallToolRequest, args RequiredParams) (*CallToolResult, any, error) { + return &CallToolResult{ + Content: []Content{&TextContent{Text: "success"}}, + }, nil, nil + } + AddTool(s, &Tool{Name: "validate", Description: "validates params"}, handler) + + // Add a prompt + s.AddPrompt(codeReviewPrompt, codReviewPromptHandler) + + // Add a resource that returns ResourceNotFoundError + s.AddResource( + &Resource{URI: "file:///test.txt", Name: "test", MIMEType: "text/plain"}, + func(ctx context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) { + return nil, ResourceNotFoundError(req.Params.URI) + }, + ) + }) + defer cleanup() + + testCases := []struct { + name string + executeCall func() error + expectedCode int64 + }{ + { + name: "missing required param", + executeCall: func() error { + _, err := cs.CallTool(ctx, &CallToolParams{ + Name: "validate", + Arguments: map[string]any{}, // Missing required "name" field + }) + return err + }, + expectedCode: jsonrpc.CodeInvalidParams, + }, + { + name: "unknown tool", + executeCall: func() error { + _, err := cs.CallTool(ctx, &CallToolParams{ + Name: "nonexistent_tool", + Arguments: map[string]any{}, + }) + return err + }, + expectedCode: jsonrpc.CodeInvalidParams, + }, + { + name: "unknown prompt", + executeCall: func() error { + _, err := cs.GetPrompt(ctx, &GetPromptParams{ + Name: "nonexistent_prompt", + Arguments: map[string]string{}, + }) + return err + }, + expectedCode: jsonrpc.CodeInvalidParams, + }, + { + name: "resource not found", + executeCall: func() error { + _, err := cs.ReadResource(ctx, &ReadResourceParams{ + URI: "file:///test.txt", + }) + return err + }, + expectedCode: CodeResourceNotFound, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := tc.executeCall() + if err == nil { + t.Fatal("got nil error, want non-nil") + } + + var rpcErr *jsonrpc.Error + if !errors.As(err, &rpcErr) { + t.Fatalf("got error type %T, want jsonrpc.Error: %v", err, err) + } + + if rpcErr.Code != tc.expectedCode { + t.Errorf("got error code %d, want %d", rpcErr.Code, tc.expectedCode) + } + + if rpcErr.Message == "" { + t.Error("got empty error message, want non-empty") + } + }) + } +} + +// TestURLElicitationRequired validates that URL elicitation required errors +// are properly created and handled by the client. +func TestURLElicitationRequired(t *testing.T) { + ctx := context.Background() + + t.Run("error creation", func(t *testing.T) { + elicitations := []*ElicitParams{ + { + Mode: "url", + Message: "Please authorize", + URL: "https://example.com/auth", + ElicitationID: "auth-123", + }, + } + + err := URLElicitationRequiredError(elicitations) + + var rpcErr *jsonrpc.Error + if !errors.As(err, &rpcErr) { + t.Fatalf("got error type %T, want jsonrpc.Error", err) + } + + if rpcErr.Code != CodeURLElicitationRequired { + t.Errorf("got error code %d, want %d", rpcErr.Code, CodeURLElicitationRequired) + } + + if rpcErr.Message != "URL elicitation required" { + t.Errorf("got message %q, want 'URL elicitation required'", rpcErr.Message) + } + + if rpcErr.Data == nil { + t.Fatal("got nil error data, want non-nil") + } + + // Verify the elicitations can be unmarshaled from the error data + var errorData struct { + Elicitations []*ElicitParams `json:"elicitations"` + } + if err := json.Unmarshal(rpcErr.Data, &errorData); err != nil { + t.Fatalf("failed to unmarshal error data: %v", err) + } + + if len(errorData.Elicitations) != 1 { + t.Fatalf("got %d elicitations, want 1", len(errorData.Elicitations)) + } + + if errorData.Elicitations[0].URL != "https://example.com/auth" { + t.Errorf("got URL %q, want 'https://example.com/auth'", errorData.Elicitations[0].URL) + } + }) + + t.Run("error creation with non-URL mode panics", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("got no panic when creating URLElicitationRequiredError with non-URL mode, want panic") + } + }() + + // This should panic because mode is "form" + URLElicitationRequiredError([]*ElicitParams{ + { + Mode: "form", + Message: "This should panic", + ElicitationID: "bad-123", + }, + }) + }) + + t.Run("error creation with empty mode panics", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("got no panic when creating URLElicitationRequiredError with empty mode (defaults to form), want panic") + } + }() + + // This should panic because empty mode defaults to "form" + URLElicitationRequiredError([]*ElicitParams{ + { + Message: "This should panic", + ElicitationID: "bad-123", + }, + }) + }) + + t.Run("client middleware", func(t *testing.T) { + // Declare ss outside so it can be captured in handlers. + var ss *ServerSession + + elicitCalled := false + elicitURL := "" + elicitID := "form-123" + + // Create client with elicitation handler and middleware. + client := NewClient(testImpl, &ClientOptions{ + ElicitationModes: []string{"url"}, + ElicitationHandler: func(ctx context.Context, req *ElicitRequest) (*ElicitResult, error) { + elicitCalled = true + elicitURL = req.Params.URL + + // Simulate the server sending elicitation complete notification. + // In a real scenario, this would happen out-of-band after the user + // completes the form submission. + go func() { + err := handleNotify(ctx, notificationElicitationComplete, + newServerRequest(ss, &ElicitationCompleteParams{ + ElicitationID: elicitID, + })) + if err != nil { + t.Errorf("failed to send elicitation complete notification: %v", err) + } + }() + + return &ElicitResult{Action: "accept"}, nil + }, + }) + // Add URL elicitation middleware for automatic retry. + client.AddSendingMiddleware(urlElicitationMiddleware()) + + var callCount atomic.Int32 + + cs, serverSession, cleanup := basicClientServerConnection(t, + client, + nil, + func(s *Server) { + // Tool that requires form submission on first call, succeeds on second. + handler := func(ctx context.Context, req *CallToolRequest, args map[string]any) (*CallToolResult, any, error) { + if callCount.Add(1) == 1 { + // First call: require elicitation. + return nil, nil, URLElicitationRequiredError([]*ElicitParams{ + { + Mode: "url", + Message: "Please complete the form", + URL: "https://example.com/form", + ElicitationID: elicitID, + }, + }) + } + // Second call (after retry): return success. + return &CallToolResult{ + Content: []Content{&TextContent{Text: "form submitted"}}, + }, nil, nil + } + AddTool(s, &Tool{Name: "submit_form", Description: "requires form submission"}, handler) + + // Tool that returns invalid elicitation mode (form instead of URL). + badHandler := func(ctx context.Context, req *CallToolRequest, args map[string]any) (*CallToolResult, any, error) { + // Manually construct an error with form mode (bypassing validation). + data, _ := json.Marshal(map[string]any{ + "elicitations": []*ElicitParams{ + { + Mode: "form", + Message: "Invalid mode", + ElicitationID: "bad-form", + }, + }, + }) + return nil, nil, &jsonrpc.Error{ + Code: CodeURLElicitationRequired, + Message: "URL elicitation required", + Data: json.RawMessage(data), + } + } + AddTool(s, &Tool{Name: "bad_tool", Description: "returns invalid elicitation"}, badHandler) + }, + ) + ss = serverSession + defer cleanup() + + t.Run("auto-retry after elicitation", func(t *testing.T) { + // Reset state for this subtest. + elicitCalled = false + elicitURL = "" + callCount.Store(0) + + // Call the tool that requires URL elicitation. + result, err := cs.CallTool(ctx, &CallToolParams{ + Name: "submit_form", + Arguments: map[string]any{}, + }) + + // After automatic retry, the operation should succeed. + if err != nil { + t.Fatalf("CallTool failed: %v", err) + } + + // Verify the elicitation handler was called. + if !elicitCalled { + t.Error("elicitation handler not called") + } + + if elicitURL != "https://example.com/form" { + t.Errorf("got elicit URL %q, want 'https://example.com/form'", elicitURL) + } + + // Verify the tool was called twice (first attempt + retry). + if got, want := callCount.Load(), int32(2); got != want { + t.Errorf("CallTool(): with retry, got %d tool calls, want %d", got, want) + } + + // Verify we got the successful result. + if len(result.Content) != 1 { + t.Fatalf("CallTool(): got %d content items, want 1", len(result.Content)) + } + + textContent, ok := result.Content[0].(*TextContent) + if !ok { + t.Fatalf("CallTool(): got content type %T, want TextContent", result.Content[0]) + } + + if textContent.Text != "form submitted" { + t.Errorf("CallTool(): got text %q, want 'form submitted'", textContent.Text) + } + }) + + t.Run("reject non-URL mode", func(t *testing.T) { + // Call the tool that returns invalid elicitation mode. + _, err := cs.CallTool(ctx, &CallToolParams{ + Name: "bad_tool", + Arguments: map[string]any{}, + }) + + // Should get an error about invalid mode. + if err == nil { + t.Fatal("got nil error for non-URL mode elicitation, want error") + } + + if !strings.Contains(err.Error(), "URL mode") { + t.Errorf("got error %v, want mention of URL mode", err) + } + }) + }) +} diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index c2c949e..3edfefd 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -24,7 +24,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 hiParams struct { @@ -316,7 +316,7 @@ func TestEndToEnd(t *testing.T) { } { rres, err := cs.ReadResource(ctx, &ReadResourceParams{URI: tt.uri}) if err != nil { - if code := errorCode(err); code == codeResourceNotFound { + if code := errorCode(err); code == CodeResourceNotFound { if tt.mimeType != "" { t.Errorf("%s: not found but expected it to be", tt.uri) } @@ -576,7 +576,7 @@ func errorCode(err error) int64 { if err == nil { return 0 } - var werr *jsonrpc2.WireError + var werr *jsonrpc.Error if errors.As(err, &werr) { return werr.Code } @@ -1367,8 +1367,8 @@ func TestElicitationSchemaValidation(t *testing.T) { t.Errorf("expected error for invalid schema %q, got nil", tc.name) return } - if code := errorCode(err); code != codeInvalidParams { - t.Errorf("got error code %d, want %d (CodeInvalidParams)", code, codeInvalidParams) + if code := errorCode(err); code != jsonrpc.CodeInvalidParams { + t.Errorf("got error code %d, want %d (CodeInvalidParams)", code, jsonrpc.CodeInvalidParams) } if !strings.Contains(err.Error(), tc.expectedError) { t.Errorf("error message %q does not contain expected text %q", err.Error(), tc.expectedError) diff --git a/mcp/resource.go b/mcp/resource.go index 8746eda..dc657f5 100644 --- a/mcp/resource.go +++ b/mcp/resource.go @@ -15,8 +15,8 @@ import ( "path/filepath" "strings" - "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/internal/util" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/yosida95/uritemplate/v3" ) @@ -40,8 +40,8 @@ type ResourceHandler func(context.Context, *ReadResourceRequest) (*ReadResourceR // ResourceNotFoundError returns an error indicating that a resource being read could // not be found. func ResourceNotFoundError(uri string) error { - return &jsonrpc2.WireError{ - Code: codeResourceNotFound, + return &jsonrpc.Error{ + Code: CodeResourceNotFound, Message: "Resource not found", Data: json.RawMessage(fmt.Sprintf(`{"uri":%q}`, uri)), } diff --git a/mcp/server.go b/mcp/server.go index 254c2d5..d431722 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -146,7 +146,7 @@ func NewServer(impl *Implementation, options *ServerOptions) *Server { tools: newFeatureSet(func(t *serverTool) string { return t.tool.Name }), resources: newFeatureSet(func(r *serverResource) string { return r.resource.URI }), resourceTemplates: newFeatureSet(func(t *serverResourceTemplate) string { return t.resourceTemplate.URITemplate }), - sendingMethodHandler_: defaultSendingMethodHandler[*ServerSession], + sendingMethodHandler_: defaultSendingMethodHandler, receivingMethodHandler_: defaultReceivingMethodHandler[*ServerSession], resourceSubscriptions: make(map[string]map[*ServerSession]bool), } @@ -293,12 +293,12 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHan // Call typed handler. res, out, err := h(ctx, req, in) // Handle server errors appropriately: - // - If the handler returns a structured error (like jsonrpc2.WireError), return it directly + // - If the handler returns a structured error (like jsonrpc.Error), return it directly // - If the handler returns a regular error, wrap it in a CallToolResult with IsError=true // - This allows tools to distinguish between protocol errors and tool execution errors if err != nil { // Check if this is already a structured JSON-RPC error - if wireErr, ok := err.(*jsonrpc2.WireError); ok { + if wireErr, ok := err.(*jsonrpc.Error); ok { return nil, wireErr } // For regular errors, embed them in the tool result as per MCP spec @@ -542,8 +542,8 @@ func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetProm s.mu.Unlock() if !ok { // Return a proper JSON-RPC error with the correct error code - return nil, &jsonrpc2.WireError{ - Code: codeInvalidParams, + return nil, &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidParams, Message: fmt.Sprintf("unknown prompt %q", req.Params.Name), } } @@ -569,8 +569,8 @@ func (s *Server) callTool(ctx context.Context, req *CallToolRequest) (*CallToolR st, ok := s.tools.get(req.Params.Name) s.mu.Unlock() if !ok { - return nil, &jsonrpc2.WireError{ - Code: codeInvalidParams, + return nil, &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidParams, Message: fmt.Sprintf("unknown tool %q", req.Params.Name), } } diff --git a/mcp/shared.go b/mcp/shared.go index 3fac40b..e590144 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -88,7 +88,7 @@ func addMiddleware(handlerp *MethodHandler, middleware []Middleware) { } } -func defaultSendingMethodHandler[S Session](ctx context.Context, method string, req Request) (Result, error) { +func defaultSendingMethodHandler(ctx context.Context, method string, req Request) (Result, error) { info, ok := req.GetSession().sendingMethodInfos()[method] if !ok { // This can be called from user code, with an arbitrary value for method. @@ -331,16 +331,52 @@ func clientSessionMethod[P Params, R Result](f func(*ClientSession, context.Cont } } -// Error codes +// MCP-specific error codes. +const ( + // CodeResourceNotFound indicates that a requested resource could not be found. + CodeResourceNotFound = -32002 + // CodeURLElicitationRequired indicates that the server requires URL elicitation + // before processing the request. The client should execute the elicitation handler + // with the elicitations provided in the error data. + CodeURLElicitationRequired = -32042 +) + +// URLElicitationRequiredError returns an error indicating that URL elicitation is required +// before the request can be processed. The elicitations parameter should contain the +// elicitation requests that must be completed. +func URLElicitationRequiredError(elicitations []*ElicitParams) error { + // Validate that all elicitations are URL mode + for _, elicit := range elicitations { + mode := elicit.Mode + if mode == "" { + mode = "form" // default mode + } + if mode != "url" { + panic(fmt.Sprintf("URLElicitationRequiredError requires all elicitations to be URL mode, got %q", mode)) + } + } + + data, err := json.Marshal(map[string]any{ + "elicitations": elicitations, + }) + if err != nil { + // This should never happen with valid ElicitParams + panic(fmt.Sprintf("failed to marshal elicitations: %v", err)) + } + return &jsonrpc.Error{ + Code: CodeURLElicitationRequired, + Message: "URL elicitation required", + Data: json.RawMessage(data), + } +} + +// Internal error codes const ( - codeResourceNotFound = -32002 // The error code if the method exists and was called properly, but the peer does not support it. // // TODO(rfindley): this code is wrong, and we should fix it to be // consistent with other SDKs. codeUnsupportedMethod = -31001 - // The error code for invalid parameters - codeInvalidParams = -32602 ) // notifySessions calls Notify on all the sessions. diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index e9c0cbd..8c66bf6 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -962,7 +962,7 @@ func TestStreamableServerTransport(t *testing.T) { method: "POST", messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusOK, - wantMessages: []jsonrpc.Message{resp(2, nil, &jsonrpc2.WireError{ + wantMessages: []jsonrpc.Message{resp(2, nil, &jsonrpc.Error{ Message: `method "tools/call" is invalid during session initialization`, })}, }, diff --git a/mcp/tool_test.go b/mcp/tool_test.go index 80661d3..dfd859b 100644 --- a/mcp/tool_test.go +++ b/mcp/tool_test.go @@ -14,7 +14,7 @@ import ( "testing" "github.com/google/jsonschema-go/jsonschema" - "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) func TestApplySchema(t *testing.T) { @@ -65,8 +65,8 @@ func TestToolErrorHandling(t *testing.T) { // Create a tool that returns a structured error structuredErrorHandler := func(ctx context.Context, req *CallToolRequest, args map[string]any) (*CallToolResult, any, error) { - return nil, nil, &jsonrpc2.WireError{ - Code: codeInvalidParams, + return nil, nil, &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidParams, Message: "internal server error", } } @@ -106,13 +106,13 @@ func TestToolErrorHandling(t *testing.T) { t.Fatal("expected error, got nil") } - var wireErr *jsonrpc2.WireError + var wireErr *jsonrpc.Error if !errors.As(err, &wireErr) { - t.Fatalf("expected WireError, got %[1]T: %[1]v", err) + t.Fatalf("expected jsonrpc.Error, got %[1]T: %[1]v", err) } - if wireErr.Code != codeInvalidParams { - t.Errorf("expected error code %d, got %d", codeInvalidParams, wireErr.Code) + if wireErr.Code != jsonrpc.CodeInvalidParams { + t.Errorf("expected error code %d, got %d", jsonrpc.CodeInvalidParams, wireErr.Code) } }) diff --git a/mcp/transport.go b/mcp/transport.go index cacd65f..d6359f5 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -204,8 +204,7 @@ func (c *canceller) Preempt(ctx context.Context, req *jsonrpc.Request) (result a // call executes and awaits a jsonrpc2 call on the given connection, // translating errors into the mcp domain. func call(ctx context.Context, conn *jsonrpc2.Connection, method string, params Params, result Result) error { - // TODO: the "%w"s in this function effectively make jsonrpc2.WireError part of the API. - // Consider alternatives. + // The "%w"s in this function expose jsonrpc.Error as part of the API. call := conn.Call(ctx, method, params) err := call.Await(ctx, result) switch { From 1bec3c60a0229df71933fdea7d5046702fc8cc4b Mon Sep 17 00:00:00 2001 From: isfzhang Date: Tue, 2 Dec 2025 01:16:39 +0800 Subject: [PATCH 128/418] fix: handle Windows CRLF in MCP client(#664) (#665) This change updates the MCP client stdio JSON decoder to correctly handle both Unix (`\n`) and Windows (`\r\n`) line endings. Fixes #664 Co-authored-by: qysz --- mcp/transport.go | 3 ++- mcp/transport_test.go | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/mcp/transport.go b/mcp/transport.go index d6359f5..ec57b38 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -380,7 +380,8 @@ func newIOConn(rwc io.ReadWriteCloser) *ioConn { var tr [1]byte if n, readErr := dec.Buffered().Read(tr[:]); n > 0 { // If read byte is not a newline, it is an error. - if tr[0] != '\n' { + // Support both Unix (\n) and Windows (\r\n) line endings. + if tr[0] != '\n' && tr[0] != '\r' { err = fmt.Errorf("invalid trailing data at the end of stream") } } else if readErr != nil && readErr != io.EOF { diff --git a/mcp/transport_test.go b/mcp/transport_test.go index aeff366..515b8c1 100644 --- a/mcp/transport_test.go +++ b/mcp/transport_test.go @@ -83,6 +83,11 @@ func TestIOConnRead(t *testing.T) { want: "", protocolVersion: "", }, + { + name: "windows newline at the end of first valid json input", + input: "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"test\",\"params\":{}}\r\n", + want: "", + }, { name: "batching old protocol", input: `[{"jsonrpc":"2.0","id":1,"method":"test1"},{"jsonrpc":"2.0","id":2,"method":"test2"}]`, From 623780125cbb74daba04375807b887b935e50037 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Mon, 1 Dec 2025 16:21:37 -0500 Subject: [PATCH 129/418] mcp: support SSE polling and server disconnects (SEP-1699) (#663) This CL implements support for SEP-1699, adding the following new API: - Event.Retry corresponds to the 'retry' field of a server sent event. This is used to signal client retry behavior. - RequestExtra.CloseStream is set by the streamable transport to allow closing a stream. The streamable server transport is updated to set CloseStream for calls, allowing server operations or middleware to close the underlying stream. If the configured 'reconnectAfter' delay is set, a 'retry' message is sent prior to terminating the stream. The streamable client transport is updated to read the configured delay. Fixes #630 --- docs/rough_edges.md | 4 + internal/docs/rough_edges.src.md | 4 + mcp/event.go | 23 ++-- mcp/shared.go | 8 ++ mcp/streamable.go | 161 ++++++++++++++++++++---- mcp/streamable_test.go | 204 +++++++++++++++++++++++++++---- 6 files changed, 340 insertions(+), 64 deletions(-) diff --git a/docs/rough_edges.md b/docs/rough_edges.md index 41888f8..5c732bd 100644 --- a/docs/rough_edges.md +++ b/docs/rough_edges.md @@ -11,6 +11,10 @@ v2. **Workaround**: `Open` may be implemented as a no-op. +- `Event` need not have been exported: it's an implementation detail of the SSE + and streamable transports. Also the 'Name' field is a misnomer: it should be + 'event'. + - Enforcing valid tool names: with [SEP-986](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/986) landing after the SDK was at v1, we missed an opportunity to panic on invalid diff --git a/internal/docs/rough_edges.src.md b/internal/docs/rough_edges.src.md index fb52391..ff95b26 100644 --- a/internal/docs/rough_edges.src.md +++ b/internal/docs/rough_edges.src.md @@ -10,6 +10,10 @@ v2. **Workaround**: `Open` may be implemented as a no-op. +- `Event` need not have been exported: it's an implementation detail of the SSE + and streamable transports. Also the 'Name' field is a misnomer: it should be + 'event'. + - Enforcing valid tool names: with [SEP-986](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/986) landing after the SDK was at v1, we missed an opportunity to panic on invalid diff --git a/mcp/event.go b/mcp/event.go index 281f592..5c322c4 100644 --- a/mcp/event.go +++ b/mcp/event.go @@ -29,14 +29,15 @@ const validateMemoryEventStore = false // An Event is a server-sent event. // See https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#fields. type Event struct { - Name string // the "event" field - ID string // the "id" field - Data []byte // the "data" field + Name string // the "event" field + ID string // the "id" field + Data []byte // the "data" field + Retry string // the "retry" field } // Empty reports whether the Event is empty. func (e Event) Empty() bool { - return e.Name == "" && e.ID == "" && len(e.Data) == 0 + return e.Name == "" && e.ID == "" && len(e.Data) == 0 && e.Retry == "" } // writeEvent writes the event to w, and flushes. @@ -48,6 +49,9 @@ func writeEvent(w io.Writer, evt Event) (int, error) { if evt.ID != "" { fmt.Fprintf(&b, "id: %s\n", evt.ID) } + if evt.Retry != "" { + fmt.Fprintf(&b, "retry: %s\n", evt.Retry) + } fmt.Fprintf(&b, "data: %s\n\n", string(evt.Data)) n, err := w.Write(b.Bytes()) if f, ok := w.(http.Flusher); ok { @@ -73,6 +77,7 @@ func scanEvents(r io.Reader) iter.Seq2[Event, error] { eventKey = []byte("event") idKey = []byte("id") dataKey = []byte("data") + retryKey = []byte("retry") ) return func(yield func(Event, error) bool) { @@ -119,6 +124,8 @@ func scanEvents(r io.Reader) iter.Seq2[Event, error] { evt.Name = strings.TrimSpace(string(after)) case bytes.Equal(before, idKey): evt.ID = strings.TrimSpace(string(after)) + case bytes.Equal(before, retryKey): + evt.Retry = strings.TrimSpace(string(after)) case bytes.Equal(before, dataKey): data := bytes.TrimSpace(after) if dataBuf != nil { @@ -191,12 +198,8 @@ type dataList struct { } func (dl *dataList) appendData(d []byte) { - // If we allowed empty data, we would consume memory without incrementing the size. - // We could of course account for that, but we keep it simple and assume there is no - // empty data. - if len(d) == 0 { - panic("empty data item") - } + // Empty data consumes memory but doesn't increment size. However, it should + // be rare. dl.data = append(dl.data, d) dl.size += len(d) } diff --git a/mcp/shared.go b/mcp/shared.go index e590144..43a0d6f 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -468,6 +468,14 @@ type ServerRequest[P Params] struct { type RequestExtra struct { TokenInfo *auth.TokenInfo // bearer token info (e.g. from OAuth) if any Header http.Header // header from HTTP request, if any + + // CloseStream closes the current request stream, if the current transport + // supports replaying requests. + // + // If reconnectAfter is nonzero, it signals to the client to reconnect after + // the given duration. Otherwise, clients may determine their own + // reconnection policy. + CloseStream func(reconnectAfter time.Duration) } func (*ClientRequest[P]) isRequest() {} diff --git a/mcp/streamable.go b/mcp/streamable.go index 67cdc39..21c7384 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -602,12 +602,25 @@ type stream struct { // HTTP connection acquires ownership of the stream by setting this field. deliver func(data []byte, final bool) error + // closeLocked sends a 'close' event to the client with a configurable retry + // delay, if there is a delivery channel available. The stream must be + // locked. + closeLocked func(reconnectAfter time.Duration) + // streamRequests is the set of unanswered incoming requests for the stream. // // Requests are removed when their response has been received. requests map[jsonrpc.ID]struct{} } +func (s *stream) close(reconnectAfter time.Duration) { + s.mu.Lock() + defer s.mu.Unlock() + if s.closeLocked != nil { + s.closeLocked(reconnectAfter) + } +} + // doneLocked reports whether the stream is logically complete. // // s.mu must be held while calling this function. @@ -704,6 +717,7 @@ func (c *streamableServerConn) serveGET(w http.ResponseWriter, req *http.Request defer func() { stream.mu.Lock() stream.deliver = nil + stream.closeLocked = nil stream.mu.Unlock() }() @@ -720,14 +734,12 @@ func (c *streamableServerConn) serveGET(w http.ResponseWriter, req *http.Request // writeEvent writes an SSE event to w corresponding to the given stream, data, and index. // lastIdx is incremented before writing, so that it continues to point to the index of the // last event written to the stream. -func (c *streamableServerConn) writeEvent(w http.ResponseWriter, stream *stream, data []byte, lastIdx *int) error { +func (c *streamableServerConn) writeEvent(w http.ResponseWriter, streamID string, e Event, lastIdx *int) error { *lastIdx++ - e := Event{ - Name: "message", - Data: data, - } if c.eventStore != nil { - e.ID = formatEventID(stream.id, *lastIdx) + // TODO(rfindley): this isn't quite right: we should only set the ID if the + // message was actually stored successfully. + e.ID = formatEventID(streamID, *lastIdx) } if _, err := writeEvent(w, e); err != nil { return err @@ -735,6 +747,19 @@ func (c *streamableServerConn) writeEvent(w http.ResponseWriter, stream *stream, return nil } +// writeCloseEvent writes a 'close' event to the stream, signaling to the +// caller that they should reconnect after the configured delay. +func (c *streamableServerConn) writeCloseEvent(w http.ResponseWriter, reconnectAfter time.Duration) { + reconnectStr := strconv.FormatInt(reconnectAfter.Milliseconds(), 10) + // Note: this event is not stored, since we don't want or need to replay it. + if _, err := writeEvent(w, Event{ + Name: "close", // don't make this empty, as the default event type is "message" + Retry: reconnectStr, + }); err != nil { + c.logger.Warn(fmt.Sprintf("Writing close event: %v", err)) + } +} + // acquireStream acquires the stream and replays all events since lastIdx, if // any, updating lastIdx accordingly. If non-nil, the resulting stream will be // registered for receiving new messages, and the resulting done channel will @@ -805,7 +830,9 @@ func (c *streamableServerConn) acquireStream(ctx context.Context, w http.Respons http.Error(w, "failed to replay events", http.StatusBadRequest) return nil, nil } - toReplay = append(toReplay, data) + if len(data) > 0 { + toReplay = append(toReplay, data) + } } } @@ -823,7 +850,7 @@ func (c *streamableServerConn) acquireStream(ctx context.Context, w http.Respons } for _, data := range toReplay { - if err := c.writeEvent(w, s, data, lastIdx); err != nil { + if err := c.writeEvent(w, s.id, Event{Name: "message", Data: data}, lastIdx); err != nil { return nil, nil } } @@ -835,16 +862,31 @@ func (c *streamableServerConn) acquireStream(ctx context.Context, w http.Respons // The stream is not done: register a delivery function before the stream is // unlocked, allowing the connection to write new events. - done := make(chan struct{}) + var done = make(chan struct{}) s.deliver = func(data []byte, final bool) error { + select { + case <-done: + return fmt.Errorf("stream closed") + default: + } if err := ctx.Err(); err != nil { return err } - err := c.writeEvent(w, s, data, lastIdx) if final { - close(done) + defer close(done) } - return err + return c.writeEvent(w, s.id, Event{Name: "message", Data: data}, lastIdx) + } + s.closeLocked = func(reconnectAfter time.Duration) { + select { + case <-done: + return + default: + } + if reconnectAfter > 0 { + c.writeCloseEvent(w, reconnectAfter) + } + close(done) } return s, done } @@ -914,6 +956,19 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques } if jreq.IsCall() { calls[jreq.ID] = struct{}{} + jreq.Extra.(*RequestExtra).CloseStream = func(reconnectAfter time.Duration) { + c.mu.Lock() + streamID, ok := c.requestStreams[jreq.ID] + var stream *stream + if ok { + stream = c.streams[streamID] + } + c.mu.Unlock() + + if stream != nil { + stream.close(reconnectAfter) + } + } } } } @@ -993,11 +1048,39 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques } else { // Write events in the order we receive them. lastIndex := -1 + if c.eventStore != nil { + // Write a priming event. + // We must also write it to the event store in order for indexes to + // align. + if err := c.eventStore.Append(req.Context(), c.sessionID, stream.id, nil); err != nil { + c.logger.Warn(fmt.Sprintf("Storing priming event: %v", err)) + } + if err := c.writeEvent(w, stream.id, Event{Name: "prime"}, &lastIndex); err != nil { + c.logger.Warn(fmt.Sprintf("Writing priming event: %v", err)) + } + } + stream.deliver = func(data []byte, final bool) error { + select { + case <-done: + return fmt.Errorf("stream closed") + default: + } if final { defer close(done) } - return c.writeEvent(w, stream, data, &lastIndex) + return c.writeEvent(w, stream.id, Event{Name: "message", Data: data}, &lastIndex) + } + stream.closeLocked = func(reconnectAfter time.Duration) { + select { + case <-done: + return + default: + } + if reconnectAfter > 0 { + c.writeCloseEvent(w, reconnectAfter) + } + close(done) } } @@ -1007,6 +1090,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // TODO(rfindley): if we have no event store, we should really cancel all // remaining requests here, since the client will never get the results. stream.deliver = nil + stream.closeLocked = nil stream.mu.Unlock() }() @@ -1187,22 +1271,25 @@ func (c *streamableServerConn) Write(ctx context.Context, msg jsonrpc.Message) e } delivered := false + var errs []error + // TODO(rfindley): we should only append if the response is SSE, not JSON, by + // pushing down into the delivery layer. if c.eventStore != nil { if err := c.eventStore.Append(ctx, c.sessionID, s.id, data); err != nil { - // TODO: report a side-channel error. + errs = append(errs, err) } else { delivered = true } } if s.deliver != nil { if err := s.deliver(data, s.doneLocked()); err != nil { - // TODO: report a side-channel error. + errs = append(errs, err) } else { delivered = true } } if !delivered { - return fmt.Errorf("%w: undelivered message", jsonrpc2.ErrRejected) + return fmt.Errorf("%w: undelivered message: %v", jsonrpc2.ErrRejected, errors.Join(errs...)) } return nil } @@ -1360,7 +1447,7 @@ func (c *streamableClientConn) sessionUpdated(state clientSessionState) { } func (c *streamableClientConn) connectStandaloneSSE() { - resp, err := c.connectSSE("") + resp, err := c.connectSSE("", 0) if err != nil { c.fail(fmt.Errorf("standalone SSE request failed (session ID: %v): %v", c.sessionID, err)) return @@ -1589,7 +1676,7 @@ func (c *streamableClientConn) handleSSE(requestSummary string, resp *http.Respo // // Eventually, if we don't get the response, we should stop trying and // fail the request. - lastEventID, clientClosed := c.processStream(requestSummary, resp, forCall) + lastEventID, reconnectDelay, clientClosed := c.processStream(requestSummary, resp, forCall) // If the connection was closed by the client, we're done. if clientClosed { @@ -1602,7 +1689,7 @@ func (c *streamableClientConn) handleSSE(requestSummary string, resp *http.Respo } // The stream was interrupted or ended by the server. Attempt to reconnect. - newResp, err := c.connectSSE(lastEventID) + newResp, err := c.connectSSE(lastEventID, reconnectDelay) if err != nil { // All reconnection attempts failed: fail the connection. c.fail(fmt.Errorf("%s: failed to reconnect (session ID: %v): %v", requestSummary, c.sessionID, err)) @@ -1644,7 +1731,7 @@ func (c *streamableClientConn) checkResponse(requestSummary string, resp *http.R // incoming channel. It returns the ID of the last processed event and a flag // indicating if the connection was closed by the client. If resp is nil, it // returns "", false. -func (c *streamableClientConn) processStream(requestSummary string, resp *http.Response, forCall *jsonrpc.Request) (lastEventID string, clientClosed bool) { +func (c *streamableClientConn) processStream(requestSummary string, resp *http.Response, forCall *jsonrpc.Request) (lastEventID string, reconnectDelay time.Duration, clientClosed bool) { defer resp.Body.Close() for evt, err := range scanEvents(resp.Body) { if err != nil { @@ -1656,7 +1743,11 @@ func (c *streamableClientConn) processStream(requestSummary string, resp *http.R lastEventID = evt.ID } - // Skip non-message events (e.g., "ping" events used for keep-alive) + if evt.Retry != "" { + if n, err := strconv.ParseInt(evt.Retry, 10, 64); err == nil { + reconnectDelay = time.Duration(n) * time.Millisecond + } + } // According to SSE spec, events with no name default to "message" if evt.Name != "" && evt.Name != "message" { continue @@ -1665,7 +1756,7 @@ func (c *streamableClientConn) processStream(requestSummary string, resp *http.R msg, err := jsonrpc.DecodeMessage(evt.Data) if err != nil { c.fail(fmt.Errorf("%s: failed to decode event: %v", requestSummary, err)) - return "", true + return "", 0, true } select { @@ -1674,12 +1765,12 @@ func (c *streamableClientConn) processStream(requestSummary string, resp *http.R // TODO: we should never get a response when forReq is nil (the standalone SSE request). // We should detect this case. if jsonResp.ID == forCall.ID { - return "", true + return "", 0, true } } case <-c.done: // The connection was closed by the client; exit gracefully. - return "", true + return "", 0, true } } // The loop finished without an error, indicating the server closed the stream. @@ -1696,7 +1787,7 @@ func (c *streamableClientConn) processStream(requestSummary string, resp *http.R case <-c.done: } } - return lastEventID, false + return lastEventID, reconnectDelay, false } // connectSSE handles the logic of connecting a text/event-stream connection. @@ -1706,7 +1797,10 @@ func (c *streamableClientConn) processStream(requestSummary string, resp *http.R // If connection fails, connectSSE retries with an exponential backoff // strategy. It returns a new, valid HTTP response if successful, or an error // if all retries are exhausted. -func (c *streamableClientConn) connectSSE(lastEventID string) (*http.Response, error) { +// +// reconnectDelay is the delay set by the server using the SSE retry field, or +// 0. +func (c *streamableClientConn) connectSSE(lastEventID string, reconnectDelay time.Duration) (*http.Response, error) { var finalErr error // If lastEventID is set, we've already connected successfully once, so // consider that to be the first attempt. @@ -1714,11 +1808,23 @@ func (c *streamableClientConn) connectSSE(lastEventID string) (*http.Response, e if lastEventID != "" { attempt = 1 } + delay := calculateReconnectDelay(attempt) + if reconnectDelay > 0 { + delay = reconnectDelay // honor the server's requested initial delay + } for ; attempt <= c.maxRetries; attempt++ { select { case <-c.done: return nil, fmt.Errorf("connection closed by client during reconnect") - case <-time.After(calculateReconnectDelay(attempt)): + case <-c.ctx.Done(): + // If the connection context is canceled, the request below will not + // succeed anyway. + // + // TODO(#662): we should not be using the connection context for + // reconnection: we should instead be using the call context (from + // Write). + return nil, fmt.Errorf("connection context closed") + case <-time.After(delay): req, err := http.NewRequestWithContext(c.ctx, http.MethodGet, c.url, nil) if err != nil { return nil, err @@ -1731,6 +1837,7 @@ func (c *streamableClientConn) connectSSE(lastEventID string) (*http.Response, e resp, err := c.client.Do(req) if err != nil { finalErr = err // Store the error and try again. + delay = calculateReconnectDelay(attempt + 1) continue } return resp, nil diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 8c66bf6..727a283 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -517,6 +517,92 @@ func testClientReplay(t *testing.T, test clientReplayTest) { } } +func TestStreamableServerDisconnect(t *testing.T) { + server := NewServer(testImpl, nil) + + // Test that client replayability allows the server to terminate incoming + // requests immediately, and have the client replay them. + + // testStream exercises stream resumption by interleaving stream termination + // with progress notifications. + testStream := func(ctx context.Context, session *ServerSession, extra *RequestExtra) { + // Close the stream before the first message. We should have sent an + // initial priming message already, so the client will be able to replay + extra.CloseStream(10 * time.Millisecond) + session.NotifyProgress(ctx, &ProgressNotificationParams{Message: "msg1"}) + time.Sleep(20 * time.Millisecond) + extra.CloseStream(10 * time.Millisecond) // Closing twice should still be supported. + session.NotifyProgress(ctx, &ProgressNotificationParams{Message: "msg2"}) + } + + AddTool(server, &Tool{Name: "disconnect"}, + func(ctx context.Context, req *CallToolRequest, args map[string]any) (*CallToolResult, map[string]any, error) { + testStream(ctx, req.Session, req.Extra) + return new(CallToolResult), nil, nil + }) + + server.AddPrompt(&Prompt{Name: "disconnect"}, func(ctx context.Context, req *GetPromptRequest) (*GetPromptResult, error) { + testStream(ctx, req.Session, req.Extra) + return nil, nil + }) + + tests := []struct { + name string + doCall func(context.Context, *ClientSession) error + }{ + { + "tool", + func(ctx context.Context, cs *ClientSession) error { + _, err := cs.CallTool(ctx, &CallToolParams{Name: "disconnect"}) + return err + }, + }, + { + "prompt", + func(ctx context.Context, cs *ClientSession) error { + _, err := cs.GetPrompt(ctx, &GetPromptParams{Name: "disconnect"}) + return err + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + notifications := make(chan string, 2) + handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{ + EventStore: NewMemoryEventStore(nil), // support replayability + }) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + defer httpServer.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + client := NewClient(testImpl, &ClientOptions{ + ProgressNotificationHandler: func(ctx context.Context, req *ProgressNotificationClientRequest) { + notifications <- req.Params.Message + }, + }) + clientSession, err := client.Connect(ctx, &StreamableClientTransport{ + Endpoint: httpServer.URL, + }, nil) + if err != nil { + t.Fatalf("client.Connect() failed: %v", err) + } + defer clientSession.Close() + + if err = test.doCall(ctx, clientSession); err != nil { + t.Fatalf("CallTool failed: %v", err) + } + + got := readNotifications(t, ctx, notifications, 2) + want := []string{"msg1", "msg2"} + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("got unexpected notifications (-want +got):\n%s", diff) + } + }) + } +} + func TestServerTransportCleanup(t *testing.T) { nClient := 3 @@ -691,10 +777,10 @@ func TestStreamableServerTransport(t *testing.T) { tests := []struct { name string - replay bool // if set, use a MemoryEventStore to enable stream replay - tool func(*testing.T, context.Context, *ServerSession) - requests []streamableRequest // http requests - wantSessions int // number of sessions expected after the test + replay bool // if set, use a MemoryEventStore to enable replay + tool func(*testing.T, context.Context, *CallToolRequest) // if set, called during execution + requests []streamableRequest + wantSessions int // number of sessions expected after the test }{ { name: "basic", @@ -818,9 +904,9 @@ func TestStreamableServerTransport(t *testing.T) { }, { name: "tool notification", - tool: func(t *testing.T, ctx context.Context, ss *ServerSession) { + tool: func(t *testing.T, ctx context.Context, req *CallToolRequest) { // Send an arbitrary notification. - if err := ss.NotifyProgress(ctx, &ProgressNotificationParams{}); err != nil { + if err := req.Session.NotifyProgress(ctx, &ProgressNotificationParams{}); err != nil { t.Errorf("Notify failed: %v", err) } }, @@ -843,9 +929,9 @@ func TestStreamableServerTransport(t *testing.T) { }, { name: "tool upcall", - tool: func(t *testing.T, ctx context.Context, ss *ServerSession) { + tool: func(t *testing.T, ctx context.Context, req *CallToolRequest) { // Make an arbitrary call. - if _, err := ss.ListRoots(ctx, &ListRootsParams{}); err != nil { + if _, err := req.Session.ListRoots(ctx, &ListRootsParams{}); err != nil { t.Errorf("Call failed: %v", err) } }, @@ -878,21 +964,23 @@ func TestStreamableServerTransport(t *testing.T) { name: "background", // Enabling replay is necessary here because the standalone "GET" request // is fully asynronous. Replay is needed to guarantee message delivery. + // + // TODO(rfindley): this should no longer be necessary. replay: true, - tool: func(t *testing.T, _ context.Context, ss *ServerSession) { + tool: func(t *testing.T, _ context.Context, req *CallToolRequest) { // Perform operations on a background context, and ensure the client // receives it. ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() - if err := ss.NotifyProgress(ctx, &ProgressNotificationParams{}); err != nil { + if err := req.Session.NotifyProgress(ctx, &ProgressNotificationParams{}); err != nil { t.Errorf("Notify failed: %v", err) } // TODO(rfindley): finish implementing logging. // if err := ss.LoggingMessage(ctx, &LoggingMessageParams{}); err != nil { // t.Errorf("Logging failed: %v", err) // } - if _, err := ss.ListRoots(ctx, &ListRootsParams{}); err != nil { + if _, err := req.Session.ListRoots(ctx, &ListRootsParams{}); err != nil { t.Errorf("ListRoots failed: %v", err) } }, @@ -937,6 +1025,61 @@ func TestStreamableServerTransport(t *testing.T) { }, wantSessions: 0, // session deleted }, + { + name: "priming message", + replay: true, + requests: []streamableRequest{ + initialize, + initialized, + { + method: "POST", + messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})}, + wantStatusCode: http.StatusOK, + wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)}, + wantBodyContaining: "prime", + }, + }, + wantSessions: 1, + }, + { + name: "close message", + replay: true, + tool: func(t *testing.T, _ context.Context, req *CallToolRequest) { + req.Extra.CloseStream(time.Millisecond) + }, + requests: []streamableRequest{ + initialize, + initialized, + { + method: "POST", + messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})}, + wantStatusCode: http.StatusOK, + wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)}, + wantBodyContaining: "close", + wantBodyNotContaining: "result", + }, + }, + wantSessions: 1, + }, + { + name: "no close message", + replay: true, + tool: func(t *testing.T, _ context.Context, req *CallToolRequest) { + req.Extra.CloseStream(0) + }, + requests: []streamableRequest{ + initialize, + initialized, + { + method: "POST", + messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})}, + wantStatusCode: http.StatusOK, + wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)}, + wantBodyNotContaining: "close", + }, + }, + wantSessions: 1, + }, { name: "errors", requests: []streamableRequest{ @@ -980,7 +1123,7 @@ func TestStreamableServerTransport(t *testing.T) { &Tool{Name: "tool", InputSchema: &jsonschema.Schema{Type: "object"}}, func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { if test.tool != nil { - test.tool(t, ctx, req.Session) + test.tool(t, ctx, req) } return &CallToolResult{}, nil }) @@ -1092,11 +1235,14 @@ func testStreamableHandler(t *testing.T, handler http.Handler, requests []stream } wg.Wait() - if request.wantBodyContaining != "" { + if request.wantBodyContaining != "" || request.wantBodyNotContaining != "" { body := string(gotBody) - if !strings.Contains(body, request.wantBodyContaining) { + if request.wantBodyContaining != "" && !strings.Contains(body, request.wantBodyContaining) { t.Errorf("body does not contain %q:\n%s", request.wantBodyContaining, body) } + if request.wantBodyNotContaining != "" && strings.Contains(body, request.wantBodyNotContaining) { + t.Errorf("body contains %q:\n%s", request.wantBodyNotContaining, body) + } } else { transform := cmpopts.AcyclicTransformer("jsonrpcid", func(id jsonrpc.ID) any { return id.Raw() }) if diff := cmp.Diff(request.wantMessages, got, transform); diff != "" { @@ -1148,11 +1294,12 @@ type streamableRequest struct { headers http.Header // additional headers to set, overlaid on top of the default headers messages []jsonrpc.Message // messages to send - closeAfter int // if nonzero, close after receiving this many messages - wantStatusCode int // expected status code - wantBodyContaining string // if set, expect the response body to contain this text; overrides wantMessages - wantMessages []jsonrpc.Message // expected messages to receive; ignored if wantBodyContaining is set - wantSessionID bool // whether or not a session ID is expected in the response + closeAfter int // if nonzero, close after receiving this many messages + wantStatusCode int // expected status code + wantBodyContaining string // if set, expect the response body to contain this text; overrides wantMessages + wantBodyNotContaining string // if set, a negative assertion on the body; overrides wantMessages + wantMessages []jsonrpc.Message // expected messages to receive; ignored if wantBodyContaining is set + wantSessionID bool // whether or not a session ID is expected in the response } // streamingRequest makes a request to the given streamable server with the @@ -1221,13 +1368,15 @@ func (s streamableRequest) do(ctx context.Context, serverURL, sessionID string, if err != nil { return newSessionID, resp.StatusCode, nil, fmt.Errorf("reading events: %v", err) } - // TODO(rfindley): do we need to check evt.name? - // Does the MCP spec say anything about this? - msg, err := jsonrpc2.DecodeMessage(evt.Data) - if err != nil { - return newSessionID, resp.StatusCode, nil, fmt.Errorf("decoding message: %w", err) + if evt.Name == "" || evt.Name == "message" { // ordinary message + // TODO(rfindley): do we need to check evt.name? + // Does the MCP spec say anything about this? + msg, err := jsonrpc2.DecodeMessage(evt.Data) + if err != nil { + return newSessionID, resp.StatusCode, nil, fmt.Errorf("decoding message: %w", err) + } + out <- msg } - out <- msg } respBody = r.w.Bytes() } else if strings.HasPrefix(contentType, "application/json") { @@ -1882,12 +2031,13 @@ data: {"jsonrpc":"2.0","method":"test2","params":{}} // Verify that we can decode the message events but would fail on ping events for i, evt := range events { - if evt.Name == "message" { + switch evt.Name { + case "message": _, err := jsonrpc.DecodeMessage(evt.Data) if err != nil { t.Errorf("event %d: failed to decode message event: %v", i, err) } - } else if evt.Name == "ping" { + case "ping": // Ping events have non-JSON data and should fail decoding _, err := jsonrpc.DecodeMessage(evt.Data) if err == nil { From 5928940d5f31456dc48b110e970928b4794fca00 Mon Sep 17 00:00:00 2001 From: Markus Kusano Date: Wed, 3 Dec 2025 10:12:07 -0500 Subject: [PATCH 130/418] mcp/mcp_test: tests for elicit content validation (#676) This adds a simple schema and checks that the content returned by an elicitation handler is validated against the schema. Previously, we had no test coverage checking if the content was validated (i.e., we would see no test failures if we removed content validation against the schema). The bulk of the actual validation logic is handled by the jsonschema library so adding comprehensive tests covering different schema-features is redundant, we simply need to see that some validation is happening. See https://github.com/modelcontextprotocol/go-sdk/issues/625 for more details on the motivation of this change. Fixes #625 --------- Co-authored-by: Markus Kusano --- mcp/mcp_test.go | 126 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 3edfefd..8c40de0 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -997,6 +997,11 @@ func TestElicitationUnsupportedMethod(t *testing.T) { } } +func anyPtr[T any](v T) *any { + var a any = v + return &a +} + func TestElicitationSchemaValidation(t *testing.T) { ctx := context.Background() ct, st := NewInMemoryTransports() @@ -1118,6 +1123,37 @@ func TestElicitationSchemaValidation(t *testing.T) { }, }, }, + { + name: "enum with enum schema", + schema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "priority": { + Type: "string", + OneOf: []*jsonschema.Schema{ + { + Const: anyPtr(map[string]string{ + "const": "high", + "title": "High Priority", + }), + }, + { + Const: anyPtr(map[string]string{ + "const": "medium", + "title": "Medium Priority", + }), + }, + { + Const: anyPtr(map[string]string{ + "const": "low", + "title": "Low Priority", + }), + }, + }, + }, + }, + }, + }, } for _, tc := range validSchemas { @@ -1377,6 +1413,96 @@ func TestElicitationSchemaValidation(t *testing.T) { } } +func TestElicitContentValidation(t *testing.T) { + ctx := context.Background() + ct, st := NewInMemoryTransports() + + s := NewServer(testImpl, nil) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + // Set up a client that exercises valid/invalid elicitation: the returned + // Content from the handler ("potato") is validated against the schemas + // defined in the testcases below. + c := NewClient(testImpl, &ClientOptions{ + ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept", Content: map[string]any{"test": "potato"}}, nil + }, + }) + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + testcases := []struct { + name string + schema *jsonschema.Schema + expectedError string + }{ + { + name: "string enum with schema not matching content", + schema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "test": { + Type: "string", + OneOf: []*jsonschema.Schema{ + { + Const: anyPtr(map[string]string{ + "const": "high", + "title": "High Priority", + }), + }, + }, + }, + }, + }, + expectedError: "oneOf: did not validate against any of", + }, + { + name: "string enum with schema matching content", + schema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "test": { + Type: "string", + OneOf: []*jsonschema.Schema{ + { + Const: anyPtr(map[string]string{ + "const": "potato", + "title": "Potato Priority", + }), + }, + }, + }, + }, + }, + expectedError: "", + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + _, err := ss.Elicit(ctx, &ElicitParams{ + Message: "Test schema: " + tc.name, + RequestedSchema: tc.schema, + }) + if tc.expectedError != "" { + if err == nil { + t.Errorf("expected error but got no error: %s", tc.expectedError) + return + } + if !strings.Contains(err.Error(), tc.expectedError) { + t.Errorf("error message %q does not contain expected text %q", err.Error(), tc.expectedError) + } + } + }) + } +} + func TestElicitationProgressToken(t *testing.T) { ctx := context.Background() ct, st := NewInMemoryTransports() From 6a87ca8d62a45cdbf20303f95c73bce25809a2ce Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Wed, 3 Dec 2025 10:55:24 -0500 Subject: [PATCH 131/418] CONTRIBUTING.md: defer to the blanket antitrust policy (#661) Defer to the model context protocol's blanket antitrust policy, rather than maintain an ad-hoc antitrust policy. https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/ANTITRUST.md Fixes #651 --- CONTRIBUTING.md | 137 +++--------------------------------------------- 1 file changed, 6 insertions(+), 131 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 001c35d..e4da23d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -159,134 +159,9 @@ GitHub discussions, or public steering meetings. ### Antitrust considerations -It is important that the SDK avoids bias toward specific integration paths or -providers. The antitrust policy below details precise terms that should be -adhered to in order to ensure that the evolution and governance of the SDK remain -procompetitive. - -### Antitrust policy - -Note: all changes to the policy in this section must be approved by legal -counsel. - -The Go+Anthropic MCP SDK Working Group (the “Working Group”) is established to -guide and review technical contributions to an open-source Go software -development kit (“SDK”) for the Model Context Protocol (“MCP”). The Working Group -and its members are committed to operating for procompetitive purposes that -accelerate AI development and benefit businesses and consumers. This -collaboration is focused on technical and infrastructure objectives – namely, -developing and maintaining a neutral, open-source, and MIT-licensed library. -Google and Anthropic, as well as other stakeholders, participate with the -understanding that the Working Group’s sole purpose is to improve interoperability -and innovation in the MCP ecosystem. - -Antitrust law recognizes that when competitors collaborate for valid reasons -(e.g., joint R&D or standard-setting), such collaborations can be -procompetitive. This Antitrust Compliance Policy (the “Policy”) therefore -outlines guidelines and safeguards to ensure the collaboration remains focused -on its technical mission. - -The Policy applies to all Working Group activities and communications, -including official meetings, subcommittee discussions, emails, shared -documents, and any other interactions under the Working Group’s auspices (e.g., -group chats, version control systems). It applies to all participants from -Google, Anthropic, and any other member organizations or independent -contributors involved in the Working Group. Each participating entity should -ensure its representatives understand and uphold these rules. By participating, -members agree to follow the Policy in both letter and spirit. - -#### Governance Procedures and Principles - -- **Participant Guidelines.** Participants should generally be limited to - individuals in technical roles who are directly involved in the MCP SDK - project. These participants should not be key decision-makers in their - company’s AI commercial strategy, sales, marketing, pricing, or other - competitively or strategically sensitive business planning. -- **Agenda Preparation.** A written agenda should be circulated before each - Working Group meeting. Agenda items should focus on the SDK’s technical - development, maintenance, or documentation. Where appropriate, counsel should - review the agenda prior to circulation to ensure compliance with the Policy. -- **Policy Reminder at Start.** Meetings should begin with a brief antitrust - compliance reminder with reference to the Policy. -- **Minutes.** Meetings will be minuted by a designated participant, and - neutrally record attendees, roles, topics discussed, action items, and - outcomes. Draft minutes will be circulated to all participants. -- **Documentation and Transparency.** Working Group outputs are intended - for public release. Significant design decisions and discussion outcomes - should be documented publicly. If a topic cannot be safely disclosed - publicly, it likely does not belong in this forum. -- **Independence of Decision-Making.** All participants and their companies - retain complete independence in their own business decisions and competitive - strategies outside of the MCP SDK project. Nothing in this collaboration - restricts or influences how each company operates its commercial business. - -### Information Exchange Guidelines - -**Appropriate Topics.** Working Group members are anticipated to remain within the -project’s technical scope. In general, discussions should focus on improving -the Go SDK for MCP in a transparent, collaborative, and non-exclusive manner. -The following topics are appropriate and expected: - -- **Software Design and Architecture:** Implementation of MCP features in Go, - API design, code structure, testing frameworks, performance considerations, - compatibility issues, and security concerns. -- **Technical Contributions and Bug Fixes:** Review of code contributions, - debugging problems, and handling feature requests. -- **Documentation and Open-Source Logistics:** Discussions of project - documentation, changelogs, versioning strategy, and managing contributions. -- **Standards and Interoperability:** Ensuring SDK compliance with MCP or other - open technical standards. Any standardization effort should be open, - voluntary, and tailored to promote interoperability. -- **Public Information:** Any public information relevant to the Working - Group’s technical work (e.g., published research, open-source code from - outside projects, publicly documented API specs). - -**Inappropriate Topics.** To ensure compliance with antitrust law and maintain -the open character of the collaboration, the following subjects should not be -discussed in Working Group meetings, side conversations, or related communications: - -- **Pricing and Commercial Terms:** Do not discuss prices, pricing strategy, - discounting, or future pricing plans for Claude, Gemini, or any other AI - product or service provided by Working Group members. -- **Sales or Output:** Avoid sharing sales volumes, revenue, customer counts, - market shares, production plans, or any business performance metrics. -- **Product Roadmaps (beyond SDK):** Do not disclose internal plans for AI - model development, feature rollouts, or future commercialization strategies. -- **Customers or Markets:** No discussions of which customers, industries, or - geographies the parties will pursue or avoid. -- **Customer or Supplier Details:** Do not share specifics of contracts, - negotiations, or relationships with commercial partners. -- **Non-SDK Proprietary Tech:** Keep discussions focused on the open SDK. - Sharing of information should be limited to what is needed to achieve the - Working Group’s goals. Each party’s internal model architectures, fine-tuning - approaches, or training methods unrelated to the project should not be - disclosed. -- **HR or Labor Matters:** No discussions about wages, hiring plans, or - policies toward employees. - -#### Enforcement and Support - -- **Shared Responsibility.** All Working Group participants share responsibility - for upholding the Policy. While legal counsel can provide support, day-to-day - compliance is a function of culture and practice. -- **Designated Legal Contacts.** Each participating entity should designate a - legal point of contact responsible for reviewing meeting materials (e.g., - agendas, minutes) and fielding questions about compliance. These contacts - should be included in the Working Group distribution list for all official - materials and should be consulted in advance of any meetings where sensitive - topics may arise. -- **Final Note.** The Policy is not meant to chill legitimate technical - collaboration. It is meant to ensure that the Working Group can focus on its - mission without creating unnecessary legal risk or attracting regulator - scrutiny. Participants who follow the Policy and avoid Inappropriate Topics - will remain squarely within the procompetitive zone. - -### Ongoing evaluation - -On an ongoing basis, the administrators of the SDK will evaluate whether it is -keeping pace with changes to the MCP spec and meeting its goals of openness and -transparency. If it is not meeting these goals, either because it exceeds the -bandwidth of its current Working Group, or because the processes here are -inadequate, these processes will be re-evaluated by the Working Group. At this -time, the Working Group set may be expanded to include additional community -members, based on their history of strong contribution. +The goal of this repository is to provide a robust and complete Go +implementation of the model context protocol, in an open and transparent +manner, without bias toward specific integration paths or providers. To that +end, the model context protocol organization's [antitrust +policy](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/ANTITRUST.md) +applies to all participation in this project. From 8adc1e3a3d77766b43af77f231430ac8fa405006 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Wed, 3 Dec 2025 11:07:05 -0500 Subject: [PATCH 132/418] CONTRIBUTING.md: add a dependency update policy, and nightly vulncheck (#678) Add a section the our CONTRIBUTING.md describing when and how dependencies should be updated, and the criteria for adding a new dependency. Also, add a nightly workflow to run vulncheck. Fixes #635 --- .github/workflows/nightly.yml | 17 +++++++++++++++++ CONTRIBUTING.md | 24 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 .github/workflows/nightly.yml diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..748ac91 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,17 @@ +name: Nightly + +on: + schedule: + - cron: '0 2 * * *' # 2 AM UTC daily + + workflow_dispatch: + +jobs: + vulncheck: + runs-on: ubuntu-latest + steps: + - name: Run govulncheck + uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4 + with: + go-version-file: go.mod + go-package: ./... diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e4da23d..407b3bf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,6 +105,30 @@ copyright header following the format below: // license that can be found in the LICENSE file. ``` +### Adding and updating dependencies + +In general, the SDK tries to use as few dependencies as possible. Each new +dependency is a potential source for bugs, churn, and conflicts for our users. +Therefore, we require a [proposal](#proposals) for any new module dependency, +including upgrading an existing module to a new major version. New dependencies +should be evaluated for their stability and security, and should be +well-established in the Go ecosystem. + +In general, dependencies should be for internal use by the SDK implementation, +or for testing. Do not include types from dependencies in the SDK API. + +On the other hand, updating existing dependencies can be done at any time +without a proposal, as long as their major version does not change. Prefer to +update dependencies immediately following a release of the SDK, to allow as +much time as possible to find issues with the new version. + +After any change to dependencies, run govulncheck to check them for +vulnerabilities. + +``` +go run golang.org/x/vuln/cmd/govulncheck@latest +``` + ### Updating the README The top-level `README.md` file is generated from `internal/readme/README.src.md` From 272e0cd4f572c3f790139169e3058c4c4c56fec1 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Wed, 3 Dec 2025 12:26:50 -0500 Subject: [PATCH 133/418] mcp: better handling for streamable context cancellation (#677) After walking through our handling of streamable client context cancellation (due to encountering a shutdown deadlock), I think I've settled on a more coherent strategy for handling call cancellation: - In our call handler, retire the request if the call exits due to cancellation: the caller will never see the actual result anyway. - In connectSSE, use the actual request context (the same context used in Write) for the client request, so that it terminates when the context is cancelled. Thread through the initialization context for the standalone SSE request. Also, a couple minor improvements: - Use a detached context for the background context of the client connection. We want to preserve context values (see #513), but it is not right to cancel the connection after Connect has already returned, if the context times out. - Don't use Last-Event-ID != "" as the signal for whether the connectSSE call is initial: if the standalone SSE stream disconnects without an event ID, we'll still reconnect it, and don't want to do so without a delay. + tests, updating the streamable client connection test harness to accomodate the new aspects being exercised. Fixes #662 --- internal/jsonrpc2/conn.go | 31 ++++-- mcp/streamable.go | 100 ++++++++++++----- mcp/streamable_client_test.go | 201 +++++++++++++++++++++++++++++----- mcp/streamable_test.go | 6 +- mcp/transport.go | 14 ++- 5 files changed, 284 insertions(+), 68 deletions(-) diff --git a/internal/jsonrpc2/conn.go b/internal/jsonrpc2/conn.go index 5549ee1..f4ac86f 100644 --- a/internal/jsonrpc2/conn.go +++ b/internal/jsonrpc2/conn.go @@ -361,19 +361,26 @@ func (c *Connection) Call(ctx context.Context, method string, params any) *Async if err := c.write(ctx, call); err != nil { // Sending failed. We will never get a response, so deliver a fake one if it // wasn't already retired by the connection breaking. - c.updateInFlight(func(s *inFlightState) { - if s.outgoingCalls[ac.id] == ac { - delete(s.outgoingCalls, ac.id) - ac.retire(&Response{ID: id, Error: err}) - } else { - // ac was already retired by the readIncoming goroutine: - // perhaps our write raced with the Read side of the connection breaking. - } - }) + c.Retire(ac, err) } return ac } +// Retire stops tracking the call, and reports err as its terminal error. +// +// Retire is safe to call multiple times: if the call is already no longer +// tracked, Retire is a no op. +func (c *Connection) Retire(ac *AsyncCall, err error) { + c.updateInFlight(func(s *inFlightState) { + if s.outgoingCalls[ac.id] == ac { + delete(s.outgoingCalls, ac.id) + ac.retire(&Response{ID: ac.id, Error: err}) + } else { + // ac was already retired elsewhere. + } + }) +} + // Async, signals that the current jsonrpc2 request may be handled // asynchronously to subsequent requests, when ctx is the request context. // @@ -437,6 +444,9 @@ func (ac *AsyncCall) IsReady() bool { } // retire processes the response to the call. +// +// It is an error to call retire more than once: retire is guarded by the +// connection's outgoingCalls map. func (ac *AsyncCall) retire(response *Response) { select { case <-ac.ready: @@ -450,6 +460,9 @@ func (ac *AsyncCall) retire(response *Response) { // Await waits for (and decodes) the results of a Call. // The response will be unmarshaled from JSON into the result. +// +// If the call is cancelled due to context cancellation, the result is +// ctx.Err(). func (ac *AsyncCall) Await(ctx context.Context, result any) error { select { case <-ctx.Done(): diff --git a/mcp/streamable.go b/mcp/streamable.go index 21c7384..9e210ab 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -25,6 +25,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/auth" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + "github.com/modelcontextprotocol/go-sdk/internal/xcontext" "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) @@ -1336,12 +1337,17 @@ const ( // A value of 1.0 results in a constant delay, while a value of 2.0 would double it each time. // It must be 1.0 or greater if MaxRetries is greater than 0. reconnectGrowFactor = 1.5 - // reconnectInitialDelay is the base delay for the first reconnect attempt. - reconnectInitialDelay = 1 * time.Second // reconnectMaxDelay caps the backoff delay, preventing it from growing indefinitely. reconnectMaxDelay = 30 * time.Second ) +var ( + // reconnectInitialDelay is the base delay for the first reconnect attempt. + // + // Mutable for testing. + reconnectInitialDelay = 1 * time.Second +) + // Connect implements the [Transport] interface. // // The resulting [Connection] writes messages via POST requests to the @@ -1364,7 +1370,20 @@ func (t *StreamableClientTransport) Connect(ctx context.Context) (Connection, er // Create a new cancellable context that will manage the connection's lifecycle. // This is crucial for cleanly shutting down the background SSE listener by // cancelling its blocking network operations, which prevents hangs on exit. - connCtx, cancel := context.WithCancel(ctx) + // + // This context should be detached from the incoming context: the standalone + // SSE request should not break when the connection context is done. + // + // For example, consider that the user may want to wait at most 5s to connect + // to the server, and therefore uses a context with a 5s timeout when calling + // client.Connect. Let's suppose that Connect returns after 1s, and the user + // starts using the resulting session. If we didn't detach here, the session + // would break after 4s, when the background SSE stream is terminated. + // + // Instead, creating a cancellable context detached from the incoming context + // allows us to preserve context values (which may be necessary for auth + // middleware), yet only cancel the standalone stream when the connection is closed. + connCtx, cancel := context.WithCancel(xcontext.Detach(ctx)) conn := &streamableClientConn{ url: t.Endpoint, client: client, @@ -1383,8 +1402,8 @@ func (t *StreamableClientTransport) Connect(ctx context.Context) (Connection, er type streamableClientConn struct { url string client *http.Client - ctx context.Context - cancel context.CancelFunc + ctx context.Context // connection context, detached from Connect + cancel context.CancelFunc // cancels ctx incoming chan jsonrpc.Message maxRetries int strict bool // from [StreamableClientTransport.strict] @@ -1447,9 +1466,13 @@ func (c *streamableClientConn) sessionUpdated(state clientSessionState) { } func (c *streamableClientConn) connectStandaloneSSE() { - resp, err := c.connectSSE("", 0) + resp, err := c.connectSSE(c.ctx, "", 0, true) if err != nil { - c.fail(fmt.Errorf("standalone SSE request failed (session ID: %v): %v", c.sessionID, err)) + // If the client didn't cancel the request, and failure breaks the logical + // session. + if c.ctx.Err() == nil { + c.fail(fmt.Errorf("standalone SSE request failed (session ID: %v): %v", c.sessionID, err)) + } return } @@ -1481,7 +1504,7 @@ func (c *streamableClientConn) connectStandaloneSSE() { c.fail(err) return } - go c.handleSSE(summary, resp, true, nil) + go c.handleSSE(c.ctx, summary, resp, true, nil) } // fail handles an asynchronous error while reading. @@ -1616,7 +1639,7 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e forCall = jsonReq } // TODO: should we cancel this logical SSE request if/when jsonReq is canceled? - go c.handleSSE(requestSummary, resp, false, forCall) + go c.handleSSE(ctx, requestSummary, resp, false, forCall) default: resp.Body.Close() @@ -1668,15 +1691,17 @@ func (c *streamableClientConn) handleJSON(requestSummary string, resp *http.Resp // // If forCall is set, it is the call that initiated the stream, and the // stream is complete when we receive its response. -func (c *streamableClientConn) handleSSE(requestSummary string, resp *http.Response, persistent bool, forCall *jsonrpc2.Request) { +func (c *streamableClientConn) handleSSE(ctx context.Context, requestSummary string, resp *http.Response, persistent bool, forCall *jsonrpc2.Request) { for { // Connection was successful. Continue the loop with the new response. - // TODO: we should set a reasonable limit on the number of times we'll try - // getting a response for a given request. + // + // TODO(#679): we should set a reasonable limit on the number of times + // we'll try getting a response for a given request, or enforce that we + // actually make progress. // // Eventually, if we don't get the response, we should stop trying and // fail the request. - lastEventID, reconnectDelay, clientClosed := c.processStream(requestSummary, resp, forCall) + lastEventID, reconnectDelay, clientClosed := c.processStream(ctx, requestSummary, resp, forCall) // If the connection was closed by the client, we're done. if clientClosed { @@ -1689,12 +1714,17 @@ func (c *streamableClientConn) handleSSE(requestSummary string, resp *http.Respo } // The stream was interrupted or ended by the server. Attempt to reconnect. - newResp, err := c.connectSSE(lastEventID, reconnectDelay) + newResp, err := c.connectSSE(ctx, lastEventID, reconnectDelay, false) if err != nil { - // All reconnection attempts failed: fail the connection. - c.fail(fmt.Errorf("%s: failed to reconnect (session ID: %v): %v", requestSummary, c.sessionID, err)) + // If the client didn't cancel this request, any failure to execute it + // breaks the logical MCP session. + if ctx.Err() == nil { + // All reconnection attempts failed: fail the connection. + c.fail(fmt.Errorf("%s: failed to reconnect (session ID: %v): %v", requestSummary, c.sessionID, err)) + } return } + resp = newResp if err := c.checkResponse(requestSummary, resp); err != nil { c.fail(err) @@ -1731,11 +1761,13 @@ func (c *streamableClientConn) checkResponse(requestSummary string, resp *http.R // incoming channel. It returns the ID of the last processed event and a flag // indicating if the connection was closed by the client. If resp is nil, it // returns "", false. -func (c *streamableClientConn) processStream(requestSummary string, resp *http.Response, forCall *jsonrpc.Request) (lastEventID string, reconnectDelay time.Duration, clientClosed bool) { +func (c *streamableClientConn) processStream(ctx context.Context, requestSummary string, resp *http.Response, forCall *jsonrpc.Request) (lastEventID string, reconnectDelay time.Duration, clientClosed bool) { defer resp.Body.Close() for evt, err := range scanEvents(resp.Body) { if err != nil { - // TODO: we should differentiate EOF from other errors here. + if ctx.Err() != nil { + return "", 0, true // don't reconnect: client cancelled + } break } @@ -1768,6 +1800,7 @@ func (c *streamableClientConn) processStream(requestSummary string, resp *http.R return "", 0, true } } + case <-c.done: // The connection was closed by the client; exit gracefully. return "", 0, true @@ -1777,6 +1810,9 @@ func (c *streamableClientConn) processStream(requestSummary string, resp *http.R // // If the lastEventID is "", the stream is not retryable and we should // report a synthetic error for the call. + // + // Note that this is different from the cancellation case above, since the + // caller is still waiting for a response that will never come. if lastEventID == "" && forCall != nil { errmsg := &jsonrpc2.Response{ ID: forCall.ID, @@ -1800,12 +1836,20 @@ func (c *streamableClientConn) processStream(requestSummary string, resp *http.R // // reconnectDelay is the delay set by the server using the SSE retry field, or // 0. -func (c *streamableClientConn) connectSSE(lastEventID string, reconnectDelay time.Duration) (*http.Response, error) { +// +// If initial is set, this is the initial attempt. +// +// If connectSSE exits due to context cancellation, the result is (nil, ctx.Err()). +func (c *streamableClientConn) connectSSE(ctx context.Context, lastEventID string, reconnectDelay time.Duration, initial bool) (*http.Response, error) { var finalErr error - // If lastEventID is set, we've already connected successfully once, so - // consider that to be the first attempt. attempt := 0 - if lastEventID != "" { + if !initial { + // We've already connected successfully once, so delay subsequent + // reconnections. Otherwise, if the server returns 200 but terminates the + // connection, we'll reconnect as fast as we can, ad infinitum. + // + // TODO: we should consider also setting a limit on total attempts for one + // logical request. attempt = 1 } delay := calculateReconnectDelay(attempt) @@ -1816,16 +1860,14 @@ func (c *streamableClientConn) connectSSE(lastEventID string, reconnectDelay tim select { case <-c.done: return nil, fmt.Errorf("connection closed by client during reconnect") - case <-c.ctx.Done(): + + case <-ctx.Done(): // If the connection context is canceled, the request below will not // succeed anyway. - // - // TODO(#662): we should not be using the connection context for - // reconnection: we should instead be using the call context (from - // Write). - return nil, fmt.Errorf("connection context closed") + return nil, ctx.Err() + case <-time.After(delay): - req, err := http.NewRequestWithContext(c.ctx, http.MethodGet, c.url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url, nil) if err != nil { return nil, err } diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 6d3d83b..dcdda32 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -13,6 +13,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/google/go-cmp/cmp" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" @@ -23,16 +24,18 @@ type streamableRequestKey struct { httpMethod string // http method sessionID string // session ID header jsonrpcMethod string // jsonrpc method, or "" for non-requests + lastEventID string // Last-Event-ID header } type header map[string]string type streamableResponse struct { - header header // response headers - status int // or http.StatusOK - body string // or "" - optional bool // if set, request need not be sent - wantProtocolVersion string // if "", unchecked + header header // response headers + status int // or http.StatusOK + body string // or "" + optional bool // if set, request need not be sent + wantProtocolVersion string // if "", unchecked + done chan struct{} // if set, receive from this channel before terminating the request } type fakeResponses map[streamableRequestKey]*streamableResponse @@ -60,8 +63,9 @@ func (s *fakeStreamableServer) missingRequests() []streamableRequestKey { func (s *fakeStreamableServer) ServeHTTP(w http.ResponseWriter, req *http.Request) { key := streamableRequestKey{ - httpMethod: req.Method, - sessionID: req.Header.Get(sessionIDHeader), + httpMethod: req.Method, + sessionID: req.Header.Get(sessionIDHeader), + lastEventID: req.Header.Get("Last-Event-ID"), // TODO: extract this to a constant, like sessionIDHeader } if req.Method == http.MethodPost { body, err := io.ReadAll(req.Body) @@ -102,11 +106,17 @@ func (s *fakeStreamableServer) ServeHTTP(w http.ResponseWriter, req *http.Reques status = http.StatusOK } w.WriteHeader(status) + w.(http.Flusher).Flush() // flush response headers if v := req.Header.Get(protocolVersionHeader); v != resp.wantProtocolVersion && resp.wantProtocolVersion != "" { s.t.Errorf("%v: bad protocol version header: got %q, want %q", key, v, resp.wantProtocolVersion) } w.Write([]byte(resp.body)) + w.(http.Flusher).Flush() // flush response + + if resp.done != nil { + <-resp.done + } } var ( @@ -140,24 +150,24 @@ func TestStreamableClientTransportLifecycle(t *testing.T) { fake := &fakeStreamableServer{ t: t, responses: fakeResponses{ - {"POST", "", methodInitialize}: { + {"POST", "", methodInitialize, ""}: { header: header{ "Content-Type": "application/json", sessionIDHeader: "123", }, body: jsonBody(t, initResp), }, - {"POST", "123", notificationInitialized}: { + {"POST", "123", notificationInitialized, ""}: { status: http.StatusAccepted, wantProtocolVersion: latestProtocolVersion, }, - {"GET", "123", ""}: { + {"GET", "123", "", ""}: { header: header{ "Content-Type": "text/event-stream", }, wantProtocolVersion: latestProtocolVersion, }, - {"DELETE", "123", ""}: {}, + {"DELETE", "123", "", ""}: {}, }, } @@ -191,21 +201,21 @@ func TestStreamableClientRedundantDelete(t *testing.T) { fake := &fakeStreamableServer{ t: t, responses: fakeResponses{ - {"POST", "", methodInitialize}: { + {"POST", "", methodInitialize, ""}: { header: header{ "Content-Type": "application/json", sessionIDHeader: "123", }, body: jsonBody(t, initResp), }, - {"POST", "123", notificationInitialized}: { + {"POST", "123", notificationInitialized, ""}: { status: http.StatusAccepted, wantProtocolVersion: latestProtocolVersion, }, - {"GET", "123", ""}: { + {"GET", "123", "", ""}: { status: http.StatusMethodNotAllowed, }, - {"POST", "123", methodListTools}: { + {"POST", "123", methodListTools, ""}: { status: http.StatusNotFound, }, }, @@ -251,25 +261,25 @@ func TestStreamableClientGETHandling(t *testing.T) { fake := &fakeStreamableServer{ t: t, responses: fakeResponses{ - {"POST", "", methodInitialize}: { + {"POST", "", methodInitialize, ""}: { header: header{ "Content-Type": "application/json; charset=utf-8", // should ignore the charset sessionIDHeader: "123", }, body: jsonBody(t, initResp), }, - {"POST", "123", notificationInitialized}: { + {"POST", "123", notificationInitialized, ""}: { status: http.StatusAccepted, wantProtocolVersion: latestProtocolVersion, }, - {"GET", "123", ""}: { + {"GET", "123", "", ""}: { header: header{ "Content-Type": "text/event-stream", }, status: test.status, wantProtocolVersion: latestProtocolVersion, }, - {"DELETE", "123", ""}: {optional: true}, + {"DELETE", "123", "", ""}: {optional: true}, }, } httpServer := httptest.NewServer(fake) @@ -320,25 +330,25 @@ func TestStreamableClientStrictness(t *testing.T) { fake := &fakeStreamableServer{ t: t, responses: fakeResponses{ - {"POST", "", methodInitialize}: { + {"POST", "", methodInitialize, ""}: { header: header{ "Content-Type": "application/json", sessionIDHeader: "123", }, body: jsonBody(t, initResp), }, - {"POST", "123", notificationInitialized}: { + {"POST", "123", notificationInitialized, ""}: { status: test.initializedStatus, wantProtocolVersion: latestProtocolVersion, }, - {"GET", "123", ""}: { + {"GET", "123", "", ""}: { header: header{ "Content-Type": "text/event-stream", }, status: test.getStatus, wantProtocolVersion: latestProtocolVersion, }, - {"POST", "123", methodListTools}: { + {"POST", "123", methodListTools, ""}: { header: header{ "Content-Type": "application/json", sessionIDHeader: "123", @@ -346,7 +356,7 @@ func TestStreamableClientStrictness(t *testing.T) { body: jsonBody(t, resp(2, &ListToolsResult{Tools: []*Tool{}}, nil)), optional: true, }, - {"DELETE", "123", ""}: {optional: true}, + {"DELETE", "123", "", ""}: {optional: true}, }, } httpServer := httptest.NewServer(fake) @@ -379,14 +389,14 @@ func TestStreamableClientUnresumableRequest(t *testing.T) { fake := &fakeStreamableServer{ t: t, responses: fakeResponses{ - {"POST", "", methodInitialize}: { + {"POST", "", methodInitialize, ""}: { header: header{ "Content-Type": "text/event-stream", sessionIDHeader: "123", }, body: "", }, - {"DELETE", "123", ""}: {optional: true}, + {"DELETE", "123", "", ""}: {optional: true}, }, } httpServer := httptest.NewServer(fake) @@ -406,3 +416,142 @@ func TestStreamableClientUnresumableRequest(t *testing.T) { t.Errorf("Connect: got error %v, want containing %q", err, msg) } } + +func TestStreamableClientResumption_Cancelled(t *testing.T) { + // This test verifies that the resumed requests are closed when their context + // is cancelled (issue #662). + + // This test (unfortunately) relies on timing, so may have false positives. + // + // Set the reconnect initial delay to some small(ish) value so that the test + // doesn't take too long. But this value must be large enough that we mostly + // avoid races in the tests below, where one test cases is intended to be in + // between the initial attempt and first reconnection. + // + // For easier tuning (and debugging), factor out the tick size. + // + // TODO(#680): experiment with instead using synctest. + const tick = 10 * time.Millisecond + defer func(delay time.Duration) { + reconnectInitialDelay = delay + }(reconnectInitialDelay) + reconnectInitialDelay = 2 * tick + + // The setup: terminate a request stream and make the resumed request hang + // indefinitely. CallTool should still exit when its context is canceled. + // + // This should work whether we're handling the initial request, waiting to + // retry, or handling the retry. + // + // Furthermore, closing the client connection should not hang, because there + // should be no ongoing requests. + + tests := []struct { + label string + cancelAfter time.Duration + }{ + {"in process", 1 * tick}, // cancel while the request is being handled + // initial request terminates at 2 ticks (see below) + {"awaiting retry", 3 * tick}, // cancel in-between first and second attempt + // retry starts at 4 ticks (=2+2) + {"in retry", 5 * tick}, // cancel while second attempt is hanging + } + + for _, test := range tests { + t.Run(test.label, func(t *testing.T) { + ctx := context.Background() + + // done will be closed when the test exits: used to simulate requests that + // hang indefinitely. + initialRequestDone := make(chan struct{}) // closed below + allDone := make(chan struct{}) + + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodInitialize, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "123", + }, + body: jsonBody(t, initResp), + }, + {"POST", "123", notificationInitialized, ""}: { + status: http.StatusAccepted, + wantProtocolVersion: latestProtocolVersion, + }, + {"GET", "123", "", ""}: { + header: header{ + "Content-Type": "text/event-stream", + }, + status: http.StatusMethodNotAllowed, // don't allow the standalone stream + }, + {"POST", "123", methodCallTool, ""}: { + header: header{ + "Content-Type": "text/event-stream", + }, + status: http.StatusOK, + body: `id: 1 +data: { "jsonrpc": "2.0", "method": "notifications/message", "params": { "level": "error", "data": "bad" } } + +`, + done: initialRequestDone, + }, + {"POST", "123", methodListTools, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "123", + }, + body: jsonBody(t, resp(3, &ListToolsResult{Tools: []*Tool{}}, nil)), + }, + {"GET", "123", "", "1"}: { + header: header{ + "Content-Type": "text/event-stream", + }, + status: http.StatusOK, + done: allDone, // hang indefinitely + }, + {"POST", "123", notificationCancelled, ""}: {status: http.StatusAccepted}, + {"DELETE", "123", "", ""}: {optional: true}, + }, + } + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + defer close(allDone) // must be deferred *after* httpServer.Close, to avoid deadlock + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, nil) + cs, err := client.Connect(ctx, transport, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() // ensure the session is closed, though we're also closing below + + // start the timer on the initial request + go func() { + <-time.After(2 * tick) + close(initialRequestDone) + }() + + // start the timer on the call cancellation + timeoutCtx, cancel := context.WithTimeout(ctx, test.cancelAfter) + defer cancel() + + go func() { + <-timeoutCtx.Done() + }() + + if _, err := cs.CallTool(timeoutCtx, &CallToolParams{ + Name: "tool", + }); err == nil { + t.Errorf("CallTool succeeded unexpectedly") + } + + // ...but cancellation should not break the session. + // Check that an arbitrary request succeeds. + if _, err := cs.ListTools(ctx, nil); err != nil { + t.Errorf("ListTools failed after cancellation") + } + }) + } +} diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 727a283..d2234b8 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1788,8 +1788,8 @@ func TestStreamableClientContextPropagation(t *testing.T) { cancel() select { case <-streamableConn.ctx.Done(): - case <-time.After(100 * time.Millisecond): - t.Error("Connection context was not cancelled when parent was cancelled") + t.Errorf("cancelling the connection context after successful connection broke the connection") + default: } } @@ -1945,7 +1945,7 @@ data: keepalive } // Process the stream - go conn.processStream("test", resp, testReq) + go conn.processStream(ctx, "test", resp, testReq) // Collect messages with timeout var messages []jsonrpc.Message diff --git a/mcp/transport.go b/mcp/transport.go index ec57b38..25f1d5d 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -69,7 +69,7 @@ type Connection interface { type clientConnection interface { Connection - // SessionUpdated is called whenever the client session state changes. + // sessionUpdated is called whenever the client session state changes. sessionUpdated(clientSessionState) } @@ -216,6 +216,18 @@ func call(ctx context.Context, conn *jsonrpc2.Connection, method string, params Reason: ctx.Err().Error(), RequestID: call.ID().Raw(), }) + // By default, the jsonrpc2 library waits for graceful shutdown when the + // connection is closed, meaning it expects all outgoing and incoming + // requests to complete. However, for MCP this expectation is unrealistic, + // and can lead to hanging shutdown. For example, if a streamable client is + // killed, the server will not be able to detect this event, except via + // keepalive pings (if they are configured), and so outgoing calls may hang + // indefinitely. + // + // Therefore, we choose to eagerly retire calls, removing them from the + // outgoingCalls map, when the caller context is cancelled: if the caller + // will never receive the response, there's no need to track it. + conn.Retire(call, ctx.Err()) return errors.Join(ctx.Err(), err) case err != nil: return fmt.Errorf("calling %q: %w", method, err) From 4c52f37649c46e5d2d8fab96f781a68eaf13b812 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Fri, 5 Dec 2025 16:00:32 -0500 Subject: [PATCH 134/418] SECURITY.md: add a security policy (#682) Consistent with other SDKs (typescript, python, csharp), add a SECURITY.md file citing Anthropic's overarching security policy. This is not specialized to the Go SDK, but seems better than not having a policy. --- SECURITY.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..6545156 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +# Security Policy + +Thank you for helping us keep the SDKs and systems they interact with secure. + +## Reporting Security Issues + +This SDK is maintained by [Anthropic](https://www.anthropic.com/) as part of the Model Context Protocol project. + +The security of our systems and user data is Anthropic’s top priority. We appreciate the work of security researchers acting in good faith in identifying and reporting potential vulnerabilities. + +Our security program is managed on HackerOne and we ask that any validated vulnerability in this functionality be reported through their [submission form](https://hackerone.com/anthropic-vdp/reports/new?type=team&report_type=vulnerability). + +## Vulnerability Disclosure Program + +Our Vulnerability Program Guidelines are defined on our [HackerOne program page](https://hackerone.com/anthropic-vdp). From 3b42db997cec0638c83553dcbe19f121ba4451f2 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Mon, 8 Dec 2025 10:32:21 -0500 Subject: [PATCH 135/418] examples: add a server for conformance testing (#681) Add a server example for the new conformance test suite. Passes all current conformance tests. For #650 --- examples/server/conformance/main.go | 775 ++++++++++++++++++++++++++++ 1 file changed, 775 insertions(+) create mode 100644 examples/server/conformance/main.go diff --git a/examples/server/conformance/main.go b/examples/server/conformance/main.go new file mode 100644 index 0000000..ffee291 --- /dev/null +++ b/examples/server/conformance/main.go @@ -0,0 +1,775 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// The conformance server implements features required for MCP conformance testing. +// It mirrors the functionality of the TypeScript conformance server at +// https://github.com/modelcontextprotocol/conformance/blob/main/examples/servers/typescript/everything-server.ts +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "flag" + "fmt" + "log" + "net/http" + "os" + "time" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/yosida95/uritemplate/v3" +) + +var ( + httpAddr = flag.String("http", "", "if set, use streamable HTTP at this address, instead of stdin/stdout") +) + +const watchedResourceURI = "test://watched-resource" + +func main() { + flag.Parse() + + opts := &mcp.ServerOptions{ + CompletionHandler: completionHandler, + SubscribeHandler: subscribeHandler, + UnsubscribeHandler: unsubscribeHandler, + } + + server := mcp.NewServer(&mcp.Implementation{ + Name: "mcp-conformance-test-server", + Version: "1.0.0", + }, opts) + + // Register server features. + registerTools(server) + registerResources(server) + registerPrompts(server) + + // Start the watched resource auto-update goroutine. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go watchedResourceUpdater(ctx, server) + + // Serve over stdio, or streamable HTTP if -http is set. + if *httpAddr != "" { + handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { + return server + }, nil) + log.Printf("Conformance server listening at %s", *httpAddr) + log.Fatal(http.ListenAndServe(*httpAddr, handler)) + } else { + t := &mcp.StdioTransport{} + if err := server.Run(ctx, t); err != nil { + log.Printf("Server failed: %v", err) + os.Exit(1) + } + } +} + +// watchedResourceUpdater sends resource update notifications every 3 seconds. +func watchedResourceUpdater(ctx context.Context, server *mcp.Server) { + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + server.ResourceUpdated(ctx, &mcp.ResourceUpdatedNotificationParams{ + URI: watchedResourceURI, + }) + } + } +} + +// ============================================================================= +// Tools +// ============================================================================= + +func registerTools(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "test_simple_text", + Description: "Tests simple text content response", + }, testSimpleTextHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_image_content", + Description: "Tests image content response", + }, testImageContentHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_audio_content", + Description: "Tests audio content response", + }, testAudioContentHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_embedded_resource", + Description: "Tests embedded resource content response", + }, testEmbeddedResourceHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_multiple_content_types", + Description: "Tests response with multiple content types (text, image, resource)", + }, testMultipleContentTypesHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_tool_with_logging", + Description: "Tests tool that emits log messages during execution", + }, testToolWithLoggingHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_tool_with_progress", + Description: "Tests tool that reports progress notifications", + }, testToolWithProgressHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_error_handling", + Description: "Tests error response handling", + }, testErrorHandlingHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_sampling", + Description: "Tests server-initiated sampling (LLM completion request)", + }, testSamplingHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_elicitation", + Description: "Tests server-initiated elicitation (user input request)", + }, testElicitationHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_elicitation_sep1034_defaults", + Description: "Tests elicitation with default values per SEP-1034", + }, testElicitationDefaultsHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_elicitation_sep1330_enums", + Description: "Tests elicitation with enum schema improvements per SEP-1330", + }, testElicitationEnumsHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "json_schema_2020_12_tool", + Description: "Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613)", + InputSchema: json.RawMessage(`{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "$defs": { + "address": { + "type": "object", + "properties": { + "street": { "type": "string" }, + "city": { "type": "string" } + } + } + }, + "properties": { + "name": { "type": "string" }, + "address": { "$ref": "#/$defs/address" } + }, + "additionalProperties": false + }`), + }, jsonSchema202012Handler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_reconnection", + Description: "Tests SSE stream disconnection and client reconnection (SEP-1699). Server will close the stream mid-call and send the result after client reconnects.", + }, testReconnectionHandler) +} + +// Tool handlers + +func testSimpleTextHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "This is a simple text response for testing."}, + }, + }, nil, nil +} + +func testImageContentHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.ImageContent{ + Data: imageData(), + MIMEType: "image/png", + }, + }, + }, nil, nil +} + +func testAudioContentHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.AudioContent{ + Data: audioData(), + MIMEType: "audio/wav", + }, + }, + }, nil, nil +} + +func testEmbeddedResourceHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.EmbeddedResource{ + Resource: &mcp.ResourceContents{ + URI: "test://embedded-resource", + MIMEType: "text/plain", + Text: "This is an embedded resource", + }, + }, + }, + }, nil, nil +} + +func testMultipleContentTypesHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "This is text content"}, + &mcp.ImageContent{ + Data: imageData(), + MIMEType: "image/png", + }, + &mcp.EmbeddedResource{ + Resource: &mcp.ResourceContents{ + URI: "test://embedded-in-multiple", + MIMEType: "text/plain", + Text: "This is an embedded resource", + }, + }, + }, + }, nil, nil +} + +func testToolWithLoggingHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + // Emit three info-level log messages + req.Session.Log(ctx, &mcp.LoggingMessageParams{ + Level: "info", + Data: "Tool execution started", + }) + time.Sleep(50 * time.Millisecond) + req.Session.Log(ctx, &mcp.LoggingMessageParams{ + Level: "info", + Data: "Tool processing data", + }) + time.Sleep(50 * time.Millisecond) + req.Session.Log(ctx, &mcp.LoggingMessageParams{ + Level: "info", + Data: "Tool execution completed", + }) + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "Tool with logging executed successfully"}, + }, + }, nil, nil +} + +func testToolWithProgressHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + // Get progress token from the request if provided + progressToken := req.Params.GetProgressToken() + + // Send three progress notifications (0%, 50%, 100%) + total := 100.0 + steps := []float64{0, 50, 100} + for _, progress := range steps { + req.Session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{ + ProgressToken: progressToken, + Progress: progress, + Total: total, + Message: fmt.Sprintf("Completed step %.0f of %.0f", progress, total), + }) + time.Sleep(50 * time.Millisecond) + } + + // Return the progress token value as the response (matching TypeScript behavior) + tokenStr := fmt.Sprintf("%v", progressToken) + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: tokenStr}, + }, + }, nil, nil +} + +func testErrorHandlingHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + return nil, nil, errors.New("this tool intentionally returns an error for testing") +} + +type samplingInput struct { + Prompt string `json:"prompt" jsonschema:"The prompt to send to the LLM"` +} + +func testSamplingHandler(ctx context.Context, req *mcp.CallToolRequest, input samplingInput) (*mcp.CallToolResult, any, error) { + // Request LLM completion from the client + result, err := req.Session.CreateMessage(ctx, &mcp.CreateMessageParams{ + Messages: []*mcp.SamplingMessage{ + { + Role: "user", + Content: &mcp.TextContent{ + Text: input.Prompt, + }, + }, + }, + MaxTokens: 100, + }) + if err != nil { + return nil, nil, fmt.Errorf("sampling failed: %w", err) + } + + // Extract the text response from the result + var responseText string + if tc, ok := result.Content.(*mcp.TextContent); ok { + responseText = tc.Text + } else { + responseText = "(non-text response)" + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("LLM response: %s", responseText)}, + }, + }, nil, nil +} + +type elicitationInput struct { + Message string `json:"message" jsonschema:"The message to show the user"` +} + +func testElicitationHandler(ctx context.Context, req *mcp.CallToolRequest, input elicitationInput) (*mcp.CallToolResult, any, error) { + result, err := req.Session.Elicit(ctx, &mcp.ElicitParams{ + Message: input.Message, + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "username": { + Type: "string", + Description: "Your preferred username", + }, + }, + Required: []string{"username"}, + }, + }) + if err != nil { + return nil, nil, fmt.Errorf("elicitation failed: %w", err) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Elicitation result: action=%s, content=%v", result.Action, result.Content)}, + }, + }, nil, nil +} + +func testElicitationDefaultsHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + result, err := req.Session.Elicit(ctx, &mcp.ElicitParams{ + Message: "Test defaults for primitives", + RequestedSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{ + "type": "string", + "description": "User name", + "default": "John Doe", + }, + "age": map[string]any{ + "type": "integer", + "description": "User age", + "default": 30, + }, + "score": map[string]any{ + "type": "number", + "description": "User score", + "default": 95.5, + }, + "status": map[string]any{ + "type": "string", + "description": "User status", + "enum": []string{"active", "inactive", "pending"}, + "default": "active", + }, + "verified": map[string]any{ + "type": "boolean", + "description": "Verification status", + "default": true, + }, + }, + }, + }) + if err != nil { + return nil, nil, fmt.Errorf("elicitation failed: %w", err) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Elicitation result: action=%s, content=%v", result.Action, result.Content)}, + }, + }, nil, nil +} + +func testElicitationEnumsHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + result, err := req.Session.Elicit(ctx, &mcp.ElicitParams{ + Message: "Test enum schemas", + RequestedSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + // Basic enum without titles + "untitledSingle": map[string]any{ + "type": "string", + "enum": []string{"option1", "option2", "option3"}, + }, + // Enum with titles using oneOf + "titledSingle": map[string]any{ + "type": "string", + "oneOf": []map[string]any{ + {"const": "value1", "title": "First Option"}, + {"const": "value2", "title": "Second Option"}, + {"const": "value3", "title": "Third Option"}, + }, + }, + // Legacy enum with enumNames + "legacyEnum": map[string]any{ + "type": "string", + "enum": []string{"opt1", "opt2", "opt3"}, + "enumNames": []string{"Option One", "Option Two", "Option Three"}, + }, + // Multi-select without titles + "untitledMulti": map[string]any{ + "type": "array", + "minItems": 1, + "maxItems": 3, + "items": map[string]any{ + "type": "string", + "enum": []string{"option1", "option2", "option3"}, + }, + }, + // Multi-select with titles using anyOf + "titledMulti": map[string]any{ + "type": "array", + "minItems": 1, + "maxItems": 3, + "items": map[string]any{ + "type": "string", + "anyOf": []map[string]any{ + {"const": "value1", "title": "First Option"}, + {"const": "value2", "title": "Second Option"}, + {"const": "value3", "title": "Third Option"}, + }, + }, + }, + }, + }, + }) + if err != nil { + return nil, nil, fmt.Errorf("elicitation failed: %w", err) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Elicitation result: action=%s, content=%v", result.Action, result.Content)}, + }, + }, nil, nil +} + +type jsonSchemaAddress struct { + Street string `json:"street"` + City string `json:"city"` +} + +type jsonSchemaInput struct { + Name string `json:"name"` + Address *jsonSchemaAddress `json:"address"` +} + +func jsonSchema202012Handler(ctx context.Context, req *mcp.CallToolRequest, input jsonSchemaInput) (*mcp.CallToolResult, any, error) { + // Echo back the arguments received + var addressStr string + if input.Address != nil { + addressStr = fmt.Sprintf("{street: %q, city: %q}", input.Address.Street, input.Address.City) + } else { + addressStr = "nil" + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{ + Text: fmt.Sprintf("Received: name=%q, address=%s", input.Name, addressStr), + }, + }, + }, nil, nil +} + +func testReconnectionHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + // Close the SSE stream to trigger client reconnection (SEP-1699) + if req.Extra != nil && req.Extra.CloseStream != nil { + req.Extra.CloseStream(10 * time.Millisecond) + } + + // Wait for client to reconnect + time.Sleep(100 * time.Millisecond) + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{ + Text: "Reconnection test completed successfully. If you received this, the client properly reconnected after stream closure.", + }, + }, + }, nil, nil +} + +// ============================================================================= +// Resources +// ============================================================================= + +func registerResources(server *mcp.Server) { + server.AddResource(&mcp.Resource{ + Name: "static-text", + Description: "A static text resource for testing", + MIMEType: "text/plain", + URI: "test://static-text", + }, staticTextHandler) + + server.AddResource(&mcp.Resource{ + Name: "static-binary", + Description: "A static binary resource (image) for testing", + MIMEType: "image/png", + URI: "test://static-binary", + }, staticBinaryHandler) + + server.AddResourceTemplate(&mcp.ResourceTemplate{ + Name: "template", + Description: "A resource template with parameter substitution", + MIMEType: "application/json", + URITemplate: "test://template/{id}/data", + }, templateResourceHandler) + + server.AddResource(&mcp.Resource{ + Name: "watched-resource", + Description: "A resource that auto-updates every 3 seconds", + MIMEType: "text/plain", + URI: watchedResourceURI, + }, watchedResourceHandler) +} + +func staticTextHandler(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{ + { + URI: req.Params.URI, + MIMEType: "text/plain", + Text: "This is the content of the static text resource.", + }, + }, + }, nil +} + +func staticBinaryHandler(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{ + { + URI: req.Params.URI, + MIMEType: "image/png", + Blob: imageData(), + }, + }, + }, nil +} + +// templatePattern is the compiled URI template for the template resource. +var templatePattern = uritemplate.MustNew("test://template/{id}/data") + +func templateResourceHandler(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + // Extract the ID from the URI using the template pattern + uri := req.Params.URI + match := templatePattern.Regexp().FindStringSubmatch(uri) + id := "" + if len(match) > 1 { + id = match[1] + } + + jsonContent := fmt.Sprintf(`{"id": "%s", "templateTest": true, "data": "Data for ID: %s"}`, id, id) + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{ + { + URI: uri, + MIMEType: "application/json", + Text: jsonContent, + }, + }, + }, nil +} + +func watchedResourceHandler(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{ + { + URI: req.Params.URI, + MIMEType: "text/plain", + Text: "Watched resource content", + }, + }, + }, nil +} + +// ============================================================================= +// Prompts +// ============================================================================= + +func registerPrompts(server *mcp.Server) { + server.AddPrompt(&mcp.Prompt{ + Name: "test_simple_prompt", + Title: "Simple Test Prompt", + Description: "A simple prompt without arguments", + }, simplePromptHandler) + + server.AddPrompt(&mcp.Prompt{ + Name: "test_prompt_with_arguments", + Title: "Prompt With Arguments", + Description: "A prompt with required arguments", + Arguments: []*mcp.PromptArgument{ + {Name: "arg1", Description: "First test argument", Required: true}, + {Name: "arg2", Description: "Second test argument", Required: true}, + }, + }, promptWithArgumentsHandler) + + server.AddPrompt(&mcp.Prompt{ + Name: "test_prompt_with_embedded_resource", + Title: "Prompt With Embedded Resource", + Description: "A prompt that includes an embedded resource", + Arguments: []*mcp.PromptArgument{ + {Name: "resourceUri", Description: "URI of the resource to embed", Required: true}, + }, + }, promptWithEmbeddedResourceHandler) + + server.AddPrompt(&mcp.Prompt{ + Name: "test_prompt_with_image", + Title: "Prompt With Image", + Description: "A prompt that includes image content", + }, promptWithImageHandler) +} + +func simplePromptHandler(ctx context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return &mcp.GetPromptResult{ + Description: "A simple test prompt", + Messages: []*mcp.PromptMessage{ + { + Role: "user", + Content: &mcp.TextContent{Text: "This is a simple prompt for testing."}, + }, + }, + }, nil +} + +func promptWithArgumentsHandler(ctx context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + arg1 := req.Params.Arguments["arg1"] + arg2 := req.Params.Arguments["arg2"] + + return &mcp.GetPromptResult{ + Description: "A prompt with arguments", + Messages: []*mcp.PromptMessage{ + { + Role: "user", + Content: &mcp.TextContent{ + Text: fmt.Sprintf("Prompt with arguments: arg1='%s', arg2='%s'", arg1, arg2), + }, + }, + }, + }, nil +} + +func promptWithEmbeddedResourceHandler(ctx context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + resourceUri := req.Params.Arguments["resourceUri"] + + return &mcp.GetPromptResult{ + Description: "A prompt with an embedded resource", + Messages: []*mcp.PromptMessage{ + { + Role: "user", + Content: &mcp.EmbeddedResource{ + Resource: &mcp.ResourceContents{ + URI: resourceUri, + MIMEType: "text/plain", + Text: "Embedded resource content for testing.", + }, + }, + }, + { + Role: "user", + Content: &mcp.TextContent{Text: "Please process the embedded resource above."}, + }, + }, + }, nil +} + +func promptWithImageHandler(ctx context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return &mcp.GetPromptResult{ + Description: "A prompt with an image", + Messages: []*mcp.PromptMessage{ + { + Role: "user", + Content: &mcp.ImageContent{ + Data: imageData(), + MIMEType: "image/png", + }, + }, + { + Role: "user", + Content: &mcp.TextContent{Text: "Please analyze the image above."}, + }, + }, + }, nil +} + +// ============================================================================= +// Server handlers +// ============================================================================= + +func completionHandler(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResult, error) { + // Return empty completion - just acknowledging the capability + return &mcp.CompleteResult{ + Completion: mcp.CompletionResultDetails{ + Values: []string{}, + Total: 0, + }, + }, nil +} + +func subscribeHandler(ctx context.Context, req *mcp.SubscribeRequest) error { + // The SDK handles subscription tracking internally via Server.ResourceUpdated() + return nil +} + +func unsubscribeHandler(ctx context.Context, req *mcp.UnsubscribeRequest) error { + // The SDK handles subscription tracking internally + return nil +} + +// ============================================================================= +// Helper functions +// ============================================================================= + +// Base64-encoded minimal test files, copied from the typescript conformance example. +const ( + // Minimal 1x1 red PNG image + testImageBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==" + // Minimal WAV audio file (silence) + testAudioBase64 = "UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA=" +) + +func imageData() []byte { + data, err := base64.StdEncoding.DecodeString(testImageBase64) + if err != nil { + panic("invalid testImageBase64: " + err.Error()) + } + return data +} + +func audioData() []byte { + data, err := base64.StdEncoding.DecodeString(testAudioBase64) + if err != nil { + panic("invalid testAudioBase64: " + err.Error()) + } + return data +} From 7b8f83fa1b10859933aae1961b1cf18058daec16 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Mon, 8 Dec 2025 12:58:02 -0500 Subject: [PATCH 136/418] .github: add permissions to the nightly workflow (#690) This is best practice, and I'd missed it while adding the nightly vulncheck workflow. --- .github/workflows/nightly.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 748ac91..9a90793 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -6,6 +6,9 @@ on: workflow_dispatch: +permissions: + contents: read + jobs: vulncheck: runs-on: ubuntu-latest From 6c16aa60a6ce4843c0e5a87eef25d3276653db8c Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Mon, 8 Dec 2025 13:03:17 -0500 Subject: [PATCH 137/418] scripts: add conformance test runner (#694) Add a script to run the official MCP conformance tests against the SDK's conformance server. The script supports saving results to a custom directory (--result_dir) and running against a local checkout of the conformance repo (--conformance_repo). Document the script in CONTRIBUTING.md. For #650 --- CONTRIBUTING.md | 28 +++++++++++ scripts/conformance.sh | 111 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100755 scripts/conformance.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 407b3bf..b340182 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,34 @@ containing the SDK, run: go work init ./project ./go-sdk ``` +### Conformance tests + +The SDK includes a script to run the official MCP conformance tests against the +SDK's conformance server: + +```sh +./scripts/conformance.sh +``` + +By default, results are cleaned up after the script runs. To save results to a +specific directory: + +```sh +./scripts/conformance.sh --result_dir ./conformance-results +``` + +To run against a local checkout of the +[conformance repo](https://github.com/modelcontextprotocol/conformance) instead +of the latest npm release: + +```sh +./scripts/conformance.sh --conformance_repo ~/src/conformance +``` + +Note: you must run `npm install` in the conformance repo first. + +Run `./scripts/conformance.sh --help` for more options. + ## Filing issues This project uses the [GitHub issue diff --git a/scripts/conformance.sh b/scripts/conformance.sh new file mode 100755 index 0000000..9e7d300 --- /dev/null +++ b/scripts/conformance.sh @@ -0,0 +1,111 @@ +#!/bin/bash +# Copyright 2025 The Go MCP SDK Authors. All rights reserved. +# Use of this source code is governed by an MIT-style +# license that can be found in the LICENSE file. + +# Run MCP conformance tests against the Go SDK conformance server. + +set -e + +PORT="${PORT:-3000}" +SERVER_PID="" +RESULT_DIR="" +WORKDIR="" +CONFORMANCE_REPO="" + +usage() { + echo "Usage: $0 [options]" + echo "" + echo "Run MCP conformance tests against the Go SDK conformance server." + echo "" + echo "Options:" + echo " --result_dir Save results to the specified directory" + echo " --conformance_repo Run conformance tests from a local checkout" + echo " instead of using the latest npm release" + echo " --help Show this help message" +} + +# Parse arguments. +while [[ $# -gt 0 ]]; do + case $1 in + --result_dir) + RESULT_DIR="$2" + shift 2 + ;; + --conformance_repo) + CONFORMANCE_REPO="$2" + shift 2 + ;; + --help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" + usage + exit 1 + ;; + esac +done + +cleanup() { + if [ -n "$SERVER_PID" ]; then + kill "$SERVER_PID" 2>/dev/null || true + fi + # Clean up the work directory unless --result_dir was specified. + if [ -z "$RESULT_DIR" ] && [ -n "$WORKDIR" ]; then + rm -rf "$WORKDIR" + fi +} +trap cleanup EXIT + +# Set up the work directory. +if [ -n "$RESULT_DIR" ]; then + mkdir -p "$RESULT_DIR" + WORKDIR="$RESULT_DIR" +else + WORKDIR=$(mktemp -d) +fi + +# Build the conformance server. +go build -o "$WORKDIR/conformance-server" ./examples/server/conformance + +# Start the server in the background +echo "Starting conformance server on port $PORT..." +"$WORKDIR/conformance-server" -http=":$PORT" & +SERVER_PID=$! + +echo "Server pid is $SERVER_PID" + +# Wait for server to be ready +echo "Waiting for server to be ready..." +for i in {1..30}; do + if curl -s "http://localhost:$PORT" > /dev/null 2>&1; then + echo "Server is ready." + break + fi + if [ "$i" -eq 30 ]; then + echo "Server failed to start within 15 seconds." + exit 1 + fi + sleep 0.5 +done + +# Run conformance tests from the work directory to avoid writing results to the repo. +echo "Running conformance tests..." +if [ -n "$CONFORMANCE_REPO" ]; then + # Run from local checkout using npm run start. + (cd "$WORKDIR" && \ + npm --prefix "$CONFORMANCE_REPO" run start -- \ + server --url "http://localhost:$PORT") +else + (cd "$WORKDIR" && \ + npx @modelcontextprotocol/conformance@latest server --url "http://localhost:$PORT") +fi + +echo "" +if [ -n "$RESULT_DIR" ]; then + echo "See $RESULT_DIR for details." +else + echo "Run with --result_dir to save results." +fi From 18cd63599dce4a23c36ed732b60f4071a8d0413c Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Mon, 8 Dec 2025 14:32:33 -0500 Subject: [PATCH 138/418] auth, mcp: add UserID to TokenInfo for session hijacking prevention (#695) Add a UserID field to auth.TokenInfo that TokenVerifiers can populate from JWT "sub" claims or token introspection. The streamable HTTP transport uses this to bind sessions to users, rejecting requests where the user ID doesn't match the session's original user. Fixes #589 --- auth/auth.go | 5 +++ mcp/streamable.go | 20 +++++++++++ mcp/streamable_test.go | 78 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/auth/auth.go b/auth/auth.go index 0eea1d8..6c34571 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -17,6 +17,11 @@ import ( type TokenInfo struct { Scopes []string Expiration time.Time + // UserID is an optional identifier for the authenticated user. + // If set by a TokenVerifier, it can be used by transports to prevent + // session hijacking by ensuring that all requests for a given session + // come from the same user. + UserID string // TODO: add standard JWT fields Extra map[string]any } diff --git a/mcp/streamable.go b/mcp/streamable.go index 9e210ab..9ab3303 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -51,6 +51,10 @@ type StreamableHTTPHandler struct { type sessionInfo struct { session *ServerSession transport *StreamableServerTransport + // userID is the user ID from the TokenInfo when the session was created. + // If non-empty, subsequent requests must have the same user ID to prevent + // session hijacking. + userID string // If timeout is set, automatically close the session after an idle period. timeout time.Duration @@ -238,6 +242,15 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque http.Error(w, "session not found", http.StatusNotFound) return } + // Prevent session hijacking: if the session was created with a user ID, + // verify that subsequent requests come from the same user. + if sessInfo != nil && sessInfo.userID != "" { + tokenInfo := auth.TokenInfoFromContext(req.Context()) + if tokenInfo == nil || tokenInfo.UserID != sessInfo.userID { + http.Error(w, "session user mismatch", http.StatusForbidden) + return + } + } } if req.Method == http.MethodDelete { @@ -404,9 +417,16 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque http.Error(w, "failed connection", http.StatusInternalServerError) return } + // Capture the user ID from the token info to enable session hijacking + // prevention on subsequent requests. + var userID string + if tokenInfo := auth.TokenInfoFromContext(req.Context()); tokenInfo != nil { + userID = tokenInfo.UserID + } sessInfo = &sessionInfo{ session: session, transport: transport, + userID: userID, } if stateless { diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index d2234b8..9a90d24 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1649,11 +1649,87 @@ func TestTokenInfo(t *testing.T) { if !ok { t.Fatal("not TextContent") } - if g, w := tc.Text, "&{[scope] 5000-01-02 03:04:05 +0000 UTC map[]}"; g != w { + if g, w := tc.Text, "&{[scope] 5000-01-02 03:04:05 +0000 UTC map[]}"; g != w { t.Errorf("got %q, want %q", g, w) } } +func TestSessionHijackingPrevention(t *testing.T) { + // This test verifies that sessions bound to a user ID cannot be accessed + // by a different user (session hijacking prevention). + ctx := context.Background() + + server := NewServer(testImpl, nil) + streamHandler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) + + // Use the bearer token directly as the user ID. This simulates how a real + // verifier might extract a user ID from a JWT "sub" claim or introspection. + verifier := func(_ context.Context, token string, _ *http.Request) (*auth.TokenInfo, error) { + return &auth.TokenInfo{ + Scopes: []string{"scope"}, + UserID: token, + Expiration: time.Date(5000, 1, 2, 3, 4, 5, 0, time.UTC), + }, nil + } + handler := auth.RequireBearerToken(verifier, nil)(streamHandler) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + defer httpServer.Close() + + // Helper to send a JSON-RPC request as a given user. + doRequest := func(msg jsonrpc.Message, sessionID, userID string) *http.Response { + t.Helper() + data, _ := jsonrpc2.EncodeMessage(msg) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, httpServer.URL, bytes.NewReader(data)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Authorization", "Bearer "+userID) + if sessionID != "" { + req.Header.Set("Mcp-Session-Id", sessionID) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + return resp + } + + // Create a session as user1. + initReq := &jsonrpc.Request{Method: "initialize", ID: jsonrpc2.Int64ID(1)} + initReq.Params, _ = json.Marshal(&InitializeParams{ + ProtocolVersion: protocolVersion20250618, + ClientInfo: &Implementation{Name: "test", Version: "1.0"}, + }) + resp := doRequest(initReq, "", "user1") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("initialize failed with status %d: %s", resp.StatusCode, body) + } + sessionID := resp.Header.Get("Mcp-Session-Id") + if sessionID == "" { + t.Fatal("no session ID in response") + } + + pingReq := &jsonrpc.Request{Method: "ping", ID: jsonrpc2.Int64ID(2)} + pingReq.Params, _ = json.Marshal(&PingParams{}) + + // Try to access the session as user2 - should fail. + resp2 := doRequest(pingReq, sessionID, "user2") + defer resp2.Body.Close() + if resp2.StatusCode != http.StatusForbidden { + body, _ := io.ReadAll(resp2.Body) + t.Errorf("expected status %d for user mismatch, got %d: %s", http.StatusForbidden, resp2.StatusCode, body) + } + + // Access as original user1 should succeed. + resp3 := doRequest(pingReq, sessionID, "user1") + defer resp3.Body.Close() + if resp3.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp3.Body) + t.Errorf("expected status %d for matching user, got %d: %s", http.StatusOK, resp3.StatusCode, body) + } +} + func TestStreamableGET(t *testing.T) { // This test checks the fix for problematic behavior described in #410: // Hanging GET headers should be written immediately, even if there are no From d6d6edd6d4362543455a8d504db6046c884f6a2d Mon Sep 17 00:00:00 2001 From: Eliya Sadan Date: Mon, 8 Dec 2025 21:54:52 +0200 Subject: [PATCH 139/418] auth: add OAuth 2.0 Protected Resource Metadata handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change adds support for RFC 9728 (OAuth 2.0 Protected Resource Metadata) by introducing a `ProtectedResourceMetadataHandler`. The handler includes built-in CORS support with `Access-Control-Allow-Origin: *` by default, as OAuth metadata is public information meant for client discovery. Documentation includes examples for using custom CORS policies with popular middleware libraries (github.com/rs/cors and github.com/jub0bs/cors). The implementation follows RFC 9728 §3.1 for OAuth 2.0 Authorization Server Metadata discovery, enabling clients to discover protected resource capabilities and authentication requirements. --- auth/auth.go | 43 +++++++++ auth/auth_test.go | 112 ++++++++++++++++++++++ docs/protocol.md | 29 +++++- examples/server/auth-middleware/README.md | 36 +++++++ examples/server/auth-middleware/go.mod | 1 + examples/server/auth-middleware/go.sum | 2 + examples/server/auth-middleware/main.go | 34 +++++-- internal/docs/protocol.src.md | 29 +++++- 8 files changed, 275 insertions(+), 11 deletions(-) diff --git a/auth/auth.go b/auth/auth.go index 6c34571..8766512 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -6,11 +6,14 @@ package auth import ( "context" + "encoding/json" "errors" "net/http" "slices" "strings" "time" + + "github.com/modelcontextprotocol/go-sdk/oauthex" ) // TokenInfo holds information from a bearer token. @@ -123,3 +126,43 @@ func verify(req *http.Request, verifier TokenVerifier, opts *RequireBearerTokenO } return tokenInfo, "", 0 } + +// ProtectedResourceMetadataHandler returns an http.Handler that serves OAuth 2.0 +// protected resource metadata (RFC 9728) with CORS support. +// +// This handler allows cross-origin requests from any origin (Access-Control-Allow-Origin: *) +// because OAuth metadata is public information intended for client discovery (RFC 9728 §3.1). +// The metadata contains only non-sensitive configuration data about authorization servers +// and supported scopes. +// +// No validation of metadata fields is performed; ensure metadata accuracy at configuration time. +// +// For more sophisticated CORS policies or to restrict origins, wrap this handler with a +// CORS middleware like github.com/rs/cors or github.com/jub0bs/cors. +func ProtectedResourceMetadataHandler(metadata *oauthex.ProtectedResourceMetadata) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Set CORS headers for cross-origin client discovery. + // OAuth metadata is public information, so allowing any origin is safe. + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + // Handle CORS preflight requests + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + // Only GET allowed for metadata retrieval + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(metadata); err != nil { + http.Error(w, "Failed to encode metadata", http.StatusInternalServerError) + return + } + }) +} diff --git a/auth/auth_test.go b/auth/auth_test.go index ef8ea7b..a943404 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -6,10 +6,15 @@ package auth import ( "context" + "encoding/json" "errors" "net/http" + "net/http/httptest" + "strings" "testing" "time" + + "github.com/modelcontextprotocol/go-sdk/oauthex" ) func TestVerify(t *testing.T) { @@ -76,3 +81,110 @@ func TestVerify(t *testing.T) { }) } } + +func TestProtectedResourceMetadataHandler(t *testing.T) { + metadata := &oauthex.ProtectedResourceMetadata{ + Resource: "https://example.com/mcp", + AuthorizationServers: []string{ + "https://auth.example.com/.well-known/openid-configuration", + }, + ScopesSupported: []string{"read", "write"}, + } + + handler := ProtectedResourceMetadataHandler(metadata) + + tests := []struct { + name string + method string + wantStatus int + checkJSON bool + }{ + { + name: "GET returns metadata", + method: http.MethodGet, + wantStatus: http.StatusOK, + checkJSON: true, + }, + { + name: "OPTIONS for CORS preflight", + method: http.MethodOptions, + wantStatus: http.StatusNoContent, + }, + { + name: "POST not allowed", + method: http.MethodPost, + wantStatus: http.StatusMethodNotAllowed, + }, + { + name: "PUT not allowed", + method: http.MethodPut, + wantStatus: http.StatusMethodNotAllowed, + }, + { + name: "DELETE not allowed", + method: http.MethodDelete, + wantStatus: http.StatusMethodNotAllowed, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(tt.method, "/.well-known/oauth-protected-resource", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus) + } + + // All responses should have CORS headers + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("Access-Control-Allow-Origin = %q, want %q", got, "*") + } + + if got := rec.Header().Get("Access-Control-Allow-Methods"); got != "GET, OPTIONS" { + t.Errorf("Access-Control-Allow-Methods = %q, want %q", got, "GET, OPTIONS") + } + + // Validate error response body for disallowed methods + if tt.wantStatus == http.StatusMethodNotAllowed { + if !strings.Contains(rec.Body.String(), "Method not allowed") { + t.Errorf("error body = %q, want to contain %q", rec.Body.String(), "Method not allowed") + } + } + + if tt.checkJSON { + if got := rec.Header().Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q, want %q", got, "application/json") + } + + var got oauthex.ProtectedResourceMetadata + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if got.Resource != metadata.Resource { + t.Errorf("Resource = %q, want %q", got.Resource, metadata.Resource) + } + + if len(got.AuthorizationServers) != len(metadata.AuthorizationServers) { + t.Errorf("AuthorizationServers length = %d, want %d", + len(got.AuthorizationServers), len(metadata.AuthorizationServers)) + } + + for i, server := range got.AuthorizationServers { + if server != metadata.AuthorizationServers[i] { + t.Errorf("AuthorizationServers[%d] = %q, want %q", + i, server, metadata.AuthorizationServers[i]) + } + } + + if len(got.ScopesSupported) != len(metadata.ScopesSupported) { + t.Errorf("ScopesSupported length = %d, want %d", + len(got.ScopesSupported), len(metadata.ScopesSupported)) + } + } + }) + } +} diff --git a/docs/protocol.md b/docs/protocol.md index 6d22c4b..d96e26f 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -273,7 +273,34 @@ from `req.Extra.TokenInfo`, where `req` is the handler's request. (For example, [`CallToolRequest`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CallToolRequest).) HTTP handlers wrapped by the `RequireBearerToken` middleware can obtain the `TokenInfo` from the context with [`auth.TokenInfoFromContext`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenInfoFromContext). - + +#### OAuth Protected Resource Metadata + +Servers implementing OAuth 2.0 authorization should expose a protected resource metadata endpoint +as specified in [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728). This endpoint provides +clients with information about the resource server's OAuth configuration, including which +authorization servers can be used and what scopes are supported. + +The SDK provides [`ProtectedResourceMetadataHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#ProtectedResourceMetadataHandler) +to serve this metadata. The handler automatically sets CORS headers (`Access-Control-Allow-Origin: *`) +to support cross-origin client discovery, as the metadata contains only public configuration information. + +Example usage: + +```go +metadata := &oauthex.ProtectedResourceMetadata{ + Resource: "https://example.com/mcp", + AuthorizationServers: []string{ + "https://auth.example.com/.well-known/openid-configuration", + }, + ScopesSupported: []string{"read", "write"}, +} +http.Handle("/.well-known/oauth-protected-resource", + auth.ProtectedResourceMetadataHandler(metadata)) +``` + +For more sophisticated CORS policies, wrap the handler with a CORS middleware like +[github.com/rs/cors](https://github.com/rs/cors) or [github.com/jub0bs/cors](https://github.com/jub0bs/cors). The [_auth middleware example_](https://github.com/modelcontextprotocol/go-sdk/tree/main/examples/server/auth-middleware) shows how to implement authorization for both JWT tokens and API keys. diff --git a/examples/server/auth-middleware/README.md b/examples/server/auth-middleware/README.md index aaae767..001237c 100644 --- a/examples/server/auth-middleware/README.md +++ b/examples/server/auth-middleware/README.md @@ -44,6 +44,7 @@ go test -cover ### Public Endpoints (No Authentication Required) - `GET /health` - Health check +- `GET /.well-known/oauth-protected-resource` - OAuth 2.0 Protected Resource Metadata (RFC 9728) ### MCP Endpoints (Authentication Required) @@ -228,6 +229,41 @@ authenticatedHandler := authMiddleware(customMiddleware(mcpHandler)) 4. **Token Expiration**: Set appropriate token expiration times 5. **Principle of Least Privilege**: Grant only the minimum required scopes +## CORS Support + +The OAuth metadata endpoint (`/.well-known/oauth-protected-resource`) supports CORS to enable +cross-origin client discovery. It sets `Access-Control-Allow-Origin: *` by default because +OAuth metadata is public information meant for client discovery (RFC 9728 §3.1). + +### Custom CORS Policies + +For more sophisticated CORS requirements (origin validation, credentials, etc.), wrap the handler +with a CORS middleware library: + +**Using github.com/rs/cors:** +```go +import "github.com/rs/cors" + +c := cors.New(cors.Options{ + AllowedOrigins: []string{"https://example.com"}, + AllowedMethods: []string{"GET", "OPTIONS"}, +}) +http.Handle("/.well-known/oauth-protected-resource", + c.Handler(auth.ProtectedResourceMetadataHandler(metadata))) +``` + +**Using github.com/jub0bs/cors:** +```go +import "github.com/jub0bs/cors" + +corsMiddleware, err := cors.NewMiddleware(cors.Config{ + Origins: []string{"https://example.com"}, + Methods: []string{"GET", "OPTIONS"}, +}) +http.Handle("/.well-known/oauth-protected-resource", + corsMiddleware.Wrap(auth.ProtectedResourceMetadataHandler(metadata))) +``` + ## Use Cases **Ideal for:** diff --git a/examples/server/auth-middleware/go.mod b/examples/server/auth-middleware/go.mod index cbc89c7..9975879 100644 --- a/examples/server/auth-middleware/go.mod +++ b/examples/server/auth-middleware/go.mod @@ -10,6 +10,7 @@ require ( require ( github.com/google/jsonschema-go v0.3.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.30.0 // indirect ) replace github.com/modelcontextprotocol/go-sdk => ../../../ diff --git a/examples/server/auth-middleware/go.sum b/examples/server/auth-middleware/go.sum index 7b7a8e5..32ceedf 100644 --- a/examples/server/auth-middleware/go.sum +++ b/examples/server/auth-middleware/go.sum @@ -6,5 +6,7 @@ github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIy github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= diff --git a/examples/server/auth-middleware/main.go b/examples/server/auth-middleware/main.go index 75a38b8..ca339df 100644 --- a/examples/server/auth-middleware/main.go +++ b/examples/server/auth-middleware/main.go @@ -20,6 +20,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/modelcontextprotocol/go-sdk/auth" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/modelcontextprotocol/go-sdk/oauthex" ) // This example demonstrates how to integrate auth.RequireBearerToken middleware @@ -237,7 +238,8 @@ func main() { // Create authentication middleware. jwtAuth := auth.RequireBearerToken(verifyJWT, &auth.RequireBearerTokenOptions{ - Scopes: []string{"read"}, // Require "read" permission + Scopes: []string{"read"}, // Require "read" permission + ResourceMetadataURL: "http://localhost:8080/.well-known/oauth-protected-resource", }) apiKeyAuth := auth.RequireBearerToken(verifyAPIKey, &auth.RequireBearerTokenOptions{ @@ -340,22 +342,36 @@ func main() { }) }) + // OAuth protected resource metadata endpoint. + // This endpoint provides OAuth configuration information to clients. + // CORS is enabled by default to support cross-origin client discovery. + metadata := &oauthex.ProtectedResourceMetadata{ + Resource: "http://localhost:8080/mcp/jwt", + AuthorizationServers: []string{ + "https://auth.example.com/.well-known/openid-configuration", + }, + ScopesSupported: []string{"read", "write"}, + } + http.Handle("/.well-known/oauth-protected-resource", + auth.ProtectedResourceMetadataHandler(metadata)) + // Start the HTTP server. log.Println("Authenticated MCP Server") log.Println("========================") log.Println("Server starting on", *httpAddr) log.Println() log.Println("Available endpoints:") - log.Println(" GET /health - Health check (no auth)") - log.Println(" GET /generate-token - Generate JWT token") - log.Println(" POST /generate-api-key - Generate API key") - log.Println(" POST /mcp/jwt - MCP endpoint (JWT auth)") - log.Println(" POST /mcp/apikey - MCP endpoint (API key auth)") + log.Println(" GET /health - Health check (no auth)") + log.Println(" GET /.well-known/oauth-protected-resource - OAuth resource metadata") + log.Println(" GET /generate-token - Generate JWT token") + log.Println(" POST /generate-api-key - Generate API key") + log.Println(" POST /mcp/jwt - MCP endpoint (JWT auth)") + log.Println(" POST /mcp/apikey - MCP endpoint (API key auth)") log.Println() log.Println("Available MCP Tools:") - log.Println(" - say_hi - Simple greeting (any auth)") - log.Println(" - get_user_info - Get user info (read scope)") - log.Println(" - create_resource - Create resource (write scope)") + log.Println(" - say_hi - Simple greeting (any auth)") + log.Println(" - get_user_info - Get user info (read scope)") + log.Println(" - create_resource - Create resource (write scope)") log.Println() log.Println("Example usage:") log.Println(" # Generate a token") diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index b0874cc..d8d3006 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -199,7 +199,34 @@ from `req.Extra.TokenInfo`, where `req` is the handler's request. (For example, [`CallToolRequest`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CallToolRequest).) HTTP handlers wrapped by the `RequireBearerToken` middleware can obtain the `TokenInfo` from the context with [`auth.TokenInfoFromContext`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenInfoFromContext). - + +#### OAuth Protected Resource Metadata + +Servers implementing OAuth 2.0 authorization should expose a protected resource metadata endpoint +as specified in [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728). This endpoint provides +clients with information about the resource server's OAuth configuration, including which +authorization servers can be used and what scopes are supported. + +The SDK provides [`ProtectedResourceMetadataHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#ProtectedResourceMetadataHandler) +to serve this metadata. The handler automatically sets CORS headers (`Access-Control-Allow-Origin: *`) +to support cross-origin client discovery, as the metadata contains only public configuration information. + +Example usage: + +```go +metadata := &oauthex.ProtectedResourceMetadata{ + Resource: "https://example.com/mcp", + AuthorizationServers: []string{ + "https://auth.example.com/.well-known/openid-configuration", + }, + ScopesSupported: []string{"read", "write"}, +} +http.Handle("/.well-known/oauth-protected-resource", + auth.ProtectedResourceMetadataHandler(metadata)) +``` + +For more sophisticated CORS policies, wrap the handler with a CORS middleware like +[github.com/rs/cors](https://github.com/rs/cors) or [github.com/jub0bs/cors](https://github.com/jub0bs/cors). The [_auth middleware example_](https://github.com/modelcontextprotocol/go-sdk/tree/main/examples/server/auth-middleware) shows how to implement authorization for both JWT tokens and API keys. From 87c81260e29a5a9f6ec67be65410eb4308a1c2c6 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Mon, 8 Dec 2025 15:24:41 -0500 Subject: [PATCH 140/418] mcp: gate prime and close events on protocol version 2025-11-25 (#696) The prime and close SSE events (SEP-1699) were added in protocol version 2025-11-25. Only send these events when the client negotiates that version or later. - In servePOST, extract protocol version from InitializeParams for initialize requests, otherwise use the Mcp-Protocol-Version header - In serveGET/acquireStream, read protocol version from header - Only set closeLocked callback when prime/close is supported - Add unexported protocolVersion field to ClientSessionOptions for testing Fixes #686 --- mcp/client.go | 14 ++++++-- mcp/streamable.go | 72 +++++++++++++++++++++++++++----------- mcp/streamable_test.go | 79 +++++++++++++++++++++++++++++++++++++----- 3 files changed, 132 insertions(+), 33 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index a3d36b2..7349ba9 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -120,7 +120,11 @@ func (e unsupportedProtocolVersionError) Error() string { } // ClientSessionOptions is reserved for future use. -type ClientSessionOptions struct{} +type ClientSessionOptions struct { + // protocolVersion overrides the protocol version sent in the initialize + // request, for testing. If empty, latestProtocolVersion is used. + protocolVersion string +} func (c *Client) capabilities() *ClientCapabilities { caps := &ClientCapabilities{} @@ -151,14 +155,18 @@ func (c *Client) capabilities() *ClientCapabilities { // when it is no longer needed. However, if the connection is closed by the // server, calls or notifications will return an error wrapping // [ErrConnectionClosed]. -func (c *Client) Connect(ctx context.Context, t Transport, _ *ClientSessionOptions) (cs *ClientSession, err error) { +func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOptions) (cs *ClientSession, err error) { cs, err = connect(ctx, t, c, (*clientSessionState)(nil), nil) if err != nil { return nil, err } + protocolVersion := latestProtocolVersion + if opts != nil && opts.protocolVersion != "" { + protocolVersion = opts.protocolVersion + } params := &InitializeParams{ - ProtocolVersion: latestProtocolVersion, + ProtocolVersion: protocolVersion, ClientInfo: c.impl, Capabilities: c.capabilities(), } diff --git a/mcp/streamable.go b/mcp/streamable.go index 9ab3303..b48b7c1 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -730,7 +730,15 @@ func (c *streamableServerConn) serveGET(w http.ResponseWriter, req *http.Request ctx, cancel := context.WithCancel(req.Context()) defer cancel() - stream, done := c.acquireStream(ctx, w, streamID, &lastIdx) + // Read the protocol version from the header. For GET requests, this should + // always be present since GET only happens after initialization. + protocolVersion := req.Header.Get(protocolVersionHeader) + if protocolVersion == "" { + protocolVersion = protocolVersion20250326 + } + supportsPrimeClose := protocolVersion >= protocolVersion20251125 + + stream, done := c.acquireStream(ctx, w, streamID, &lastIdx, supportsPrimeClose) if stream == nil { return } @@ -792,7 +800,10 @@ func (c *streamableServerConn) writeCloseEvent(w http.ResponseWriter, reconnectA // Importantly, this function must hold the stream mutex until done replaying // all messages, so that no delivery or storage of new messages occurs while // the stream is still replaying. -func (c *streamableServerConn) acquireStream(ctx context.Context, w http.ResponseWriter, streamID string, lastIdx *int) (*stream, chan struct{}) { +// +// supportsPrimeClose indicates whether the client supports the prime and close +// events (protocol version 2025-11-25 or later). +func (c *streamableServerConn) acquireStream(ctx context.Context, w http.ResponseWriter, streamID string, lastIdx *int, supportsPrimeClose bool) (*stream, chan struct{}) { // if tempStream is set, the stream is done and we're just replaying messages. // // We record a temporary stream to claim exclusive replay rights. @@ -898,16 +909,18 @@ func (c *streamableServerConn) acquireStream(ctx context.Context, w http.Respons } return c.writeEvent(w, s.id, Event{Name: "message", Data: data}, lastIdx) } - s.closeLocked = func(reconnectAfter time.Duration) { - select { - case <-done: - return - default: + if supportsPrimeClose { + s.closeLocked = func(reconnectAfter time.Duration) { + select { + case <-done: + return + default: + } + if reconnectAfter > 0 { + c.writeCloseEvent(w, reconnectAfter) + } + close(done) } - if reconnectAfter > 0 { - c.writeCloseEvent(w, reconnectAfter) - } - close(done) } return s, done } @@ -959,6 +972,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques calls := make(map[jsonrpc.ID]struct{}) tokenInfo := auth.TokenInfoFromContext(req.Context()) isInitialize := false + var initializeProtocolVersion string for _, msg := range incoming { if jreq, ok := msg.(*jsonrpc.Request); ok { // Preemptively check that this is a valid request, so that we can fail @@ -970,6 +984,11 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques } if jreq.Method == methodInitialize { isInitialize = true + // Extract the protocol version from InitializeParams. + var params InitializeParams + if err := json.Unmarshal(jreq.Params, ¶ms); err == nil { + initializeProtocolVersion = params.ProtocolVersion + } } jreq.Extra = &RequestExtra{ TokenInfo: tokenInfo, @@ -994,6 +1013,15 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques } } + // The prime and close events were added in protocol version 2025-11-25 (SEP-1699). + // Use the version from InitializeParams if this is an initialize request, + // otherwise use the protocol version header. + effectiveVersion := protocolVersion + if isInitialize && initializeProtocolVersion != "" { + effectiveVersion = initializeProtocolVersion + } + supportsPrimeClose := effectiveVersion >= protocolVersion20251125 + // If we don't have any calls, we can just publish the incoming messages and return. // No need to track a logical stream. if len(calls) == 0 { @@ -1069,7 +1097,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques } else { // Write events in the order we receive them. lastIndex := -1 - if c.eventStore != nil { + if c.eventStore != nil && supportsPrimeClose { // Write a priming event. // We must also write it to the event store in order for indexes to // align. @@ -1092,16 +1120,18 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques } return c.writeEvent(w, stream.id, Event{Name: "message", Data: data}, &lastIndex) } - stream.closeLocked = func(reconnectAfter time.Duration) { - select { - case <-done: - return - default: + if supportsPrimeClose { + stream.closeLocked = func(reconnectAfter time.Duration) { + select { + case <-done: + return + default: + } + if reconnectAfter > 0 { + c.writeCloseEvent(w, reconnectAfter) + } + close(done) } - if reconnectAfter > 0 { - c.writeCloseEvent(w, reconnectAfter) - } - close(done) } } diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 9a90d24..9eff74b 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -584,7 +584,7 @@ func TestStreamableServerDisconnect(t *testing.T) { }) clientSession, err := client.Connect(ctx, &StreamableClientTransport{ Endpoint: httpServer.URL, - }, nil) + }, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -752,7 +752,7 @@ func TestStreamableServerTransport(t *testing.T) { // requests. // Predefined steps, to avoid repetition below. - initReq := req(1, methodInitialize, &InitializeParams{}) + initReq := req(1, methodInitialize, &InitializeParams{ProtocolVersion: protocolVersion20250618}) initResp := resp(1, &InitializeResult{ Capabilities: &ServerCapabilities{ Logging: &LoggingCapabilities{}, @@ -775,6 +775,30 @@ func TestStreamableServerTransport(t *testing.T) { wantStatusCode: http.StatusAccepted, } + // Protocol version 2025-11-25 variants, for testing prime/close events (SEP-1699). + initReq20251125 := req(1, methodInitialize, &InitializeParams{ProtocolVersion: protocolVersion20251125}) + initResp20251125 := resp(1, &InitializeResult{ + Capabilities: &ServerCapabilities{ + Logging: &LoggingCapabilities{}, + Tools: &ToolCapabilities{ListChanged: true}, + }, + ProtocolVersion: protocolVersion20251125, + ServerInfo: &Implementation{Name: "testServer", Version: "v1.0.0"}, + }, nil) + initialize20251125 := streamableRequest{ + method: "POST", + messages: []jsonrpc.Message{initReq20251125}, + wantStatusCode: http.StatusOK, + wantMessages: []jsonrpc.Message{initResp20251125}, + wantSessionID: true, + } + initialized20251125 := streamableRequest{ + method: "POST", + headers: http.Header{protocolVersionHeader: {protocolVersion20251125}}, + messages: []jsonrpc.Message{initializedMsg}, + wantStatusCode: http.StatusAccepted, + } + tests := []struct { name string replay bool // if set, use a MemoryEventStore to enable replay @@ -1026,23 +1050,23 @@ func TestStreamableServerTransport(t *testing.T) { wantSessions: 0, // session deleted }, { - name: "priming message", + name: "no priming message on old protocol", replay: true, requests: []streamableRequest{ initialize, initialized, { - method: "POST", - messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})}, - wantStatusCode: http.StatusOK, - wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)}, - wantBodyContaining: "prime", + method: "POST", + messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})}, + wantStatusCode: http.StatusOK, + wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)}, + wantBodyNotContaining: "prime", }, }, wantSessions: 1, }, { - name: "close message", + name: "no close message on old protocol", replay: true, tool: func(t *testing.T, _ context.Context, req *CallToolRequest) { req.Extra.CloseStream(time.Millisecond) @@ -1055,6 +1079,43 @@ func TestStreamableServerTransport(t *testing.T) { messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)}, + wantBodyNotContaining: "close", + }, + }, + wantSessions: 1, + }, + { + name: "priming message on 2025-11-25", + replay: true, + requests: []streamableRequest{ + initialize20251125, + initialized20251125, + { + method: "POST", + headers: http.Header{protocolVersionHeader: {protocolVersion20251125}}, + messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})}, + wantStatusCode: http.StatusOK, + wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)}, + wantBodyContaining: "prime", + }, + }, + wantSessions: 1, + }, + { + name: "close message on 2025-11-25", + replay: true, + tool: func(t *testing.T, _ context.Context, req *CallToolRequest) { + req.Extra.CloseStream(time.Millisecond) + }, + requests: []streamableRequest{ + initialize20251125, + initialized20251125, + { + method: "POST", + headers: http.Header{protocolVersionHeader: {protocolVersion20251125}}, + messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "tool"})}, + wantStatusCode: http.StatusOK, + wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)}, wantBodyContaining: "close", wantBodyNotContaining: "result", }, From d1c06cb74857a7ded909f42f5207079b6ecf4327 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Mon, 8 Dec 2025 15:25:34 -0500 Subject: [PATCH 141/418] internal/docs: document UserID for session hijacking prevention (#697) Update our Session Hijacking mitigation documentation to describe the new TokenInfo.UserID field and how the streamable transport uses it to bind sessions to users. Fixes #571 --- docs/protocol.md | 15 ++++++++++++--- internal/docs/protocol.src.md | 15 ++++++++++++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index d96e26f..16ba0bf 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -343,9 +343,18 @@ If you create your own with If you are using Go 1.24 or above, we recommend using [`crypto/rand.Text`](https://pkg.go.dev/crypto/rand#Text) -- _Binding session IDs to user information_. This is an application requirement, out of scope -for the SDK. You can create your own session IDs by setting -[`ServerOptions.GetSessionID`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.GetSessionID). +- _Binding session IDs to user information_. The SDK supports this mitigation through +[`TokenInfo.UserID`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenInfo.UserID). +When a [`TokenVerifier`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenVerifier) +sets `UserID` on the returned `TokenInfo`, the streamable transport will: + 1. Store the user ID when a new session is created. + 2. Verify that subsequent requests to that session include a token with the same `UserID`. + 3. Reject requests with a 403 Forbidden if the user ID doesn't match. + + **Recommendation**: If your `TokenVerifier` can extract a user identifier from the token + (such as a `sub` claim in a JWT, or a user ID associated with an API key), set + `TokenInfo.UserID` to enable this protection. This prevents an attacker with a valid + token from hijacking another user's session by guessing or obtaining their session ID. ## Utilities diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index d8d3006..ada3437 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -269,9 +269,18 @@ If you create your own with If you are using Go 1.24 or above, we recommend using [`crypto/rand.Text`](https://pkg.go.dev/crypto/rand#Text) -- _Binding session IDs to user information_. This is an application requirement, out of scope -for the SDK. You can create your own session IDs by setting -[`ServerOptions.GetSessionID`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.GetSessionID). +- _Binding session IDs to user information_. The SDK supports this mitigation through +[`TokenInfo.UserID`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenInfo.UserID). +When a [`TokenVerifier`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenVerifier) +sets `UserID` on the returned `TokenInfo`, the streamable transport will: + 1. Store the user ID when a new session is created. + 2. Verify that subsequent requests to that session include a token with the same `UserID`. + 3. Reject requests with a 403 Forbidden if the user ID doesn't match. + + **Recommendation**: If your `TokenVerifier` can extract a user identifier from the token + (such as a `sub` claim in a JWT, or a user ID associated with an API key), set + `TokenInfo.UserID` to enable this protection. This prevents an attacker with a valid + token from hijacking another user's session by guessing or obtaining their session ID. ## Utilities From afe88881299bd4e7beeeb47b4dd9b2e6b05cdede Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Wed, 10 Dec 2025 10:26:22 -0500 Subject: [PATCH 142/418] examples: improve example for listfeatures (#701) Use an example from the Go SDK, rather than something that requires node.js. Fixes #700 --- examples/client/listfeatures/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/client/listfeatures/main.go b/examples/client/listfeatures/main.go index 044b5e9..193d29f 100644 --- a/examples/client/listfeatures/main.go +++ b/examples/client/listfeatures/main.go @@ -39,7 +39,7 @@ func main() { fmt.Fprintln(os.Stderr, "Usage: listfeatures --http=\"https://example.com/server/mcp\"") fmt.Fprintln(os.Stderr, "List all features for a stdio MCP server") fmt.Fprintln(os.Stderr) - fmt.Fprintln(os.Stderr, "Example:\n\tlistfeatures npx @modelcontextprotocol/server-everything") + fmt.Fprintln(os.Stderr, "Example:\n\tlistfeatures go run github.com/modelcontextprotocol/go-sdk/examples/server/hello") os.Exit(2) } From b8c21e9fc038657cf2479e12575cb5c82f191d41 Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Sat, 6 Dec 2025 19:13:57 -0500 Subject: [PATCH 143/418] mcp: add lastEventIDHeader for streamable implementation Use a constant, to be consistent with other headers. --- mcp/streamable.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index b48b7c1..2fcd997 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -32,6 +32,7 @@ import ( const ( protocolVersionHeader = "Mcp-Protocol-Version" sessionIDHeader = "Mcp-Session-Id" + lastEventIDHeader = "Last-Event-ID" ) // A StreamableHTTPHandler is an http.Handler that serves streamable MCP @@ -713,8 +714,8 @@ func (c *streamableServerConn) serveGET(w http.ResponseWriter, req *http.Request // By default, we haven't seen a last index. Since indices start at 0, we represent // that by -1. This is incremented just before each event is written. lastIdx := -1 - if len(req.Header.Values("Last-Event-ID")) > 0 { - eid := req.Header.Get("Last-Event-ID") + if len(req.Header.Values(lastEventIDHeader)) > 0 { + eid := req.Header.Get(lastEventIDHeader) var ok bool streamID, lastIdx, ok = parseEventID(eid) if !ok { @@ -931,7 +932,7 @@ func (c *streamableServerConn) acquireStream(ctx context.Context, w http.Respons // // It returns an HTTP status code and error message. func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Request) { - if len(req.Header.Values("Last-Event-ID")) > 0 { + if len(req.Header.Values(lastEventIDHeader)) > 0 { http.Error(w, "can't send Last-Event-ID for POST request", http.StatusBadRequest) return } @@ -1923,7 +1924,7 @@ func (c *streamableClientConn) connectSSE(ctx context.Context, lastEventID strin } c.setMCPHeaders(req) if lastEventID != "" { - req.Header.Set("Last-Event-ID", lastEventID) + req.Header.Set(lastEventIDHeader, lastEventID) } req.Header.Set("Accept", "text/event-stream") resp, err := c.client.Do(req) From dd5a8ff77b125a60ea3e7658a52258639eb5b146 Mon Sep 17 00:00:00 2001 From: Markus Kusano Date: Fri, 5 Dec 2025 17:49:54 +0000 Subject: [PATCH 144/418] mcp: clarify documentation for the streamable transports After a deep dive with the team, document areas of the codebase identified as unclear. For now, this is a documentation-only commit (apart from one switch of jsonrpc2.Request.ID.IsValid->Request.IsCall). Special TODO(cleanup) comments are added for additional code changes in follow-up commits. Also, include high level doc comments for server and client transports, in new streamable_server.go and streamable_client.go. Eventually, the server and client transport logic should be migrated to these files, as streamable.go has gotten too large. However, we can do that at a future date, to minimize diff. --- internal/jsonrpc2/conn.go | 13 ++- mcp/shared.go | 20 +++- mcp/streamable.go | 73 +++++++++--- mcp/streamable_client.go | 226 ++++++++++++++++++++++++++++++++++++++ mcp/streamable_server.go | 153 ++++++++++++++++++++++++++ 5 files changed, 462 insertions(+), 23 deletions(-) create mode 100644 mcp/streamable_client.go create mode 100644 mcp/streamable_server.go diff --git a/internal/jsonrpc2/conn.go b/internal/jsonrpc2/conn.go index f4ac86f..9ed924a 100644 --- a/internal/jsonrpc2/conn.go +++ b/internal/jsonrpc2/conn.go @@ -55,9 +55,16 @@ type ConnectionOptions struct { } // Connection manages the jsonrpc2 protocol, connecting responses back to their -// calls. -// Connection is bidirectional; it does not have a designated server or client -// end. +// calls. Connection is bidirectional; it does not have a designated server or +// client end. +// +// Note that the word 'Connection' is overloaded: the mcp.Connection represents +// the bidirectional stream of messages between client an server. The +// jsonrpc2.Connection layers RPC logic on top of that stream, dispatching RPC +// handlers, and correlating requests with responses from the peer. +// +// Some of the complexity of the Connection type is grown out of its usage in +// gopls: it could probably be simplified based on our usage in MCP. type Connection struct { seq int64 // must only be accessed using atomic operations diff --git a/mcp/shared.go b/mcp/shared.go index 43a0d6f..f19236f 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -394,7 +394,7 @@ func notifySessions[S Session, P Params](sessions []S, method string, params P) for _, s := range sessions { req := newRequest(s, params) if err := handleNotify(ctx, method, req); err != nil { - // TODO(jba): surface this error better + // TODO(#218): surface this error better log.Printf("calling %s: %v", method, err) } } @@ -469,15 +469,23 @@ type RequestExtra struct { TokenInfo *auth.TokenInfo // bearer token info (e.g. from OAuth) if any Header http.Header // header from HTTP request, if any - // CloseStream closes the current request stream, if the current transport - // supports replaying requests. + // If set, CloseStream explicitly closes the current request stream. // - // If reconnectAfter is nonzero, it signals to the client to reconnect after - // the given duration. Otherwise, clients may determine their own - // reconnection policy. + // [SEP-1699] introduced server-side SSE stream disconnection: for + // long-running requests, servers may opt to close the SSE stream and + // ask the client to retry at a later time. CloseStream implements this + // feature; if reconnectAfter is set, an event is sent with a `retry:` field + // to configure the reconnection delay. + // + // [SEP-1699]: https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699 CloseStream func(reconnectAfter time.Duration) } +// TODO(cleanup): switch to an args struct here for forwards compatibility. +// type CloseStreamArgs struct { +// After time.Duration +// } + func (*ClientRequest[P]) isRequest() {} func (*ServerRequest[P]) isRequest() {} diff --git a/mcp/streamable.go b/mcp/streamable.go index 2fcd997..059a1a7 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -2,6 +2,10 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. +// NOTE: see streamable_server.go and streamable_client.go for detailed +// documentation of the streamable server design. +// TODO(cleanup): move the client and server logic into those files. + package mcp import ( @@ -632,6 +636,9 @@ type stream struct { // streamRequests is the set of unanswered incoming requests for the stream. // // Requests are removed when their response has been received. + // In practice, there is only one request, but in the 2025-03-26 version of + // the spec and earlier there was a concept of batching, in which POST + // payloads could hold multiple requests or responses. requests map[jsonrpc.ID]struct{} } @@ -645,6 +652,10 @@ func (s *stream) close(reconnectAfter time.Duration) { // doneLocked reports whether the stream is logically complete. // +// s.requests was populated when reading the POST body, requests are deleted as +// they are responded to. Once all requests have been responded to, the stream +// is done. +// // s.mu must be held while calling this function. func (s *stream) doneLocked() bool { return len(s.requests) == 0 && s.id != "" @@ -751,6 +762,7 @@ func (c *streamableServerConn) serveGET(w http.ResponseWriter, req *http.Request stream.mu.Unlock() }() + // TODO(cleanup): factor out, to clarify closing patterns. select { case <-ctx.Done(): // request cancelled @@ -790,10 +802,10 @@ func (c *streamableServerConn) writeCloseEvent(w http.ResponseWriter, reconnectA } } -// acquireStream acquires the stream and replays all events since lastIdx, if -// any, updating lastIdx accordingly. If non-nil, the resulting stream will be -// registered for receiving new messages, and the resulting done channel will -// be closed when all related messages have been delivered. +// acquireStream replays all events since lastIdx, and acquires the ongoing +// stream, if any, updating lastIdx accordingly. If non-nil, the resulting +// stream will be registered for receiving new messages, and the resulting done +// channel will be closed when all related messages have been delivered. // // If any errors occur, they will be written to w and the resulting stream will // be nil. The resulting stream may also be nil if the stream is complete. @@ -803,11 +815,13 @@ func (c *streamableServerConn) writeCloseEvent(w http.ResponseWriter, reconnectA // the stream is still replaying. // // supportsPrimeClose indicates whether the client supports the prime and close -// events (protocol version 2025-11-25 or later). +// events (SEP-1699, added in protocol version 2025-11-25 or later). func (c *streamableServerConn) acquireStream(ctx context.Context, w http.ResponseWriter, streamID string, lastIdx *int, supportsPrimeClose bool) (*stream, chan struct{}) { // if tempStream is set, the stream is done and we're just replaying messages. // - // We record a temporary stream to claim exclusive replay rights. + // We record a temporary stream to claim exclusive replay rights. The spec + // (https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery) + // does not explicitly require exclusive replay, but we enforce it defensively. tempStream := false c.mu.Lock() s, ok := c.streams[streamID] @@ -896,20 +910,23 @@ func (c *streamableServerConn) acquireStream(ctx context.Context, w http.Respons // The stream is not done: register a delivery function before the stream is // unlocked, allowing the connection to write new events. var done = make(chan struct{}) + // TODO(cleanup): consolidate deliver functions, and be consistent about + // checking ctx.Err(). s.deliver = func(data []byte, final bool) error { select { case <-done: return fmt.Errorf("stream closed") + case <-ctx.Done(): + return ctx.Err() default: } - if err := ctx.Err(); err != nil { - return err - } if final { defer close(done) } return c.writeEvent(w, s.id, Event{Name: "message", Data: data}, lastIdx) } + // TODO(cleanup): consolidate closeLocked functions, and be consistent about + // checking ctx.Err(). if supportsPrimeClose { s.closeLocked = func(reconnectAfter time.Duration) { select { @@ -947,6 +964,9 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques http.Error(w, "POST requires a non-empty body", http.StatusBadRequest) return } + // TODO(#674): once we've documented the support matrix for 2025-03-26 and + // earlier, drop support for matching entirely; that will simplify this + // logic. incoming, isBatch, err := readBatch(body) if err != nil { http.Error(w, fmt.Sprintf("malformed payload: %v", err), http.StatusBadRequest) @@ -991,12 +1011,15 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques initializeProtocolVersion = params.ProtocolVersion } } + // Include metadata for all requests (including notifications). jreq.Extra = &RequestExtra{ TokenInfo: tokenInfo, Header: req.Header, } if jreq.IsCall() { calls[jreq.ID] = struct{}{} + // See the doc for CloseStream: allow the request handler to explicitly + // close the ongoing stream. jreq.Extra.(*RequestExtra).CloseStream = func(reconnectAfter time.Duration) { c.mu.Lock() streamID, ok := c.requestStreams[jreq.ID] @@ -1025,6 +1048,11 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // If we don't have any calls, we can just publish the incoming messages and return. // No need to track a logical stream. + // + // See section [§2.1.4] of the spec: "If the server accepts the input, the + // server MUST return HTTP status code 202 Accepted with no body." + // + // [§2.1.4]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server if len(calls) == 0 { for _, msg := range incoming { select { @@ -1074,6 +1102,10 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // // In recent protocol versions, there should only be one message as // batching is disabled, as checked above. + // + // Note that in [streamableClientConn.Write], any non-response message is + // sent to the standalone stream (if any), when the connection is + // configured to respond with application/json. msgs = append(msgs, data) if !final { return nil @@ -1099,7 +1131,10 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // Write events in the order we receive them. lastIndex := -1 if c.eventStore != nil && supportsPrimeClose { - // Write a priming event. + // Write a priming event, as defined by [§2.1.6] of the spec. + // + // [§2.1.6]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server + // // We must also write it to the event store in order for indexes to // align. if err := c.eventStore.Append(req.Context(), c.sessionID, stream.id, nil); err != nil { @@ -1110,6 +1145,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques } } + // TODO(cleanup): consolidate per other TODO, check ctx.Err() stream.deliver = func(data []byte, final bool) error { select { case <-done: @@ -1121,6 +1157,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques } return c.writeEvent(w, stream.id, Event{Name: "message", Data: data}, &lastIndex) } + // TODO(cleanup): consolidate per other TODO. if supportsPrimeClose { stream.closeLocked = func(reconnectAfter time.Duration) { select { @@ -1163,6 +1200,8 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // Note: don't select on req.Context().Done() here, since we've already // received the requests and may have already published a response message // or notification. The client could resume the stream. + // + // In fact, this send could be in a separate goroutine. case <-c.done: // Session closed: we don't know if any data has been written, so it's // too late to write a status code here. @@ -1170,6 +1209,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques } } + // TODO(cleanup): factor this out, per other TODO. select { case <-req.Context().Done(): // request cancelled @@ -1236,7 +1276,7 @@ func (c *streamableServerConn) Write(ctx context.Context, msg jsonrpc.Message) e return err } - if req, ok := msg.(*jsonrpc.Request); ok && req.ID.IsValid() && (c.stateless || c.sessionID == "") { + if req, ok := msg.(*jsonrpc.Request); ok && req.IsCall() && (c.stateless || c.sessionID == "") { // Requests aren't possible with stateless servers, or when there's no session ID. return fmt.Errorf("%w: stateless servers cannot make requests", jsonrpc2.ErrRejected) } @@ -1689,7 +1729,8 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e if jsonReq, ok := msg.(*jsonrpc.Request); ok && jsonReq.IsCall() { forCall = jsonReq } - // TODO: should we cancel this logical SSE request if/when jsonReq is canceled? + // TODO(cleanup): decide if we should cancel this logical SSE request + // if/when jsonReq is canceled. go c.handleSSE(ctx, requestSummary, resp, false, forCall) default: @@ -1742,7 +1783,9 @@ func (c *streamableClientConn) handleJSON(requestSummary string, resp *http.Resp // // If forCall is set, it is the call that initiated the stream, and the // stream is complete when we receive its response. -func (c *streamableClientConn) handleSSE(ctx context.Context, requestSummary string, resp *http.Response, persistent bool, forCall *jsonrpc2.Request) { +func (c *streamableClientConn) handleSSE(ctx context.Context, requestSummary string, resp *http.Response, standalone bool, forCall *jsonrpc2.Request) { + // TODO(cleanup): standalone should be completely equivalent to forCall == nil. + // Enforce that at the callsite, and remove the standalone flag argument. for { // Connection was successful. Continue the loop with the new response. // @@ -1760,7 +1803,7 @@ func (c *streamableClientConn) handleSSE(ctx context.Context, requestSummary str } // If the stream has ended, then do not reconnect if the stream is // temporary (POST initiated SSE). - if lastEventID == "" && !persistent { + if lastEventID == "" && !standalone { return } @@ -1844,6 +1887,8 @@ func (c *streamableClientConn) processStream(ctx context.Context, requestSummary select { case c.incoming <- msg: + // Check if this is the response to our call, which terminates the request. + // (it could also be a server->client request or notification). if jsonResp, ok := msg.(*jsonrpc.Response); ok && forCall != nil { // TODO: we should never get a response when forReq is nil (the standalone SSE request). // We should detect this case. diff --git a/mcp/streamable_client.go b/mcp/streamable_client.go new file mode 100644 index 0000000..41a1004 --- /dev/null +++ b/mcp/streamable_client.go @@ -0,0 +1,226 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// TODO: move client-side streamable HTTP logic from streamable.go to this file. + +package mcp + +/* +Streamable HTTP Client Design + +This document describes the client-side implementation of the MCP streamable +HTTP transport, as defined by the MCP spec: +https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http + +# Overview + +The client-side streamable transport allows an MCP client to communicate with a +server over HTTP, sending messages via POST and receiving responses via either +JSON or server-sent events (SSE). The implementation consists of two main +components: + + ┌─────────────────────────────────────────────────────────────────┐ + │ [StreamableClientTransport] │ + │ Transport configuration; creates connections via Connect() │ + └─────────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────────────────────────────────┐ + │ [streamableClientConn] │ + │ Connection implementation; handles HTTP request/response │ + └─────────────────────────────────────────────────────────────────┘ + │ + ├──────────────────────────────────────┐ + ▼ ▼ + ┌─────────────────────────────────────────┐ ┌────────────────────────────────────┐ + │ POST request handlers │ │ Standalone SSE stream │ + │ (one per outgoing message/call) │ │ (server-initiated messages) │ + └─────────────────────────────────────────┘ └────────────────────────────────────┘ + +# Sessions + +The client maintains a session with the server, identified by a session ID +(Mcp-Session-Id header): + + - Session ID is received from the server after initialization + - Client includes the session ID in all subsequent requests + - Session ends when the client calls Close() (sends DELETE) or server returns 404 + +[streamableClientConn] stores the session state: + - [streamableClientConn.sessionID]: Server-assigned session identifier + - [streamableClientConn.initializedResult]: Protocol version and server capabilities + +# Connection Lifecycle + +1. Connect: [StreamableClientTransport.Connect] creates a [streamableClientConn] + with a detached context for the connection's lifetime. The context is detached + to prevent the standalone SSE stream from being cancelled when the original + Connect context times out. + +2. Initialize: The MCP client sends initialize/initialized messages. Upon + receiving [InitializeResult], the connection: + - Stores the negotiated protocol version for the Mcp-Protocol-Version header + - Captures the session ID from the Mcp-Session-Id response header + - Starts the standalone SSE stream via [streamableClientConn.connectStandaloneSSE] + +3. Operation: Messages are sent via POST, responses received via JSON or SSE. + +4. Close: [streamableClientConn.Close] sends a DELETE request to terminate + the session (unless the session is already gone), then cancels the connection + context to clean up the standalone SSE stream. + +# Sending Messages (Write) + +[streamableClientConn.Write] sends all outgoing messages via HTTP POST: + + POST /endpoint + Content-Type: application/json + Accept: application/json, text/event-stream + Mcp-Protocol-Version: + Mcp-Session-Id: + + + +The server may respond with: + - 202 Accepted: Message received, no response body (notifications/responses) + - 200 OK with application/json: Single JSON-RPC response + - 200 OK with text/event-stream: SSE stream of responses + +# Receiving Messages (Read) + +[streamableClientConn.Read] returns messages from the [streamableClientConn.incoming] +channel, which is populated by multiple concurrent goroutines: + +1. POST response handlers ([streamableClientConn.handleJSON] and + [streamableClientConn.handleSSE]): Process responses from POST requests + +2. Standalone SSE stream: Receives server-initiated requests and notifications + +The client handles both response formats: + - JSON: [streamableClientConn.handleJSON] reads body, decodes message + - SSE: [streamableClientConn.handleSSE] scans events, decodes each message + +# Standalone SSE Stream + +After initialization, [streamableClientConn.sessionUpdated] triggers +[streamableClientConn.connectStandaloneSSE] to open a GET request for +server-initiated messages: + + GET /endpoint + Accept: text/event-stream + Mcp-Session-Id: + +Stream behavior: + - Optional: Server may return 405 Method Not Allowed (spec-compliant) or + other 4xx errors (tolerated in non-strict mode for compatibility) + - Persistent: Runs for the connection lifetime in a background goroutine + - Resumable: Uses Last-Event-ID header on reconnection if server provides event IDs + - Reconnects: Automatic reconnection with exponential backoff on interruption + +# Stream Resumption + +When an SSE stream (standalone or POST response) is interrupted, the client +attempts to reconnect using [streamableClientConn.connectSSE]: + +Event ID tracking: + - [streamableClientConn.processStream] tracks the last received event ID + - On reconnection, the Last-Event-ID header is set to resume from that point + - Server replays missed events if it has an [EventStore] configured + +See [calculateReconnectDelay] for the reconnect delay details. + +Server-initiated reconnection (SEP-1699) + - SSE retry field: Sets the delay for the next reconnect attempt + - If server doesn't provide event IDs, non-standalone streams don't reconnect + +# Response Formats + +The client must handle two response formats from POST requests: + +1. application/json: Single JSON-RPC response + - Body contains one JSON-RPC message + - Handled by [streamableClientConn.handleJSON] + - Simpler but doesn't support streaming or server-initiated messages + +2. text/event-stream: SSE stream of messages + - Body contains SSE events with JSON-RPC messages + - Handled by [streamableClientConn.handleSSE] + - Supports multiple messages and server-initiated communication + - Stream completes when the response to the originating call is received + +# HTTP Methods + + - POST: Send JSON-RPC messages (requests, responses, notifications) + - Used by [streamableClientConn.Write] + - Response may be JSON or SSE + + - GET: Open or resume SSE stream for server-initiated messages + - Used by [streamableClientConn.connectSSE] + - Always expects text/event-stream response (or 405) + + - DELETE: Terminate the session + - Used by [streamableClientConn.Close] + - Skipped if session is already known to be gone ([errSessionMissing]) + +# Error Handling + +Errors are categorized and handled differently: + +1. Transient (recoverable via reconnection): + - Network interruption during SSE streaming + - Connection reset or timeout + - Triggers reconnection in [streamableClientConn.handleSSE] + +2. Terminal (breaks the connection): + - 404 Not Found: Session terminated by server ([errSessionMissing]) + - Message decode errors: Protocol violation + - Context cancellation: Client closed connection + - Mismatched session IDs: Protocol error + - See issue #683: our terminal errors are too strict. + +Terminal errors are stored via [streamableClientConn.fail] and returned by +subsequent [streamableClientConn.Read] calls. The [streamableClientConn.failed] +channel signals that the connection is broken. + +Special case: [errSessionMissing] indicates the server has terminated the session, +so [streamableClientConn.Close] skips the DELETE request. + +# Protocol Version Header + +After initialization, all requests include: + + Mcp-Protocol-Version: + +This header (set by [streamableClientConn.setMCPHeaders]): + - Allows the server to handle requests per the negotiated protocol + - Is omitted before initialization completes + - Uses the version from [streamableClientConn.initializedResult] + +# Key Implementation Details + +[StreamableClientTransport] configuration: + - [StreamableClientTransport.Endpoint]: URL of the MCP server + - [StreamableClientTransport.HTTPClient]: Custom HTTP client (optional) + - [StreamableClientTransport.MaxRetries]: Reconnection attempts (default 5) + +[streamableClientConn] handles the [Connection] interface: + - [streamableClientConn.Read]: Returns messages from incoming channel + - [streamableClientConn.Write]: Sends messages via POST, starts response handlers + - [streamableClientConn.Close]: Sends DELETE, cancels context, closes done channel + +State management: + - [streamableClientConn.incoming]: Buffered channel for received messages + - [streamableClientConn.sessionID]: Server-assigned session identifier + - [streamableClientConn.initializedResult]: Cached for protocol version header + - [streamableClientConn.failed]: Channel closed on terminal error + - [streamableClientConn.done]: Channel closed on graceful shutdown + - [streamableClientConn.ctx]: Detached context for connection lifetime + - [streamableClientConn.cancel]: Cancels ctx to terminate SSE streams + +Context handling: + - Connection context is detached from [StreamableClientTransport.Connect] context + using [xcontext.Detach] to preserve context values (for auth middleware) while + preventing premature cancellation of the standalone SSE stream + - Individual POST requests use caller-provided contexts for cancellation +*/ diff --git a/mcp/streamable_server.go b/mcp/streamable_server.go new file mode 100644 index 0000000..120931c --- /dev/null +++ b/mcp/streamable_server.go @@ -0,0 +1,153 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// TODO: move server-side streamable HTTP logic from streamable.go to this file. + +package mcp + +/* +Streamable HTTP Server Design + +This document describes the server-side implementation of the MCP streamable +HTTP transport, as defined by the MCP spec: +https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http + +# Overview + +The streamable HTTP transport enables MCP communication over HTTP, with +server-sent events (SSE) for server-to-client messages. The implementation +consists of several layered components: + + ┌─────────────────────────────────────────────────────────────────┐ + │ [StreamableHTTPHandler] │ + │ http.Handler that manages sessions and routes HTTP requests │ + └─────────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────────────────────────────────┐ + │ [StreamableServerTransport] │ + │ transport implementation, one per session; exposes ServeHTTP │ + └─────────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────────────────────────────────┐ + │ [streamableServerConn] │ + │ Connection implementation, handles message routing │ + └─────────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────────────────────────────────┐ + │ [stream] │ + │ Logical message channel within a session, may be resumed │ + └─────────────────────────────────────────────────────────────────┘ + +# Sessions + +As with other transports, a session represents a logical MCP connection between +a client and server. In the streamable transport, sessions are identified by a +unique session ID (Mcp-Session-Id header) and persist across multiple HTTP +requests. + +[StreamableHTTPHandler] maintains a map of active sessions ([sessionInfo]), +each containing: + - The [ServerSession] (MCP-level session state) + - The [StreamableServerTransport] (for message I/O) + - Optional timeout management for idle session cleanup + +Sessions are created on the first POST request (typically containing the +initialize request) and destroyed either by: + - Client sending a DELETE request + - Session timeout due to inactivity + - Server explicitly closing the session + +# Streams + +Within a session, there can be multiple concurrent "streams" - logical channels +for message delivery. This is distinct from HTTP streams; a single [stream] may +span multiple HTTP request/response cycles (via resumption). + +There are two types of streams: + +1. Optional standalone SSE stream (id = ""): + - Created when client sends a GET request to the endpoint + - Used for server-initiated messages (requests/notifications to client) + - Persists for the lifetime of the session + - Only one standalone stream per session + +2. Request streams (id = random string): + - Created for each POST request containing JSON-RPC calls + - Used to route responses back to the originating HTTP request + - Completed when all responses have been sent + - Can be resumed via GET with Last-Event-ID if interrupted + +# Message Routing + +When the server writes a message, it must be routed to the correct [stream]: + + - Responses: Routed to the stream that originated the request + - Requests/Notifications made during request handling: Routed to the same + stream as the triggering request (via context) + - Requests/Notifications made outside request handling: Routed to the + standalone SSE stream + +This routing is implemented using: + - [streamableServerConn.requestStreams] maps request IDs to stream IDs + - [idContextKey] is used to store the originating request ID in Context + - [streamableServerConn.streams] maps stream IDs to [stream] objects + +# Stream Resumption + +If an HTTP connection is interrupted (network issues, etc.), clients can +resume a stream by sending a GET request with the Last-Event-ID header. +This requires an [EventStore] to be configured on the server. + + - [EventStore.Open] is called when a new stream is created + - [EventStore.Append] is called for each message written to the stream + - [EventStore.After] is called to replay messages after a given index + - [EventStore.SessionClosed] is called when the session ends + +Event IDs are formatted as "_" to identify both the +stream and position within that stream (see [formatEventID] and [parseEventID]). + +# Stateless Mode + +For simpler deployments, the handler supports "stateless" mode +([StreamableHTTPOptions.Stateless]) where: + - No session ID validation is performed + - Each request creates a temporary session that's closed after the request + - Server-to-client requests are not supported (no way to receive response) + +This mode is useful for simple tool servers that don't need bidirectional +communication. + +# Response Formats + +The server can respond to POST requests in two formats: + +1. text/event-stream (default): Messages sent as SSE events, supports + streaming multiple messages and server-initiated communication during + request handling. + +2. application/json ([StreamableHTTPOptions.JSONResponse]): Single JSON + response, simpler but doesn't support streaming. Server-initiated messages + during request handling go to the standalone SSE stream instead. + +# HTTP Methods + + - POST: Send JSON-RPC messages (requests, responses, notifications) + - GET: Open standalone SSE stream or resume an interrupted stream + - DELETE: Terminate the session + +# Key Implementation Details + +The [stream] struct manages delivery of messages to HTTP responses: + - [stream.deliver], if set, writes data to the current HTTP response + - [stream.closeLocked] sends a close event with retry delay + - [stream.requests] tracks pending request IDs (stream completes when empty) + +[streamableServerConn] handles the [Connection] interface: + - [streamableServerConn.Read] receives messages from the incoming channel (fed by POST handlers) + - [streamableServerConn.Write] routes messages to appropriate streams + - [streamableServerConn.Close] terminates the session and notifies the [EventStore] +*/ From e69fd7ba252240e4107ea25f2f1996e3fda2b45c Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Wed, 10 Dec 2025 12:55:40 -0500 Subject: [PATCH 145/418] mcp: simplify stream state management in streamable transport Replace closure-based deliver and closeLocked fields with explicit state: - pendingJSONMessages: signals JSON mode and collects messages - w: indicates stream is claimed by HTTP request - done: signals stream completion (non-nil implies w is non-nil) - lastIdx, supportsPrimeClose, logger: moved from closures to fields Add stream methods to consolidate logic: - close: sends close event and closes done channel - release: releases stream from HTTP request for resumption - deliverLocked: writes/stores messages, handles request accounting Factor out hangResponse to document HTTP response lifecycle. Inline writeEvent to eliminate confusing pointer-based index management. --- mcp/streamable.go | 394 ++++++++++++++++++--------------------- mcp/streamable_server.go | 13 +- 2 files changed, 194 insertions(+), 213 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 059a1a7..b74b338 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -618,22 +618,39 @@ type stream struct { // The standalone SSE stream has id "". id string + // logger is used for logging errors during stream operations. + logger *slog.Logger + // mu guards the fields below, as well as storage of new messages in the // connection's event store (if any). mu sync.Mutex - // If non-nil, deliver writes data directly to the HTTP response. + // If pendingJSONMessages is non-nil, this is a JSON stream and messages are + // collected here until the stream is complete, at which point they are + // flushed as a single JSON response. + pendingJSONMessages []json.RawMessage + + // w is the HTTP response writer for this stream. A non-nil w indicates + // that the stream is claimed by an HTTP request; it is set to nil when + // the request completes. + w http.ResponseWriter + + // done is closed to release the hanging HTTP request. // - // Only one HTTP response may receive messages at a given time. An active - // HTTP connection acquires ownership of the stream by setting this field. - deliver func(data []byte, final bool) error + // Invariant: a non-nil done implies w is also non-nil, though the converse + // is not necessarily true: done is set to nil when it is closed, to avoid + // duplicate closure. + done chan struct{} - // closeLocked sends a 'close' event to the client with a configurable retry - // delay, if there is a delivery channel available. The stream must be - // locked. - closeLocked func(reconnectAfter time.Duration) + // lastIdx is the index of the last written SSE event, for event ID generation. + // It starts at -1 since indices start at 0. + lastIdx int - // streamRequests is the set of unanswered incoming requests for the stream. + // supportsPrimeClose indicates whether the client supports prime and close + // events (protocol version 2025-11-25 or later). + supportsPrimeClose bool + + // requests is the set of unanswered incoming requests for the stream. // // Requests are removed when their response has been received. // In practice, there is only one request, but in the 2025-03-26 version of @@ -642,12 +659,92 @@ type stream struct { requests map[jsonrpc.ID]struct{} } +// close sends a 'close' event to the client (if supportsPrimeClose is true and +// reconnectAfter > 0) and closes the done channel. +// +// The done channel is set to nil after closing, so that done != nil implies +// the stream is active and done is open. This simplifies checks elsewhere. func (s *stream) close(reconnectAfter time.Duration) { s.mu.Lock() defer s.mu.Unlock() - if s.closeLocked != nil { - s.closeLocked(reconnectAfter) + if s.done == nil { + return // stream not connected or already closed } + if s.supportsPrimeClose && reconnectAfter > 0 { + reconnectStr := strconv.FormatInt(reconnectAfter.Milliseconds(), 10) + if _, err := writeEvent(s.w, Event{ + Name: "close", + Retry: reconnectStr, + }); err != nil { + s.logger.Warn(fmt.Sprintf("Writing close event: %v", err)) + } + } + close(s.done) + s.done = nil +} + +// release releases the stream from its HTTP request, allowing it to be +// claimed by another request (e.g., for resumption). +func (s *stream) release() { + s.mu.Lock() + defer s.mu.Unlock() + s.w = nil + s.done = nil // may already be nil, if the stream is done or closed +} + +// deliverLocked writes data to the stream (for SSE) or stores it in +// pendingJSONMessages (for JSON mode). The eventID is used for SSE event ID; +// pass "" to omit. +// +// If responseTo is valid, it is removed from the requests map. When all +// requests have been responded to, the done channel is closed and set to nil. +// +// Returns true if the stream is now done (all requests have been responded to). +// The done value is always accurate, even if an error is returned. +// +// s.mu must be held when calling this method. +func (s *stream) deliverLocked(data []byte, eventID string, responseTo jsonrpc.ID) (done bool, err error) { + if responseTo.IsValid() { + delete(s.requests, responseTo) + } + done = len(s.requests) == 0 && s.id != "" + if s.done == nil { + return done, fmt.Errorf("stream not connected or already closed") + } + if done { + defer func() { close(s.done); s.done = nil }() + } + // Try to write to the response. + // + // If we get here, the request is still hanging (because s.done != nil + // implies s.w != nil), but may have been cancelled by the client/http layer: + // there's a brief race between request cancellation and releasing the + // stream. + if s.pendingJSONMessages != nil { + s.pendingJSONMessages = append(s.pendingJSONMessages, data) + if done { + // Flush all pending messages as JSON response. + var toWrite []byte + if len(s.pendingJSONMessages) == 1 { + toWrite = s.pendingJSONMessages[0] + } else { + toWrite, err = json.Marshal(s.pendingJSONMessages) + if err != nil { + return done, err + } + } + if _, err := s.w.Write(toWrite); err != nil { + return done, err + } + } + } else { + // SSE mode: write event to response writer. + s.lastIdx++ + if _, err := writeEvent(s.w, Event{Name: "message", Data: data, ID: eventID}); err != nil { + return done, err + } + } + return done, nil } // doneLocked reports whether the stream is logically complete. @@ -670,6 +767,8 @@ func (c *streamableServerConn) newStream(ctx context.Context, requests map[jsonr return &stream{ id: id, requests: requests, + lastIdx: -1, // indices start at 0, incremented before each write + logger: c.logger, }, nil } @@ -739,6 +838,8 @@ func (c *streamableServerConn) serveGET(w http.ResponseWriter, req *http.Request } } + // TODO(cleanup): why are we creating a cancellable context here? + // Isn't the context lifecycle already tied to the request? ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -750,62 +851,33 @@ func (c *streamableServerConn) serveGET(w http.ResponseWriter, req *http.Request } supportsPrimeClose := protocolVersion >= protocolVersion20251125 - stream, done := c.acquireStream(ctx, w, streamID, &lastIdx, supportsPrimeClose) + stream, done := c.acquireStream(ctx, w, streamID, lastIdx, supportsPrimeClose) if stream == nil { return } - // Release the stream when we're done. - defer func() { - stream.mu.Lock() - stream.deliver = nil - stream.closeLocked = nil - stream.mu.Unlock() - }() + defer stream.release() + c.hangResponse(ctx, done) +} - // TODO(cleanup): factor out, to clarify closing patterns. +// hangResponse blocks the HTTP response until one of three conditions is met: +// - ctx is cancelled (the client disconnected or the request timed out) +// - done is closed (all responses have been sent, or the stream was explicitly closed) +// - the session is closed +// +// This keeps the HTTP connection open so that server-sent events can be +// written to the response. +func (c *streamableServerConn) hangResponse(ctx context.Context, done <-chan struct{}) { select { case <-ctx.Done(): - // request cancelled case <-done: - // request complete case <-c.done: - // session closed - } -} - -// writeEvent writes an SSE event to w corresponding to the given stream, data, and index. -// lastIdx is incremented before writing, so that it continues to point to the index of the -// last event written to the stream. -func (c *streamableServerConn) writeEvent(w http.ResponseWriter, streamID string, e Event, lastIdx *int) error { - *lastIdx++ - if c.eventStore != nil { - // TODO(rfindley): this isn't quite right: we should only set the ID if the - // message was actually stored successfully. - e.ID = formatEventID(streamID, *lastIdx) - } - if _, err := writeEvent(w, e); err != nil { - return err - } - return nil -} - -// writeCloseEvent writes a 'close' event to the stream, signaling to the -// caller that they should reconnect after the configured delay. -func (c *streamableServerConn) writeCloseEvent(w http.ResponseWriter, reconnectAfter time.Duration) { - reconnectStr := strconv.FormatInt(reconnectAfter.Milliseconds(), 10) - // Note: this event is not stored, since we don't want or need to replay it. - if _, err := writeEvent(w, Event{ - Name: "close", // don't make this empty, as the default event type is "message" - Retry: reconnectStr, - }); err != nil { - c.logger.Warn(fmt.Sprintf("Writing close event: %v", err)) } } // acquireStream replays all events since lastIdx, and acquires the ongoing -// stream, if any, updating lastIdx accordingly. If non-nil, the resulting -// stream will be registered for receiving new messages, and the resulting done -// channel will be closed when all related messages have been delivered. +// stream, if any. If non-nil, the resulting stream will be registered for +// receiving new messages, and the stream's done channel will be closed when +// all related messages have been delivered. // // If any errors occur, they will be written to w and the resulting stream will // be nil. The resulting stream may also be nil if the stream is complete. @@ -816,7 +888,7 @@ func (c *streamableServerConn) writeCloseEvent(w http.ResponseWriter, reconnectA // // supportsPrimeClose indicates whether the client supports the prime and close // events (SEP-1699, added in protocol version 2025-11-25 or later). -func (c *streamableServerConn) acquireStream(ctx context.Context, w http.ResponseWriter, streamID string, lastIdx *int, supportsPrimeClose bool) (*stream, chan struct{}) { +func (c *streamableServerConn) acquireStream(ctx context.Context, w http.ResponseWriter, streamID string, lastIdx int, supportsPrimeClose bool) (*stream, chan struct{}) { // if tempStream is set, the stream is done and we're just replaying messages. // // We record a temporary stream to claim exclusive replay rights. The spec @@ -829,12 +901,12 @@ func (c *streamableServerConn) acquireStream(ctx context.Context, w http.Respons // The stream is logically done, but claim exclusive rights to replay it by // adding a temporary entry in the streams map. // - // We create this entry with a non-nil deliver function, to ensure it isn't - // claimed by another request before we lock it below. + // We create this entry with a non-nil w, to ensure it isn't claimed by + // another request before we lock it below. tempStream = true s = &stream{ - id: streamID, - deliver: func([]byte, bool) error { return nil }, + id: streamID, + w: w, } c.streams[streamID] = s @@ -851,7 +923,7 @@ func (c *streamableServerConn) acquireStream(ctx context.Context, w http.Respons defer s.mu.Unlock() // Check that this stream wasn't claimed by another request. - if !tempStream && s.deliver != nil { + if !tempStream && s.w != nil { http.Error(w, "stream ID conflicts with ongoing stream", http.StatusConflict) return nil, nil } @@ -864,7 +936,7 @@ func (c *streamableServerConn) acquireStream(ctx context.Context, w http.Respons // messages, and registered our delivery function. var toReplay [][]byte if c.eventStore != nil { - for data, err := range c.eventStore.After(ctx, c.SessionID(), s.id, *lastIdx) { + for data, err := range c.eventStore.After(ctx, c.SessionID(), s.id, lastIdx) { if err != nil { // We can't replay events, perhaps because the underlying event store // has garbage collected its storage. @@ -897,7 +969,12 @@ func (c *streamableServerConn) acquireStream(ctx context.Context, w http.Respons } for _, data := range toReplay { - if err := c.writeEvent(w, s.id, Event{Name: "message", Data: data}, lastIdx); err != nil { + lastIdx++ + e := Event{Name: "message", Data: data} + if c.eventStore != nil { + e.ID = formatEventID(s.id, lastIdx) + } + if _, err := writeEvent(w, e); err != nil { return nil, nil } } @@ -907,40 +984,13 @@ func (c *streamableServerConn) acquireStream(ctx context.Context, w http.Respons return nil, nil } - // The stream is not done: register a delivery function before the stream is + // The stream is not done: set up delivery state before the stream is // unlocked, allowing the connection to write new events. - var done = make(chan struct{}) - // TODO(cleanup): consolidate deliver functions, and be consistent about - // checking ctx.Err(). - s.deliver = func(data []byte, final bool) error { - select { - case <-done: - return fmt.Errorf("stream closed") - case <-ctx.Done(): - return ctx.Err() - default: - } - if final { - defer close(done) - } - return c.writeEvent(w, s.id, Event{Name: "message", Data: data}, lastIdx) - } - // TODO(cleanup): consolidate closeLocked functions, and be consistent about - // checking ctx.Err(). - if supportsPrimeClose { - s.closeLocked = func(reconnectAfter time.Duration) { - select { - case <-done: - return - default: - } - if reconnectAfter > 0 { - c.writeCloseEvent(w, reconnectAfter) - } - close(done) - } - } - return s, done + s.w = w + s.done = make(chan struct{}) + s.lastIdx = lastIdx + s.supportsPrimeClose = supportsPrimeClose + return s, s.done } // servePOST handles an incoming message, and replies with either an outgoing @@ -1092,44 +1142,16 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques w.Header().Set(sessionIDHeader, c.sessionID) } - // Message delivery has two paths, depending on whether we're responding with JSON or - // event stream. - done := make(chan struct{}) // closed after the final response is written + // Set up stream delivery state. + stream.w = w + done := make(chan struct{}) + stream.done = done + stream.supportsPrimeClose = supportsPrimeClose if c.jsonResponse { - var msgs []json.RawMessage - stream.deliver = func(data []byte, final bool) error { - // Collect messages until we've received the final response. - // - // In recent protocol versions, there should only be one message as - // batching is disabled, as checked above. - // - // Note that in [streamableClientConn.Write], any non-response message is - // sent to the standalone stream (if any), when the connection is - // configured to respond with application/json. - msgs = append(msgs, data) - if !final { - return nil - } - defer close(done) // final response - - // Write either the JSON object corresponding to the one response, or a - // JSON array corresponding to the batch response. - var toWrite []byte - if len(msgs) == 1 { - toWrite = []byte(msgs[0]) - } else { - var err error - toWrite, err = json.Marshal(msgs) - if err != nil { - return err - } - } - _, err = w.Write(toWrite) - return err - } + // JSON mode: collect messages in pendingJSONMessages until done. + stream.pendingJSONMessages = []json.RawMessage{} } else { - // Write events in the order we receive them. - lastIndex := -1 + // SSE mode: write a priming event if supported. if c.eventStore != nil && supportsPrimeClose { // Write a priming event, as defined by [§2.1.6] of the spec. // @@ -1140,48 +1162,17 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques if err := c.eventStore.Append(req.Context(), c.sessionID, stream.id, nil); err != nil { c.logger.Warn(fmt.Sprintf("Storing priming event: %v", err)) } - if err := c.writeEvent(w, stream.id, Event{Name: "prime"}, &lastIndex); err != nil { + stream.lastIdx++ + e := Event{Name: "prime", ID: formatEventID(stream.id, stream.lastIdx)} + if _, err := writeEvent(w, e); err != nil { c.logger.Warn(fmt.Sprintf("Writing priming event: %v", err)) } } - - // TODO(cleanup): consolidate per other TODO, check ctx.Err() - stream.deliver = func(data []byte, final bool) error { - select { - case <-done: - return fmt.Errorf("stream closed") - default: - } - if final { - defer close(done) - } - return c.writeEvent(w, stream.id, Event{Name: "message", Data: data}, &lastIndex) - } - // TODO(cleanup): consolidate per other TODO. - if supportsPrimeClose { - stream.closeLocked = func(reconnectAfter time.Duration) { - select { - case <-done: - return - default: - } - if reconnectAfter > 0 { - c.writeCloseEvent(w, reconnectAfter) - } - close(done) - } - } } - // Release ownership of the stream by unsetting deliver. - defer func() { - stream.mu.Lock() - // TODO(rfindley): if we have no event store, we should really cancel all - // remaining requests here, since the client will never get the results. - stream.deliver = nil - stream.closeLocked = nil - stream.mu.Unlock() - }() + // TODO(rfindley): if we have no event store, we should really cancel all + // remaining requests here, since the client will never get the results. + defer stream.release() // The stream is now set up to deliver messages. // @@ -1209,15 +1200,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques } } - // TODO(cleanup): factor this out, per other TODO. - select { - case <-req.Context().Done(): - // request cancelled - case <-done: - // request complete - case <-c.done: - // session is closed - } + c.hangResponse(req.Context(), done) } // Event IDs: encode both the logical connection ID and the index, as @@ -1337,35 +1320,12 @@ func (c *streamableServerConn) Write(ctx context.Context, msg jsonrpc.Message) e s.mu.Lock() defer s.mu.Unlock() - if s.doneLocked() { - // It's possible that the stream was completed in between getting s above, - // and acquiring the stream lock. In order to avoid acquiring s.mu while - // holding c.mu, we check the terminal condition again. - return fmt.Errorf("%w: write to closed stream", jsonrpc2.ErrRejected) - } - // Perform accounting on responses. - if responseTo.IsValid() { - if _, ok := s.requests[responseTo]; !ok { - panic(fmt.Sprintf("internal error: stream %v: response to untracked request %v", s.id, responseTo)) - } - if s.id == "" { - // This should be guaranteed not to happen by the stream resolution logic - // above, but be defensive: we don't ever want to delete the standalone - // stream. - panic("internal error: response on standalone stream") - } - delete(s.requests, responseTo) - if len(s.requests) == 0 { - c.mu.Lock() - delete(c.streams, s.id) - c.mu.Unlock() - } - } - delivered := false - var errs []error + // Store in eventStore before delivering. // TODO(rfindley): we should only append if the response is SSE, not JSON, by // pushing down into the delivery layer. + delivered := false + var errs []error if c.eventStore != nil { if err := c.eventStore.Append(ctx, c.sessionID, s.id, data); err != nil { errs = append(errs, err) @@ -1373,13 +1333,27 @@ func (c *streamableServerConn) Write(ctx context.Context, msg jsonrpc.Message) e delivered = true } } - if s.deliver != nil { - if err := s.deliver(data, s.doneLocked()); err != nil { - errs = append(errs, err) - } else { - delivered = true - } + + // Compute eventID for SSE streams with event store. + // Use s.lastIdx + 1 because deliverLocked increments before writing. + var eventID string + if c.eventStore != nil { + eventID = formatEventID(s.id, s.lastIdx+1) } + + done, err := s.deliverLocked(data, eventID, responseTo) + if err != nil { + errs = append(errs, err) + } else { + delivered = true + } + + if done { + c.mu.Lock() + delete(c.streams, s.id) + c.mu.Unlock() + } + if !delivered { return fmt.Errorf("%w: undelivered message: %v", jsonrpc2.ErrRejected, errors.Join(errs...)) } diff --git a/mcp/streamable_server.go b/mcp/streamable_server.go index 120931c..8a573e5 100644 --- a/mcp/streamable_server.go +++ b/mcp/streamable_server.go @@ -141,11 +141,18 @@ The server can respond to POST requests in two formats: # Key Implementation Details -The [stream] struct manages delivery of messages to HTTP responses: - - [stream.deliver], if set, writes data to the current HTTP response - - [stream.closeLocked] sends a close event with retry delay +The [stream] struct manages delivery of messages to HTTP responses. + +Fields: + - [stream.w] is the ResponseWriter for the current HTTP response (non-nil indicates claimed) + - [stream.done] is closed to release the hanging HTTP request - [stream.requests] tracks pending request IDs (stream completes when empty) +Methods: + - [stream.deliverLocked] delivers a message to the stream + - [stream.close] sends a close event and releases the stream + - [stream.release] releases the stream from the HTTP request, allowing resumption + [streamableServerConn] handles the [Connection] interface: - [streamableServerConn.Read] receives messages from the incoming channel (fed by POST handlers) - [streamableServerConn.Write] routes messages to appropriate streams From 6848e36ef0826dd96b859954d85a7fa7950308d6 Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Wed, 10 Dec 2025 13:38:38 -0500 Subject: [PATCH 146/418] mcp: finish cleanup notes in streamable.go Address remaining TODO(cleanup) comments in streamable.go: eliminate a pointless context cancellation, and simplify forCall logic. --- mcp/streamable.go | 63 +++++++++++++++++++++++++---------------------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index b74b338..c19b255 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -4,7 +4,7 @@ // NOTE: see streamable_server.go and streamable_client.go for detailed // documentation of the streamable server design. -// TODO(cleanup): move the client and server logic into those files. +// TODO: move the client and server logic into those files. package mcp @@ -838,10 +838,7 @@ func (c *streamableServerConn) serveGET(w http.ResponseWriter, req *http.Request } } - // TODO(cleanup): why are we creating a cancellable context here? - // Isn't the context lifecycle already tied to the request? - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() + ctx := req.Context() // Read the protocol version from the header. For GET requests, this should // always be present since GET only happens after initialization. @@ -1569,7 +1566,7 @@ func (c *streamableClientConn) connectStandaloneSSE() { c.fail(err) return } - go c.handleSSE(c.ctx, summary, resp, true, nil) + go c.handleSSE(c.ctx, summary, resp, nil) } // fail handles an asynchronous error while reading. @@ -1628,11 +1625,13 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e } var requestSummary string - var isCall bool + var forCall *jsonrpc.Request switch msg := msg.(type) { case *jsonrpc.Request: requestSummary = fmt.Sprintf("sending %q", msg.Method) - isCall = msg.IsCall() + if msg.IsCall() { + forCall = msg + } case *jsonrpc.Response: requestSummary = fmt.Sprintf("sending jsonrpc response #%d", msg.ID) default: @@ -1674,22 +1673,26 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e return fmt.Errorf("mismatching session IDs %q and %q", hadSessionID, sessionID) } } - // TODO(rfindley): this logic isn't quite right. - // We should keep going even if the server returns 202, if we have a call. - if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusAccepted { + + if forCall == nil { + resp.Body.Close() + // [§2.1.4]: "If the input is a JSON-RPC response or notification: // If the server accepts the input, the server MUST return HTTP status code 202 Accepted with no body." // // [§2.1.4]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#listening-for-messages-from-the-server - resp.Body.Close() - return nil - } else if !isCall && !c.strict { - // Some servers return 200, even with an empty json body. - // Ignore this response in non-strict mode. - if c.logger != nil { - c.logger.Warn(fmt.Sprintf("unexpected status code %d from non-call", resp.StatusCode)) + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusAccepted { + errMsg := fmt.Sprintf("unexpected status code %d from non-call", resp.StatusCode) + // Some servers return 200, even with an empty json body. + // + // In strict mode, return an error to the caller. + if c.logger != nil { + c.logger.Warn(errMsg) + } + if c.strict { + return errors.New(errMsg) + } } - resp.Body.Close() return nil } @@ -1703,9 +1706,11 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e if jsonReq, ok := msg.(*jsonrpc.Request); ok && jsonReq.IsCall() { forCall = jsonReq } - // TODO(cleanup): decide if we should cancel this logical SSE request - // if/when jsonReq is canceled. - go c.handleSSE(ctx, requestSummary, resp, false, forCall) + // Handle the resulting stream. Note that ctx comes from the call, and + // therefore is already cancelled when the JSON-RPC request is cancelled + // (or rather, context cancellation is what *triggers* JSON-RPC + // cancellation) + go c.handleSSE(ctx, requestSummary, resp, forCall) default: resp.Body.Close() @@ -1756,10 +1761,9 @@ func (c *streamableClientConn) handleJSON(requestSummary string, resp *http.Resp // persistent (for the main GET listener) or temporary (for a POST response). // // If forCall is set, it is the call that initiated the stream, and the -// stream is complete when we receive its response. -func (c *streamableClientConn) handleSSE(ctx context.Context, requestSummary string, resp *http.Response, standalone bool, forCall *jsonrpc2.Request) { - // TODO(cleanup): standalone should be completely equivalent to forCall == nil. - // Enforce that at the callsite, and remove the standalone flag argument. +// stream is complete when we receive its response. Otherwise, this is the +// standalone stream. +func (c *streamableClientConn) handleSSE(ctx context.Context, requestSummary string, resp *http.Response, forCall *jsonrpc2.Request) { for { // Connection was successful. Continue the loop with the new response. // @@ -1775,9 +1779,10 @@ func (c *streamableClientConn) handleSSE(ctx context.Context, requestSummary str if clientClosed { return } - // If the stream has ended, then do not reconnect if the stream is - // temporary (POST initiated SSE). - if lastEventID == "" && !standalone { + // If we don't have a last event ID, we can never get the call response, so + // there's nothing to resume. For the standalone stream, we can reconnect, + // but we may just miss messages. + if lastEventID == "" && forCall != nil { return } From b012d0824e9c94c3c6174bb73627dce640dda88f Mon Sep 17 00:00:00 2001 From: John Howard Date: Wed, 10 Dec 2025 13:45:36 -0800 Subject: [PATCH 147/418] client: allow re-using connections in more cases I am seeing a bug where connections with a Streamable HTTP client are not reused at all. This makes performance terrible, since each request establishes a new TCP connection. I was able to root cause this down to behavior of the `processStream` function. This sets up a bufio.NewScanner, and waits to get data. Once we get a message, we abort and *close the connection*. At this point, there are two paths: * The bufio reader read the *full* response body to EOF, and then we closed. This case is fine; we can re-use the idle connection. * The bufio reader *did not* real the full response body. It may have read the entire actual data that *would be sent*, but since its streaming happened to not yet be delivered. In this case, we call `body.Close()` before an EOF, which causes the HTTP client to not attempt to re-use the connection. In my case, the server I am using reliable sends the response over 2 TCP packets (one with the event, and the other with the closure of the `chunked` encoding (`0`)). This causes the body to never be fully read. This PR fixes that case by unconditionally reading the full body before we close the connection. With this change, I am able to reliably re-use connections for future requests. --- mcp/streamable.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index c19b255..a24d96e 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1835,7 +1835,11 @@ func (c *streamableClientConn) checkResponse(requestSummary string, resp *http.R // indicating if the connection was closed by the client. If resp is nil, it // returns "", false. func (c *streamableClientConn) processStream(ctx context.Context, requestSummary string, resp *http.Response, forCall *jsonrpc.Request) (lastEventID string, reconnectDelay time.Duration, clientClosed bool) { - defer resp.Body.Close() + defer func() { + // Drain any remaining unprocessed body. This allows the connection to be re-used after closing. + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + }() for evt, err := range scanEvents(resp.Body) { if err != nil { if ctx.Err() != nil { From 6f6e196c27aa0b7aae022aebfcc8abb58eb1249e Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Wed, 10 Dec 2025 16:50:39 -0500 Subject: [PATCH 148/418] mcp: fix stray log.Printf in notifySessions I encountered these logs, and they were distracting and noisy. We shouldn't log to stderr unless configured by the user. Thread through the logger. Also, use ensureLogger in the streamable client to simplify call sites. Updates #708 Updates #684 --- mcp/client.go | 5 ++++- mcp/server.go | 4 ++-- mcp/shared.go | 8 ++++---- mcp/streamable.go | 10 +++------- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index 7349ba9..b862c79 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "iter" + "log/slog" "slices" "strings" "sync" @@ -26,6 +27,7 @@ import ( type Client struct { impl *Implementation opts ClientOptions + logger *slog.Logger // TODO: file proposal to export this mu sync.Mutex roots *featureSet[*Root] sessions []*ClientSession @@ -46,6 +48,7 @@ func NewClient(impl *Implementation, opts *ClientOptions) *Client { } c := &Client{ impl: impl, + logger: ensureLogger(nil), // ensure we have a logger roots: newFeatureSet(func(r *Root) string { return r.URI }), sendingMethodHandler_: defaultSendingMethodHandler, receivingMethodHandler_: defaultReceivingMethodHandler[*ClientSession], @@ -341,7 +344,7 @@ func changeAndNotify[P Params](c *Client, notification string, params P, change sessions = slices.Clone(c.sessions) } c.mu.Unlock() - notifySessions(sessions, notification, params) + notifySessions(sessions, notification, params, c.logger) } func (c *Client) listRoots(_ context.Context, req *ListRootsRequest) (*ListRootsResult, error) { diff --git a/mcp/server.go b/mcp/server.go index d431722..5f5a4a5 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -508,7 +508,7 @@ func (s *Server) changeAndNotify(notification string, params Params, change func sessions = slices.Clone(s.sessions) } s.mu.Unlock() - notifySessions(sessions, notification, params) + notifySessions(sessions, notification, params, s.opts.Logger) } // Sessions returns an iterator that yields the current set of server sessions. @@ -708,7 +708,7 @@ func (s *Server) ResourceUpdated(ctx context.Context, params *ResourceUpdatedNot subscribedSessions := s.resourceSubscriptions[params.URI] sessions := slices.Collect(maps.Keys(subscribedSessions)) s.mu.Unlock() - notifySessions(sessions, notificationResourceUpdated, params) + notifySessions(sessions, notificationResourceUpdated, params, s.opts.Logger) s.opts.Logger.Info("resource updated notification sent", "uri", params.URI, "subscriber_count", len(sessions)) return nil } diff --git a/mcp/shared.go b/mcp/shared.go index f19236f..c2e50d9 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -15,7 +15,7 @@ import ( "context" "encoding/json" "fmt" - "log" + "log/slog" "net/http" "reflect" "slices" @@ -381,7 +381,8 @@ const ( // notifySessions calls Notify on all the sessions. // Should be called on a copy of the peer sessions. -func notifySessions[S Session, P Params](sessions []S, method string, params P) { +// The logger must be non-nil. +func notifySessions[S Session, P Params](sessions []S, method string, params P, logger *slog.Logger) { if sessions == nil { return } @@ -394,8 +395,7 @@ func notifySessions[S Session, P Params](sessions []S, method string, params P) for _, s := range sessions { req := newRequest(s, params) if err := handleNotify(ctx, method, req); err != nil { - // TODO(#218): surface this error better - log.Printf("calling %s: %v", method, err) + logger.Warn(fmt.Sprintf("calling %s: %v", method, err)) } } } diff --git a/mcp/streamable.go b/mcp/streamable.go index a24d96e..d6452b2 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1453,7 +1453,7 @@ func (t *StreamableClientTransport) Connect(ctx context.Context) (Connection, er done: make(chan struct{}), maxRetries: maxRetries, strict: t.strict, - logger: t.logger, + logger: ensureLogger(t.logger), // must be non-nil for safe logging ctx: connCtx, cancel: cancel, failed: make(chan struct{}), @@ -1555,9 +1555,7 @@ func (c *streamableClientConn) connectStandaloneSSE() { // stream. // // Treat this like MethodNotAllowed in non-strict mode. - if c.logger != nil { - c.logger.Warn(fmt.Sprintf("got %d instead of 405 for standalone SSE stream", resp.StatusCode)) - } + c.logger.Warn(fmt.Sprintf("got %d instead of 405 for standalone SSE stream", resp.StatusCode)) resp.Body.Close() return } @@ -1686,9 +1684,7 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e // Some servers return 200, even with an empty json body. // // In strict mode, return an error to the caller. - if c.logger != nil { - c.logger.Warn(errMsg) - } + c.logger.Warn(errMsg) if c.strict { return errors.New(errMsg) } From 307e32c2b3a66bb93d1ad5825e3c76825e8f5281 Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Tue, 9 Dec 2025 15:06:37 -0500 Subject: [PATCH 149/418] oauthex: validate URL schemes in auth server metadata and DCR Use URL scheme validation for remaining URL fields in auth datastructures (AuthServerMeta and ClientRegistrationMetadata), to prevent XSS attacks. By comparison, the typescript SDK doesn't validate introspection_endpoint, and I don't know why. Otherwise the implementations align. Fixes #526 --- oauthex/auth_meta.go | 35 +++++ oauthex/dcr.go | 38 +++++ oauthex/url_scheme_test.go | 314 +++++++++++++++++++++++++++++++++++++ 3 files changed, 387 insertions(+) create mode 100644 oauthex/url_scheme_test.go diff --git a/oauthex/auth_meta.go b/oauthex/auth_meta.go index 3723318..9aa0c8d 100644 --- a/oauthex/auth_meta.go +++ b/oauthex/auth_meta.go @@ -22,6 +22,10 @@ import ( // Not supported: // - signed metadata // +// Note: URL fields in this struct are validated by validateAuthServerMetaURLs to +// prevent XSS attacks. If you add a new URL field, you must also add it to that +// function. +// // [RFC 8414]: https://tools.ietf.org/html/rfc8414) type AuthServerMeta struct { // GENERATED BY GEMINI 2.5. @@ -144,9 +148,40 @@ func GetAuthServerMeta(ctx context.Context, issuerURL string, c *http.Client) (* return nil, fmt.Errorf("authorization server at %s does not implement PKCE", issuerURL) } + // Validate endpoint URLs to prevent XSS attacks (see #526). + if err := validateAuthServerMetaURLs(asm); err != nil { + return nil, err + } + return asm, nil } errs = append(errs, err) } return nil, fmt.Errorf("failed to get auth server metadata from %q: %w", issuerURL, errors.Join(errs...)) } + +// validateAuthServerMetaURLs validates all URL fields in AuthServerMeta +// to ensure they don't use dangerous schemes that could enable XSS attacks. +func validateAuthServerMetaURLs(asm *AuthServerMeta) error { + urls := []struct { + name string + value string + }{ + {"authorization_endpoint", asm.AuthorizationEndpoint}, + {"token_endpoint", asm.TokenEndpoint}, + {"jwks_uri", asm.JWKSURI}, + {"registration_endpoint", asm.RegistrationEndpoint}, + {"service_documentation", asm.ServiceDocumentation}, + {"op_policy_uri", asm.OpPolicyURI}, + {"op_tos_uri", asm.OpTOSURI}, + {"revocation_endpoint", asm.RevocationEndpoint}, + {"introspection_endpoint", asm.IntrospectionEndpoint}, + } + + for _, u := range urls { + if err := checkURLScheme(u.value); err != nil { + return fmt.Errorf("%s: %w", u.name, err) + } + } + return nil +} diff --git a/oauthex/dcr.go b/oauthex/dcr.go index 75ce296..c64cb8c 100644 --- a/oauthex/dcr.go +++ b/oauthex/dcr.go @@ -20,6 +20,10 @@ import ( ) // ClientRegistrationMetadata represents the client metadata fields for the DCR POST request (RFC 7591). +// +// Note: URL fields in this struct are validated by validateClientRegistrationURLs +// to prevent XSS attacks. If you add a new URL field, you must also add it to +// that function. type ClientRegistrationMetadata struct { // RedirectURIs is a REQUIRED JSON array of redirection URI strings for use in // redirect-based flows (such as the authorization code grant). @@ -208,6 +212,10 @@ func RegisterClient(ctx context.Context, registrationEndpoint string, clientMeta if regResponse.ClientID == "" { return nil, fmt.Errorf("registration response is missing required 'client_id' field") } + // Validate URL fields to prevent XSS attacks (see #526). + if err := validateClientRegistrationURLs(®Response.ClientRegistrationMetadata); err != nil { + return nil, err + } return ®Response, nil } @@ -221,3 +229,33 @@ func RegisterClient(ctx context.Context, registrationEndpoint string, clientMeta return nil, fmt.Errorf("registration failed with status %s: %s", resp.Status, string(body)) } + +// validateClientRegistrationURLs validates all URL fields in ClientRegistrationMetadata +// to ensure they don't use dangerous schemes that could enable XSS attacks. +func validateClientRegistrationURLs(meta *ClientRegistrationMetadata) error { + // Validate redirect URIs + for i, uri := range meta.RedirectURIs { + if err := checkURLScheme(uri); err != nil { + return fmt.Errorf("redirect_uris[%d]: %w", i, err) + } + } + + // Validate other URL fields + urls := []struct { + name string + value string + }{ + {"client_uri", meta.ClientURI}, + {"logo_uri", meta.LogoURI}, + {"tos_uri", meta.TOSURI}, + {"policy_uri", meta.PolicyURI}, + {"jwks_uri", meta.JWKSURI}, + } + + for _, u := range urls { + if err := checkURLScheme(u.value); err != nil { + return fmt.Errorf("%s: %w", u.name, err) + } + } + return nil +} diff --git a/oauthex/url_scheme_test.go b/oauthex/url_scheme_test.go new file mode 100644 index 0000000..531a1f9 --- /dev/null +++ b/oauthex/url_scheme_test.go @@ -0,0 +1,314 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +//go:build mcp_go_client_oauth + +package oauthex + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestCheckURLScheme tests the checkURLScheme function directly. +func TestCheckURLScheme(t *testing.T) { + tests := []struct { + name string + url string + wantErr bool + }{ + // Valid schemes + {"empty string", "", false}, + {"https url", "https://example.com/path", false}, + {"http url", "http://example.com/path", false}, + {"custom scheme", "myapp://callback", false}, + + // Dangerous schemes that should be blocked + {"javascript scheme", "javascript:alert('xss')", true}, + {"javascript uppercase", "JAVASCRIPT:alert('xss')", true}, + {"javascript mixed case", "JaVaScRiPt:alert('xss')", true}, + {"data scheme", "data:text/html,", true}, + {"data uppercase", "DATA:text/html,test", true}, + {"vbscript scheme", "vbscript:msgbox('xss')", true}, + {"vbscript uppercase", "VBSCRIPT:test", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := checkURLScheme(tt.url) + if (err != nil) != tt.wantErr { + t.Errorf("checkURLScheme(%q): got err %v, want err %v", tt.url, err != nil, tt.wantErr) + } + }) + } +} + +// TestValidateAuthServerMetaURLs tests validation of AuthServerMeta URL fields. +func TestValidateAuthServerMetaURLs(t *testing.T) { + validMeta := &AuthServerMeta{ + Issuer: "https://auth.example.com", + AuthorizationEndpoint: "https://auth.example.com/authorize", + TokenEndpoint: "https://auth.example.com/token", + JWKSURI: "https://auth.example.com/.well-known/jwks.json", + RegistrationEndpoint: "https://auth.example.com/register", + ServiceDocumentation: "https://docs.example.com", + OpPolicyURI: "https://example.com/policy", + OpTOSURI: "https://example.com/tos", + RevocationEndpoint: "https://auth.example.com/revoke", + IntrospectionEndpoint: "https://auth.example.com/introspect", + } + + t.Run("valid metadata", func(t *testing.T) { + if err := validateAuthServerMetaURLs(validMeta); err != nil { + t.Errorf("validateAuthServerMetaURLs(): got err %v, want nil", err) + } + }) + + // Test each URL field with a dangerous scheme + dangerousFields := []struct { + name string + setField func(*AuthServerMeta) + }{ + {"authorization_endpoint", func(m *AuthServerMeta) { m.AuthorizationEndpoint = "javascript:alert(1)" }}, + {"token_endpoint", func(m *AuthServerMeta) { m.TokenEndpoint = "javascript:alert(1)" }}, + {"jwks_uri", func(m *AuthServerMeta) { m.JWKSURI = "data:text/html,test" }}, + {"registration_endpoint", func(m *AuthServerMeta) { m.RegistrationEndpoint = "vbscript:test" }}, + {"service_documentation", func(m *AuthServerMeta) { m.ServiceDocumentation = "javascript:void(0)" }}, + {"op_policy_uri", func(m *AuthServerMeta) { m.OpPolicyURI = "javascript:x" }}, + {"op_tos_uri", func(m *AuthServerMeta) { m.OpTOSURI = "data:,test" }}, + {"revocation_endpoint", func(m *AuthServerMeta) { m.RevocationEndpoint = "javascript:1" }}, + {"introspection_endpoint", func(m *AuthServerMeta) { m.IntrospectionEndpoint = "javascript:2" }}, + } + + for _, tt := range dangerousFields { + t.Run("dangerous "+tt.name, func(t *testing.T) { + // Copy valid metadata + meta := *validMeta + // Set one field to a dangerous value + tt.setField(&meta) + + err := validateAuthServerMetaURLs(&meta) + if err == nil { + t.Errorf("validateAuthServerMetaURLs(): got nil error, want error for dangerous %s", tt.name) + } else if !strings.Contains(err.Error(), tt.name) { + t.Errorf("validateAuthServerMetaURLs(): got error %v, want error containing %q", err, tt.name) + } + }) + } + + t.Run("empty optional fields are valid", func(t *testing.T) { + meta := &AuthServerMeta{ + Issuer: "https://auth.example.com", + AuthorizationEndpoint: "https://auth.example.com/authorize", + TokenEndpoint: "https://auth.example.com/token", + JWKSURI: "https://auth.example.com/.well-known/jwks.json", + // All optional fields left empty + } + if err := validateAuthServerMetaURLs(meta); err != nil { + t.Errorf("validateAuthServerMetaURLs(): got err %v, want nil", err) + } + }) +} + +// TestValidateClientRegistrationURLs tests validation of ClientRegistrationMetadata URL fields. +func TestValidateClientRegistrationURLs(t *testing.T) { + validMeta := &ClientRegistrationMetadata{ + RedirectURIs: []string{"https://app.example.com/callback", "myapp://callback"}, + ClientURI: "https://example.com", + LogoURI: "https://example.com/logo.png", + TOSURI: "https://example.com/tos", + PolicyURI: "https://example.com/policy", + JWKSURI: "https://example.com/.well-known/jwks.json", + } + + t.Run("valid metadata", func(t *testing.T) { + if err := validateClientRegistrationURLs(validMeta); err != nil { + t.Errorf("validateClientRegistrationURLs(): got err %v, want nil", err) + } + }) + + t.Run("dangerous redirect_uri", func(t *testing.T) { + meta := *validMeta + meta.RedirectURIs = []string{"https://safe.com/cb", "javascript:alert(1)"} + + err := validateClientRegistrationURLs(&meta) + if err == nil { + t.Error("validateClientRegistrationURLs(): got nil error, want error for dangerous redirect_uri") + } else if !strings.Contains(err.Error(), "redirect_uris[1]") { + t.Errorf("validateClientRegistrationURLs(): got error %v, want error containing \"redirect_uris[1]\"", err) + } + }) + + // Test each URL field with a dangerous scheme + dangerousFields := []struct { + name string + setField func(*ClientRegistrationMetadata) + }{ + {"client_uri", func(m *ClientRegistrationMetadata) { m.ClientURI = "javascript:alert(1)" }}, + {"logo_uri", func(m *ClientRegistrationMetadata) { m.LogoURI = "data:image/svg," }}, + {"tos_uri", func(m *ClientRegistrationMetadata) { m.TOSURI = "vbscript:test" }}, + {"policy_uri", func(m *ClientRegistrationMetadata) { m.PolicyURI = "javascript:void(0)" }}, + {"jwks_uri", func(m *ClientRegistrationMetadata) { m.JWKSURI = "data:application/json,{}" }}, + } + + for _, tt := range dangerousFields { + t.Run("dangerous "+tt.name, func(t *testing.T) { + meta := *validMeta + tt.setField(&meta) + + err := validateClientRegistrationURLs(&meta) + if err == nil { + t.Errorf("validateClientRegistrationURLs(): got nil error, want error for dangerous %s", tt.name) + } else if !strings.Contains(err.Error(), tt.name) { + t.Errorf("validateClientRegistrationURLs(): got error %v, want error containing %q", err, tt.name) + } + }) + } + + t.Run("empty optional fields are valid", func(t *testing.T) { + meta := &ClientRegistrationMetadata{ + RedirectURIs: []string{"https://app.example.com/callback"}, + // All optional URL fields left empty + } + if err := validateClientRegistrationURLs(meta); err != nil { + t.Errorf("validateClientRegistrationURLs(): got err %v, want nil", err) + } + }) +} + +// TestGetAuthServerMetaRejectsDangerousURLs tests that GetAuthServerMeta rejects +// metadata containing dangerous URL schemes. +func TestGetAuthServerMetaRejectsDangerousURLs(t *testing.T) { + tests := []struct { + name string + metadata AuthServerMeta + wantErrText string + }{ + { + name: "javascript authorization_endpoint", + metadata: AuthServerMeta{ + Issuer: "", // Will be set dynamically + AuthorizationEndpoint: "javascript:alert('xss')", + TokenEndpoint: "https://auth.example.com/token", + JWKSURI: "https://auth.example.com/.well-known/jwks.json", + ResponseTypesSupported: []string{"code"}, + CodeChallengeMethodsSupported: []string{"S256"}, + }, + wantErrText: "authorization_endpoint", + }, + { + name: "data token_endpoint", + metadata: AuthServerMeta{ + Issuer: "", + AuthorizationEndpoint: "https://auth.example.com/authorize", + TokenEndpoint: "data:text/html,", + JWKSURI: "https://auth.example.com/.well-known/jwks.json", + ResponseTypesSupported: []string{"code"}, + CodeChallengeMethodsSupported: []string{"S256"}, + }, + wantErrText: "token_endpoint", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + meta := tt.metadata + meta.Issuer = "https://" + r.Host + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(meta) + })) + defer server.Close() + + ctx := context.Background() + _, err := GetAuthServerMeta(ctx, server.URL, server.Client()) + if err == nil { + t.Fatal("GetAuthServerMeta(): got nil error, want error") + } + if !strings.Contains(err.Error(), tt.wantErrText) { + t.Errorf("GetAuthServerMeta(): got error %v, want error containing %q", err, tt.wantErrText) + } + }) + } +} + +// TestGetProtectedResourceMetadataRejectsDangerousURLs tests that +// GetProtectedResourceMetadataFromID rejects metadata with dangerous authorization server URLs. +func TestGetProtectedResourceMetadataRejectsDangerousURLs(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + serverURL := "https://" + r.Host + meta := ProtectedResourceMetadata{ + Resource: serverURL, + AuthorizationServers: []string{"javascript:alert('xss')"}, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(meta) + })) + defer server.Close() + + ctx := context.Background() + _, err := GetProtectedResourceMetadataFromID(ctx, server.URL, server.Client()) + if err == nil { + t.Fatal("GetProtectedResourceMetadataFromID(): got nil error, want error") + } + if !strings.Contains(err.Error(), "disallowed scheme") { + t.Errorf("GetProtectedResourceMetadataFromID(): got error %v, want error containing \"disallowed scheme\"", err) + } +} + +// TestRegisterClientRejectsDangerousURLs tests that RegisterClient rejects +// responses containing dangerous URL schemes. +func TestRegisterClientRejectsDangerousURLs(t *testing.T) { + tests := []struct { + name string + responseJSON string + wantErrText string + }{ + { + name: "javascript redirect_uri in response", + responseJSON: `{ + "client_id": "test-client", + "redirect_uris": ["javascript:alert(1)"] + }`, + wantErrText: "redirect_uris[0]", + }, + { + name: "data client_uri", + responseJSON: `{ + "client_id": "test-client", + "redirect_uris": ["https://app.example.com/callback"], + "client_uri": "data:text/html," + }`, + wantErrText: "client_uri", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(tt.responseJSON)) + })) + defer server.Close() + + ctx := context.Background() + clientMeta := &ClientRegistrationMetadata{ + RedirectURIs: []string{"https://app.example.com/callback"}, + } + + _, err := RegisterClient(ctx, server.URL+"/register", clientMeta, server.Client()) + if err == nil { + t.Fatal("RegisterClient(): got nil error, want error") + } + if !strings.Contains(err.Error(), tt.wantErrText) { + t.Errorf("RegisterClient(): got error %v, want error containing %q", err, tt.wantErrText) + } + }) + } +} From a5e369ed125f5c36277615bf7b2cbc5e16790fcc Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Thu, 11 Dec 2025 10:16:56 -0500 Subject: [PATCH 150/418] mcp: fix broken client root capabilities (#698) To address #607, add ClientCapabilities.RootsV2, and populate it when constructing sending and receiving InitializeParams. Fixes #607 --- docs/rough_edges.md | 6 ++ docs/troubleshooting.md | 2 +- internal/docs/rough_edges.src.md | 6 ++ mcp/client.go | 4 + mcp/client_test.go | 25 ++--- mcp/protocol.go | 152 ++++++++++++++++++++++--------- mcp/server.go | 21 ++++- mcp/server_test.go | 123 +++++++++++++++++++++++++ mcp/shared.go | 12 ++- mcp/transport_example_test.go | 2 +- 10 files changed, 292 insertions(+), 61 deletions(-) diff --git a/docs/rough_edges.md b/docs/rough_edges.md index 5c732bd..d6dc826 100644 --- a/docs/rough_edges.md +++ b/docs/rough_edges.md @@ -30,3 +30,9 @@ v2. - `AudioContent.MarshalJSON` should have had a pointer receiver, to be consistent with other content types. + +- `ClientCapabilities.Roots` should have been a distinguished struct pointer + ([see #607](https://github.com/modelcontextprotocol/go-sdk/issues/607)). + + **Workaround**: use `ClientCapabilities.RootsV2`, which aligns with the + semantics of other capability fields. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c22a279..b8cc776 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -51,7 +51,7 @@ func ExampleLoggingTransport() { // Output: // read: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-06-18","serverInfo":{"name":"server","version":"v0.0.1"}}} - // write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{"roots":{"listChanged":true}},"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-06-18"}} + // write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-06-18","capabilities":{"roots":{"listChanged":true}}}} // write: {"jsonrpc":"2.0","method":"notifications/initialized","params":{}} } ``` diff --git a/internal/docs/rough_edges.src.md b/internal/docs/rough_edges.src.md index ff95b26..4566032 100644 --- a/internal/docs/rough_edges.src.md +++ b/internal/docs/rough_edges.src.md @@ -29,3 +29,9 @@ v2. - `AudioContent.MarshalJSON` should have had a pointer receiver, to be consistent with other content types. + +- `ClientCapabilities.Roots` should have been a distinguished struct pointer + ([see #607](https://github.com/modelcontextprotocol/go-sdk/issues/607)). + + **Workaround**: use `ClientCapabilities.RootsV2`, which aligns with the + semantics of other capability fields. diff --git a/mcp/client.go b/mcp/client.go index b862c79..612b12f 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -131,7 +131,11 @@ type ClientSessionOptions struct { func (c *Client) capabilities() *ClientCapabilities { caps := &ClientCapabilities{} + // Due to an oversight (#607), roots require special handling. caps.Roots.ListChanged = true + caps.RootsV2 = &RootCapabilities{ + ListChanged: true, + } if c.opts.CreateMessageHandler != nil { caps.Sampling = &SamplingCapabilities{} } diff --git a/mcp/client_test.go b/mcp/client_test.go index a7fb68d..0522c06 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -202,9 +202,8 @@ func TestClientCapabilities(t *testing.T) { name: "With initial capabilities", configureClient: func(s *Client) {}, wantCapabilities: &ClientCapabilities{ - Roots: struct { - ListChanged bool "json:\"listChanged,omitempty\"" - }{ListChanged: true}, + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, }, }, { @@ -216,9 +215,8 @@ func TestClientCapabilities(t *testing.T) { }, }, wantCapabilities: &ClientCapabilities{ - Roots: struct { - ListChanged bool "json:\"listChanged,omitempty\"" - }{ListChanged: true}, + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, Sampling: &SamplingCapabilities{}, }, }, @@ -232,9 +230,8 @@ func TestClientCapabilities(t *testing.T) { }, }, wantCapabilities: &ClientCapabilities{ - Roots: struct { - ListChanged bool "json:\"listChanged,omitempty\"" - }{ListChanged: true}, + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, Elicitation: &ElicitationCapabilities{ Form: &FormElicitationCapabilities{}, }, @@ -250,9 +247,8 @@ func TestClientCapabilities(t *testing.T) { }, }, wantCapabilities: &ClientCapabilities{ - Roots: struct { - ListChanged bool "json:\"listChanged,omitempty\"" - }{ListChanged: true}, + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, Elicitation: &ElicitationCapabilities{ URL: &URLElicitationCapabilities{}, }, @@ -268,9 +264,8 @@ func TestClientCapabilities(t *testing.T) { }, }, wantCapabilities: &ClientCapabilities{ - Roots: struct { - ListChanged bool "json:\"listChanged,omitempty\"" - }{ListChanged: true}, + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, Elicitation: &ElicitationCapabilities{ Form: &FormElicitationCapabilities{}, URL: &URLElicitationCapabilities{}, diff --git a/mcp/protocol.go b/mcp/protocol.go index 8a88f8e..563b379 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -177,23 +177,62 @@ func (x *CancelledParams) isParams() {} func (x *CancelledParams) GetProgressToken() any { return getProgressToken(x) } func (x *CancelledParams) SetProgressToken(t any) { setProgressToken(x, t) } +// RootCapabilities describes a client's support for roots. +type RootCapabilities struct { + // ListChanged reports whether the client supports notifications for + // changes to the roots list. + ListChanged bool `json:"listChanged,omitempty"` +} + // Capabilities a client may support. Known capabilities are defined here, in // this schema, but this is not a closed set: any client can define its own, // additional capabilities. type ClientCapabilities struct { - // Experimental, non-standard capabilities that the client supports. + // Experimental reports non-standard capabilities that the client supports. Experimental map[string]any `json:"experimental,omitempty"` - // Present if the client supports listing roots. + // Roots describes the client's support for roots. + // + // Deprecated: use RootsV2. As described in #607, Roots should have been a + // pointer to a RootCapabilities value. Roots will be continue to be + // populated, but any new fields will only be added in the RootsV2 field. Roots struct { - // Whether the client supports notifications for changes to the roots list. + // ListChanged reports whether the client supports notifications for + // changes to the roots list. ListChanged bool `json:"listChanged,omitempty"` } `json:"roots,omitempty"` - // Present if the client supports sampling from an LLM. + // RootsV2 is present if the client supports roots. + RootsV2 *RootCapabilities `json:"-"` + // Sampling is present if the client supports sampling from an LLM. Sampling *SamplingCapabilities `json:"sampling,omitempty"` - // Present if the client supports elicitation from the server. + // Elicitation is present if the client supports elicitation from the server. Elicitation *ElicitationCapabilities `json:"elicitation,omitempty"` } +func (c *ClientCapabilities) toV2() *clientCapabilitiesV2 { + return &clientCapabilitiesV2{ + ClientCapabilities: *c, + Roots: c.RootsV2, + } +} + +// clientCapabilitiesV2 is a version of ClientCapabilities that fixes the bug +// described in #607: Roots should have been a pointer to value type +// RootCapabilities. +type clientCapabilitiesV2 struct { + ClientCapabilities + Roots *RootCapabilities `json:"roots,omitempty"` +} + +func (c *clientCapabilitiesV2) toV1() *ClientCapabilities { + caps := c.ClientCapabilities + caps.RootsV2 = c.Roots + // Sync Roots from RootsV2 for backward compatibility (#607). + if caps.RootsV2 != nil { + caps.Roots = *caps.RootsV2 + } + return &caps +} + type CompleteParamsArgument struct { // The name of the argument Name string `json:"name"` @@ -373,27 +412,53 @@ type GetPromptResult struct { func (*GetPromptResult) isResult() {} +// InitializeParams is sent by the client to initialize the session. type InitializeParams struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. - Meta `json:"_meta,omitempty"` + Meta `json:"_meta,omitempty"` + // Capabilities describes the client's capabilities. Capabilities *ClientCapabilities `json:"capabilities"` - ClientInfo *Implementation `json:"clientInfo"` - // The latest version of the Model Context Protocol that the client supports. - // The client may decide to support older versions as well. + // ClientInfo provides information about the client. + ClientInfo *Implementation `json:"clientInfo"` + // ProtocolVersion is the latest version of the Model Context Protocol that + // the client supports. ProtocolVersion string `json:"protocolVersion"` } +func (p *InitializeParams) toV2() *initializeParamsV2 { + return &initializeParamsV2{ + InitializeParams: *p, + Capabilities: p.Capabilities.toV2(), + } +} + +// initializeParamsV2 works around the mistake in #607: Capabilities.Roots +// should have been a pointer. +type initializeParamsV2 struct { + InitializeParams + Capabilities *clientCapabilitiesV2 `json:"capabilities"` +} + +func (p *initializeParamsV2) toV1() *InitializeParams { + p1 := p.InitializeParams + if p.Capabilities != nil { + p1.Capabilities = p.Capabilities.toV1() + } + return &p1 +} + func (x *InitializeParams) isParams() {} func (x *InitializeParams) GetProgressToken() any { return getProgressToken(x) } func (x *InitializeParams) SetProgressToken(t any) { setProgressToken(x, t) } -// After receiving an initialize request from the client, the server sends this -// response. +// InitializeResult is sent by the server in response to an initialize request +// from the client. type InitializeResult struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. - Meta `json:"_meta,omitempty"` + Meta `json:"_meta,omitempty"` + // Capabilities describes the server's capabilities. Capabilities *ServerCapabilities `json:"capabilities"` // Instructions describing how to use the server and its features. // @@ -411,8 +476,8 @@ type InitializeResult struct { func (*InitializeResult) isResult() {} type InitializedParams struct { - // This property is reserved by the protocol to allow clients and servers to - // attach additional metadata to their responses. + // Meta is reserved by the protocol to allow clients and servers to attach + // additional metadata to their responses. Meta `json:"_meta,omitempty"` } @@ -875,7 +940,10 @@ func (x *RootsListChangedParams) isParams() {} func (x *RootsListChangedParams) GetProgressToken() any { return getProgressToken(x) } func (x *RootsListChangedParams) SetProgressToken(t any) { setProgressToken(x, t) } -// SamplingCapabilities describes the capabilities for sampling. +// TODO: to be consistent with ServerCapabilities, move the capability types +// below directly above ClientCapabilities. + +// SamplingCapabilities describes the client's support for sampling. type SamplingCapabilities struct{} // ElicitationCapabilities describes the capabilities for elicitation. @@ -1160,48 +1228,50 @@ type Implementation struct { Icons []Icon `json:"icons,omitempty"` } -// Present if the server supports argument autocompletion suggestions. +// CompletionCapabilities describes the server's support for argument autocompletion. type CompletionCapabilities struct{} -// Present if the server supports sending log messages to the client. +// LoggingCapabilities describes the server's support for sending log messages to the client. type LoggingCapabilities struct{} -// Present if the server offers any prompt templates. +// PromptCapabilities describes the server's support for prompts. type PromptCapabilities struct { // Whether this server supports notifications for changes to the prompt list. ListChanged bool `json:"listChanged,omitempty"` } -// Present if the server offers any resources to read. +// ResourceCapabilities describes the server's support for resources. type ResourceCapabilities struct { - // Whether this server supports notifications for changes to the resource list. + // ListChanged reports whether the client supports notifications for + // changes to the resource list. ListChanged bool `json:"listChanged,omitempty"` - // Whether this server supports subscribing to resource updates. + // Subscribe reports whether this server supports subscribing to resource + // updates. Subscribe bool `json:"subscribe,omitempty"` } -// Capabilities that a server may support. Known capabilities are defined here, -// in this schema, but this is not a closed set: any server can define its own, -// additional capabilities. -type ServerCapabilities struct { - // Present if the server supports argument autocompletion suggestions. - Completions *CompletionCapabilities `json:"completions,omitempty"` - // Experimental, non-standard capabilities that the server supports. - Experimental map[string]any `json:"experimental,omitempty"` - // Present if the server supports sending log messages to the client. - Logging *LoggingCapabilities `json:"logging,omitempty"` - // Present if the server offers any prompt templates. - Prompts *PromptCapabilities `json:"prompts,omitempty"` - // Present if the server offers any resources to read. - Resources *ResourceCapabilities `json:"resources,omitempty"` - // Present if the server offers any tools to call. - Tools *ToolCapabilities `json:"tools,omitempty"` +// ToolCapabilities describes the server's support for tools. +type ToolCapabilities struct { + // ListChanged reports whether the client supports notifications for + // changes to the tool list. + ListChanged bool `json:"listChanged,omitempty"` } -// Present if the server offers any tools to call. -type ToolCapabilities struct { - // Whether this server supports notifications for changes to the tool list. - ListChanged bool `json:"listChanged,omitempty"` +// ServerCapabilities describes capabilities that a server supports. +type ServerCapabilities struct { + // Experimental reports non-standard capabilities that the server supports. + Experimental map[string]any `json:"experimental,omitempty"` + // Completions is present if the server supports argument autocompletion + // suggestions. + Completions *CompletionCapabilities `json:"completions,omitempty"` + // Logging is present if the server supports log messages. + Logging *LoggingCapabilities `json:"logging,omitempty"` + // Prompts is present if the server supports prompts. + Prompts *PromptCapabilities `json:"prompts,omitempty"` + // Resources is present if the server supports resourcs. + Resources *ResourceCapabilities `json:"resources,omitempty"` + // Tools is present if the supports tools. + Tools *ToolCapabilities `json:"tools,omitempty"` } const ( diff --git a/mcp/server.go b/mcp/server.go index 5f5a4a5..a8f4885 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1138,7 +1138,7 @@ func (s *Server) AddReceivingMiddleware(middleware ...Middleware) { // curating these method flags. var serverMethodInfos = map[string]methodInfo{ methodComplete: newServerMethodInfo(serverMethod((*Server).complete), 0), - methodInitialize: newServerMethodInfo(serverSessionMethod((*ServerSession).initialize), 0), + methodInitialize: initializeMethodInfo(), methodPing: newServerMethodInfo(serverSessionMethod((*ServerSession).ping), missingParamsOK), methodListPrompts: newServerMethodInfo(serverMethod((*Server).listPrompts), missingParamsOK), methodGetPrompt: newServerMethodInfo(serverMethod((*Server).getPrompt), 0), @@ -1156,6 +1156,25 @@ var serverMethodInfos = map[string]methodInfo{ notificationProgress: newServerMethodInfo(serverSessionMethod((*ServerSession).callProgressNotificationHandler), notification), } +// initializeMethodInfo handles the workaround for #607: we must set +// params.Capabilities.RootsV2. +func initializeMethodInfo() methodInfo { + info := newServerMethodInfo(serverSessionMethod((*ServerSession).initialize), 0) + info.unmarshalParams = func(m json.RawMessage) (Params, error) { + var params *initializeParamsV2 + if m != nil { + if err := json.Unmarshal(m, ¶ms); err != nil { + return nil, fmt.Errorf("unmarshaling %q into a %T: %w", m, params, err) + } + } + if params == nil { + return nil, fmt.Errorf(`missing required "params"`) + } + return params.toV1(), nil + } + return info +} + func (ss *ServerSession) sendingMethodInfos() map[string]methodInfo { return clientMethodInfos } func (ss *ServerSession) receivingMethodInfos() map[string]methodInfo { return serverMethodInfos } diff --git a/mcp/server_test.go b/mcp/server_test.go index d8c0df6..b578cbf 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -17,6 +17,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" ) type testItem struct { @@ -600,6 +601,128 @@ func testToolForSchema[In, Out any](t *testing.T, tool *Tool, in string, out Out } } +// TestClientRootCapabilities verifies that the server correctly observes +// RootsV2 for various client capability configurations. This tests the fix +// for #607. +func TestClientRootCapabilities(t *testing.T) { + testCases := []struct { + name string + capabilities *string // JSON for the capabilities field; nil means omit the field + wantRootsV2 *RootCapabilities + }{ + { + name: "capabilities field omitted", + capabilities: nil, + wantRootsV2: nil, + }, + { + name: "empty capabilities", + capabilities: ptr(`{}`), + wantRootsV2: nil, + }, + { + name: "capabilities with no roots", + capabilities: ptr(`{"sampling": {}}`), + wantRootsV2: nil, + }, + { + name: "capabilities with empty roots", + capabilities: ptr(`{"roots": {}}`), + wantRootsV2: &RootCapabilities{ListChanged: false}, + }, + { + name: "capabilities with roots without listChanged", + capabilities: ptr(`{"roots": {"listChanged": false}}`), + wantRootsV2: &RootCapabilities{ListChanged: false}, + }, + { + name: "capabilities with roots with listChanged", + capabilities: ptr(`{"roots": {"listChanged": true}}`), + wantRootsV2: &RootCapabilities{ListChanged: true}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + + // Create a minimal server. + impl := &Implementation{Name: "testServer", Version: "v1.0.0"} + s := NewServer(impl, nil) + + // Connect the server. + cTransport, sTransport := NewInMemoryTransports() + ss, err := s.Connect(ctx, sTransport, nil) + if err != nil { + t.Fatal(err) + } + + // Connect the client JSON-RPC connection (raw, no client). + cConn, err := cTransport.Connect(ctx) + if err != nil { + t.Fatal(err) + } + + // Build initialize params, optionally including capabilities. + var initParams json.RawMessage + if tc.capabilities != nil { + initParams = json.RawMessage(`{ + "protocolVersion": "2025-06-18", + "capabilities": ` + *tc.capabilities + `, + "clientInfo": {"name": "TestClient", "version": "1.0.0"} + }`) + } else { + initParams = json.RawMessage(`{ + "protocolVersion": "2025-06-18", + "clientInfo": {"name": "TestClient", "version": "1.0.0"} + }`) + } + + initReq, err := jsonrpc2.NewCall(jsonrpc2.Int64ID(1), "initialize", initParams) + if err != nil { + t.Fatal(err) + } + + if err := cConn.Write(ctx, initReq); err != nil { + t.Fatalf("Write failed: %v", err) + } + + // Read the initialize response. + msg, err := cConn.Read(ctx) + if err != nil { + t.Fatalf("Read failed: %v", err) + } + resp, ok := msg.(*jsonrpc2.Response) + if !ok { + t.Fatalf("expected Response, got %T", msg) + } + if resp.Error != nil { + t.Fatalf("initialize failed: %v", resp.Error) + } + + // Verify that the server session has the correct RootsV2 value. + params := ss.InitializeParams() + if params == nil { + t.Fatal("InitializeParams is nil") + } + + var gotRootsV2 *RootCapabilities + if params.Capabilities != nil { + gotRootsV2 = params.Capabilities.RootsV2 + } + if diff := cmp.Diff(tc.wantRootsV2, gotRootsV2); diff != "" { + t.Errorf("RootsV2 mismatch (-want +got):\n%s", diff) + } + + // Close the client connection. + if err := cConn.Close(); err != nil { + t.Fatalf("Stream.Close failed: %v", err) + } + ss.Wait() + }) + } +} + // TODO: move this to tool_test.go func TestToolForSchemas(t *testing.T) { // Validate that toolForErr handles schemas properly. diff --git a/mcp/shared.go b/mcp/shared.go index c2e50d9..5e1dc0c 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -94,14 +94,22 @@ func defaultSendingMethodHandler(ctx context.Context, method string, req Request // This can be called from user code, with an arbitrary value for method. return nil, jsonrpc2.ErrNotHandled } + params := req.GetParams() + if initParams, ok := params.(*InitializeParams); ok { + // Fix the marshaling of initialize params, to work around #607. + // + // The initialize params we produce should never be nil, nor have nil + // capabilities, so any panic here is a bug. + params = initParams.toV2() + } // Notifications don't have results. if strings.HasPrefix(method, "notifications/") { - return nil, req.GetSession().getConn().Notify(ctx, method, req.GetParams()) + return nil, req.GetSession().getConn().Notify(ctx, method, params) } // Create the result to unmarshal into. // The concrete type of the result is the return type of the receiving function. res := info.newResult() - if err := call(ctx, req.GetSession().getConn(), method, req.GetParams(), res); err != nil { + if err := call(ctx, req.GetSession().getConn(), method, params, res); err != nil { return nil, err } return res, nil diff --git a/mcp/transport_example_test.go b/mcp/transport_example_test.go index 7390ea4..bb032f1 100644 --- a/mcp/transport_example_test.go +++ b/mcp/transport_example_test.go @@ -46,7 +46,7 @@ func ExampleLoggingTransport() { // Output: // read: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-06-18","serverInfo":{"name":"server","version":"v0.0.1"}}} - // write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{"roots":{"listChanged":true}},"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-06-18"}} + // write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-06-18","capabilities":{"roots":{"listChanged":true}}}} // write: {"jsonrpc":"2.0","method":"notifications/initialized","params":{}} } From 1a964ae23d7b29cd41de5ad644ca584f8b24f23b Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Thu, 11 Dec 2025 15:11:32 -0500 Subject: [PATCH 151/418] mcp: debounce server change notifications (#717) Debounce and batch notifications for changes in server feature sets (adding or removing tools, prompts, resources, or resource templates). This PR handles server-side change notifications only. We could do the same for client-side notifications (e.g. roots changed), but at the moment we don't need to. Fixes #649. --- mcp/mcp_test.go | 24 +++++++++++++------ mcp/server.go | 62 ++++++++++++++++++++++++++++++++----------------- mcp/shared.go | 2 ++ 3 files changed, 60 insertions(+), 28 deletions(-) diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 8c40de0..e272cb4 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -61,6 +61,8 @@ func TestEndToEnd(t *testing.T) { var ct, st Transport = NewInMemoryTransports() // Channels to check if notification callbacks happened. + // These test asynchronous sending of notifications after a small delay (see + // Server.sendNotification). notificationChans := map[string]chan int{} for _, name := range []string{"initialized", "roots", "tools", "prompts", "resources", "progress_server", "progress_client", "resource_updated", "subscribe", "unsubscribe", "elicitation_complete"} { notificationChans[name] = make(chan int, 1) @@ -1797,14 +1799,15 @@ func TestAddTool_DuplicateNoPanicAndNoDuplicate(t *testing.T) { } func TestSynchronousNotifications(t *testing.T) { - var toolsChanged atomic.Bool + var toolsChanged atomic.Int32 clientOpts := &ClientOptions{ ToolListChangedHandler: func(ctx context.Context, req *ToolListChangedRequest) { - toolsChanged.Store(true) + toolsChanged.Add(1) }, CreateMessageHandler: func(ctx context.Context, req *CreateMessageRequest) (*CreateMessageResult, error) { - if !toolsChanged.Load() { - return nil, fmt.Errorf("didn't get a tools changed notification") + // See the comment after "from server" below. + if n := toolsChanged.Load(); n != 1 { + return nil, fmt.Errorf("got %d tools-changed notification, wanted 1", n) } // TODO(rfindley): investigate the error returned from this test if // CreateMessageResult is new(CreateMessageResult): it's a mysterious @@ -1821,14 +1824,15 @@ func TestSynchronousNotifications(t *testing.T) { }, } server := NewServer(testImpl, serverOpts) - cs, ss, cleanup := basicClientServerConnection(t, client, server, func(s *Server) { + addTool := func(s *Server) { AddTool(s, &Tool{Name: "tool"}, func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { if !rootsChanged.Load() { return nil, nil, fmt.Errorf("didn't get root change notification") } return new(CallToolResult), nil, nil }) - }) + } + cs, ss, cleanup := basicClientServerConnection(t, client, server, addTool) defer cleanup() t.Run("from client", func(t *testing.T) { @@ -1843,7 +1847,13 @@ func TestSynchronousNotifications(t *testing.T) { }) t.Run("from server", func(t *testing.T) { - server.RemoveTools("tool") + // Despite all this tool-changed activity, we expect only one notification. + for range 10 { + server.RemoveTools("tool") + addTool(server) + } + + time.Sleep(notificationDelay * 2) // Wait for delayed notification. if _, err := ss.CreateMessage(context.Background(), new(CreateMessageParams)); err != nil { t.Errorf("CreateMessage failed: %v", err) } diff --git a/mcp/server.go b/mcp/server.go index a8f4885..776924a 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -51,6 +51,7 @@ type Server struct { sendingMethodHandler_ MethodHandler receivingMethodHandler_ MethodHandler resourceSubscriptions map[string]map[*ServerSession]bool // uri -> session -> bool + pendingNotifications map[string]*time.Timer // notification name -> timer for pending notification send } // ServerOptions is used to configure behavior of the server. @@ -149,6 +150,7 @@ func NewServer(impl *Implementation, options *ServerOptions) *Server { sendingMethodHandler_: defaultSendingMethodHandler, receivingMethodHandler_: defaultReceivingMethodHandler[*ServerSession], resourceSubscriptions: make(map[string]map[*ServerSession]bool), + pendingNotifications: make(map[string]*time.Timer), } } @@ -158,15 +160,13 @@ func (s *Server) AddPrompt(p *Prompt, h PromptHandler) { // (It's possible an item was replaced with an identical one, but not worth checking.) s.changeAndNotify( notificationPromptListChanged, - &PromptListChangedParams{}, func() bool { s.prompts.add(&serverPrompt{p, h}); return true }) } // RemovePrompts removes the prompts with the given names. // It is not an error to remove a nonexistent prompt. func (s *Server) RemovePrompts(names ...string) { - s.changeAndNotify(notificationPromptListChanged, &PromptListChangedParams{}, - func() bool { return s.prompts.remove(names...) }) + s.changeAndNotify(notificationPromptListChanged, func() bool { return s.prompts.remove(names...) }) } // AddTool adds a [Tool] to the server, or replaces one with the same name. @@ -235,8 +235,7 @@ func (s *Server) AddTool(t *Tool, h ToolHandler) { // (It's possible a tool was replaced with an identical one, but not worth checking.) // TODO: Batch these changes by size and time? The typescript SDK doesn't. // TODO: Surface notify error here? best not, in case we need to batch. - s.changeAndNotify(notificationToolListChanged, &ToolListChangedParams{}, - func() bool { s.tools.add(st); return true }) + s.changeAndNotify(notificationToolListChanged, func() bool { s.tools.add(st); return true }) } func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHandler, error) { @@ -419,14 +418,13 @@ func AddTool[In, Out any](s *Server, t *Tool, h ToolHandlerFor[In, Out]) { // RemoveTools removes the tools with the given names. // It is not an error to remove a nonexistent tool. func (s *Server) RemoveTools(names ...string) { - s.changeAndNotify(notificationToolListChanged, &ToolListChangedParams{}, - func() bool { return s.tools.remove(names...) }) + s.changeAndNotify(notificationToolListChanged, func() bool { return s.tools.remove(names...) }) } // AddResource adds a [Resource] to the server, or replaces one with the same URI. // AddResource panics if the resource URI is invalid or not absolute (has an empty scheme). func (s *Server) AddResource(r *Resource, h ResourceHandler) { - s.changeAndNotify(notificationResourceListChanged, &ResourceListChangedParams{}, + s.changeAndNotify(notificationResourceListChanged, func() bool { if _, err := url.Parse(r.URI); err != nil { panic(err) // url.Parse includes the URI in the error @@ -439,14 +437,13 @@ func (s *Server) AddResource(r *Resource, h ResourceHandler) { // RemoveResources removes the resources with the given URIs. // It is not an error to remove a nonexistent resource. func (s *Server) RemoveResources(uris ...string) { - s.changeAndNotify(notificationResourceListChanged, &ResourceListChangedParams{}, - func() bool { return s.resources.remove(uris...) }) + s.changeAndNotify(notificationResourceListChanged, func() bool { return s.resources.remove(uris...) }) } // AddResourceTemplate adds a [ResourceTemplate] to the server, or replaces one with the same URI. // AddResourceTemplate panics if a URI template is invalid or not absolute (has an empty scheme). func (s *Server) AddResourceTemplate(t *ResourceTemplate, h ResourceHandler) { - s.changeAndNotify(notificationResourceListChanged, &ResourceListChangedParams{}, + s.changeAndNotify(notificationResourceListChanged, func() bool { // Validate the URI template syntax _, err := uritemplate.New(t.URITemplate) @@ -461,8 +458,7 @@ func (s *Server) AddResourceTemplate(t *ResourceTemplate, h ResourceHandler) { // RemoveResourceTemplates removes the resource templates with the given URI templates. // It is not an error to remove a nonexistent resource. func (s *Server) RemoveResourceTemplates(uriTemplates ...string) { - s.changeAndNotify(notificationResourceListChanged, &ResourceListChangedParams{}, - func() bool { return s.resourceTemplates.remove(uriTemplates...) }) + s.changeAndNotify(notificationResourceListChanged, func() bool { return s.resourceTemplates.remove(uriTemplates...) }) } func (s *Server) capabilities() *ServerCapabilities { @@ -497,18 +493,43 @@ func (s *Server) complete(ctx context.Context, req *CompleteRequest) (*CompleteR return s.opts.CompletionHandler(ctx, req) } +// Map from notification name to its corresponding params. The params have no fields, +// so a single struct can be reused. +var changeNotificationParams = map[string]Params{ + notificationToolListChanged: &ToolListChangedParams{}, + notificationPromptListChanged: &PromptListChangedParams{}, + notificationResourceListChanged: &ResourceListChangedParams{}, +} + +// How long to wait before sending a change notification. +const notificationDelay = 10 * time.Millisecond + // changeAndNotify is called when a feature is added or removed. // It calls change, which should do the work and report whether a change actually occurred. -// If there was a change, it notifies a snapshot of the sessions. -func (s *Server) changeAndNotify(notification string, params Params, change func() bool) { - var sessions []*ServerSession - // Lock for the change, but not for the notification. +// If there was a change, it sets a timer to send a notification. +// This debounces change notifications: a single notification is sent after +// multiple changes occur in close proximity. +func (s *Server) changeAndNotify(notification string, change func() bool) { s.mu.Lock() + defer s.mu.Unlock() if change() { - sessions = slices.Clone(s.sessions) + // Reset the outstanding delayed call, if any. + if t := s.pendingNotifications[notification]; t == nil { + s.pendingNotifications[notification] = time.AfterFunc(notificationDelay, func() { s.notifySessions(notification) }) + } else { + t.Reset(notificationDelay) + } } - s.mu.Unlock() - notifySessions(sessions, notification, params, s.opts.Logger) +} + +// notifySessions sends the notification n to all existing sessions. +// It is called asynchronously by changeAndNotify. +func (s *Server) notifySessions(n string) { + s.mu.Lock() + sessions := slices.Clone(s.sessions) + s.pendingNotifications[n] = nil + s.mu.Unlock() // Don't hold the lock during notification: it causes deadlock. + notifySessions(sessions, n, changeNotificationParams[n], s.opts.Logger) } // Sessions returns an iterator that yields the current set of server sessions. @@ -1068,7 +1089,6 @@ func (ss *ServerSession) Elicit(ctx context.Context, params *ElicitParams) (*Eli resolved, err := schema.Resolve(nil) if err != nil { - fmt.Printf(" resolve err: %s", err) return nil, err } if err := resolved.Validate(res.Content); err != nil { diff --git a/mcp/shared.go b/mcp/shared.go index 5e1dc0c..288ed80 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -394,6 +394,8 @@ func notifySessions[S Session, P Params](sessions []S, method string, params P, if sessions == nil { return } + // Notify with the background context, so the messages are sent on the + // standalone stream. // TODO: make this timeout configurable, or call handleNotify asynchronously. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() From b3399e67309abd3886c0b017f09eb6193469cf32 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Thu, 11 Dec 2025 15:13:38 -0500 Subject: [PATCH 152/418] mcp: use args struct for CloseSSEStream (#718) Change CloseSSEStream from func(time.Duration) to func(CloseSSEStreamArgs) for forward compatibility, allowing new fields to be added without breaking existing callers. --- examples/server/conformance/main.go | 4 ++-- mcp/shared.go | 18 ++++++++++-------- mcp/streamable.go | 8 ++++---- mcp/streamable_test.go | 10 +++++----- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/examples/server/conformance/main.go b/examples/server/conformance/main.go index ffee291..70bd4bc 100644 --- a/examples/server/conformance/main.go +++ b/examples/server/conformance/main.go @@ -503,8 +503,8 @@ func jsonSchema202012Handler(ctx context.Context, req *mcp.CallToolRequest, inpu func testReconnectionHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { // Close the SSE stream to trigger client reconnection (SEP-1699) - if req.Extra != nil && req.Extra.CloseStream != nil { - req.Extra.CloseStream(10 * time.Millisecond) + if req.Extra != nil && req.Extra.CloseSSEStream != nil { + req.Extra.CloseSSEStream(mcp.CloseSSEStreamArgs{RetryAfter: 10 * time.Millisecond}) } // Wait for client to reconnect diff --git a/mcp/shared.go b/mcp/shared.go index 288ed80..d83eae7 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -479,22 +479,24 @@ type RequestExtra struct { TokenInfo *auth.TokenInfo // bearer token info (e.g. from OAuth) if any Header http.Header // header from HTTP request, if any - // If set, CloseStream explicitly closes the current request stream. + // If set, CloseSSEStream explicitly closes the current SSE request stream. // // [SEP-1699] introduced server-side SSE stream disconnection: for // long-running requests, servers may opt to close the SSE stream and - // ask the client to retry at a later time. CloseStream implements this - // feature; if reconnectAfter is set, an event is sent with a `retry:` field + // ask the client to retry at a later time. CloseSSEStream implements this + // feature; if RetryAfter is set, an event is sent with a `retry:` field // to configure the reconnection delay. // // [SEP-1699]: https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699 - CloseStream func(reconnectAfter time.Duration) + CloseSSEStream func(CloseSSEStreamArgs) } -// TODO(cleanup): switch to an args struct here for forwards compatibility. -// type CloseStreamArgs struct { -// After time.Duration -// } +// CloseSSEStreamArgs are arguments for [RequestExtra.CloseSSEStream]. +type CloseSSEStreamArgs struct { + // RetryAfter configures the reconnection delay sent to the client via the + // SSE retry field. If zero, no retry field is sent. + RetryAfter time.Duration +} func (*ClientRequest[P]) isRequest() {} func (*ServerRequest[P]) isRequest() {} diff --git a/mcp/streamable.go b/mcp/streamable.go index d6452b2..dcb0bdf 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1065,9 +1065,9 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques } if jreq.IsCall() { calls[jreq.ID] = struct{}{} - // See the doc for CloseStream: allow the request handler to explicitly - // close the ongoing stream. - jreq.Extra.(*RequestExtra).CloseStream = func(reconnectAfter time.Duration) { + // See the doc for CloseSSEStream: allow the request handler to + // explicitly close the ongoing stream. + jreq.Extra.(*RequestExtra).CloseSSEStream = func(args CloseSSEStreamArgs) { c.mu.Lock() streamID, ok := c.requestStreams[jreq.ID] var stream *stream @@ -1077,7 +1077,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques c.mu.Unlock() if stream != nil { - stream.close(reconnectAfter) + stream.close(args.RetryAfter) } } } diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 9eff74b..b1c3f07 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -528,10 +528,10 @@ func TestStreamableServerDisconnect(t *testing.T) { testStream := func(ctx context.Context, session *ServerSession, extra *RequestExtra) { // Close the stream before the first message. We should have sent an // initial priming message already, so the client will be able to replay - extra.CloseStream(10 * time.Millisecond) + extra.CloseSSEStream(CloseSSEStreamArgs{RetryAfter: 10 * time.Millisecond}) session.NotifyProgress(ctx, &ProgressNotificationParams{Message: "msg1"}) time.Sleep(20 * time.Millisecond) - extra.CloseStream(10 * time.Millisecond) // Closing twice should still be supported. + extra.CloseSSEStream(CloseSSEStreamArgs{RetryAfter: 10 * time.Millisecond}) // Closing twice should still be supported. session.NotifyProgress(ctx, &ProgressNotificationParams{Message: "msg2"}) } @@ -1069,7 +1069,7 @@ func TestStreamableServerTransport(t *testing.T) { name: "no close message on old protocol", replay: true, tool: func(t *testing.T, _ context.Context, req *CallToolRequest) { - req.Extra.CloseStream(time.Millisecond) + req.Extra.CloseSSEStream(CloseSSEStreamArgs{RetryAfter: time.Millisecond}) }, requests: []streamableRequest{ initialize, @@ -1105,7 +1105,7 @@ func TestStreamableServerTransport(t *testing.T) { name: "close message on 2025-11-25", replay: true, tool: func(t *testing.T, _ context.Context, req *CallToolRequest) { - req.Extra.CloseStream(time.Millisecond) + req.Extra.CloseSSEStream(CloseSSEStreamArgs{RetryAfter: time.Millisecond}) }, requests: []streamableRequest{ initialize20251125, @@ -1126,7 +1126,7 @@ func TestStreamableServerTransport(t *testing.T) { name: "no close message", replay: true, tool: func(t *testing.T, _ context.Context, req *CallToolRequest) { - req.Extra.CloseStream(0) + req.Extra.CloseSSEStream(CloseSSEStreamArgs{}) }, requests: []streamableRequest{ initialize, From bae09298203a9bb7163c6d5ebbba6a486387a94e Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Thu, 11 Dec 2025 15:16:04 -0500 Subject: [PATCH 153/418] mcp: address review comments on streamable cleanup (#716) Follow up on some post-submit comments from jba. --- mcp/streamable.go | 42 ++++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index dcb0bdf..d46bd2f 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -627,12 +627,15 @@ type stream struct { // If pendingJSONMessages is non-nil, this is a JSON stream and messages are // collected here until the stream is complete, at which point they are - // flushed as a single JSON response. + // flushed as a single JSON response. Note that the non-nilness of this field + // is significant, as it signals the expected content type. + // + // Note: if we remove support for batching, this could just be a bool. pendingJSONMessages []json.RawMessage // w is the HTTP response writer for this stream. A non-nil w indicates - // that the stream is claimed by an HTTP request; it is set to nil when - // the request completes. + // that the stream is claimed by an HTTP request (the hanging POST or GET); + // it is set to nil when the request completes. w http.ResponseWriter // done is closed to release the hanging HTTP request. @@ -646,9 +649,8 @@ type stream struct { // It starts at -1 since indices start at 0. lastIdx int - // supportsPrimeClose indicates whether the client supports prime and close - // events (protocol version 2025-11-25 or later). - supportsPrimeClose bool + // protocolVersion is the protocol version for this stream. + protocolVersion string // requests is the set of unanswered incoming requests for the stream. // @@ -659,8 +661,8 @@ type stream struct { requests map[jsonrpc.ID]struct{} } -// close sends a 'close' event to the client (if supportsPrimeClose is true and -// reconnectAfter > 0) and closes the done channel. +// close sends a 'close' event to the client (if protocolVersion >= 2025-11-25 +// and reconnectAfter > 0) and closes the done channel. // // The done channel is set to nil after closing, so that done != nil implies // the stream is active and done is open. This simplifies checks elsewhere. @@ -670,7 +672,7 @@ func (s *stream) close(reconnectAfter time.Duration) { if s.done == nil { return // stream not connected or already closed } - if s.supportsPrimeClose && reconnectAfter > 0 { + if s.protocolVersion >= protocolVersion20251125 && reconnectAfter > 0 { reconnectStr := strconv.FormatInt(reconnectAfter.Milliseconds(), 10) if _, err := writeEvent(s.w, Event{ Name: "close", @@ -704,9 +706,13 @@ func (s *stream) release() { // // s.mu must be held when calling this method. func (s *stream) deliverLocked(data []byte, eventID string, responseTo jsonrpc.ID) (done bool, err error) { + // First, record the response. We must do this *before* returning an error + // below, as even if the stream is disconnected we want to update our + // accounting. if responseTo.IsValid() { delete(s.requests, responseTo) } + // Now, try to deliver the message to the client. done = len(s.requests) == 0 && s.id != "" if s.done == nil { return done, fmt.Errorf("stream not connected or already closed") @@ -846,9 +852,8 @@ func (c *streamableServerConn) serveGET(w http.ResponseWriter, req *http.Request if protocolVersion == "" { protocolVersion = protocolVersion20250326 } - supportsPrimeClose := protocolVersion >= protocolVersion20251125 - stream, done := c.acquireStream(ctx, w, streamID, lastIdx, supportsPrimeClose) + stream, done := c.acquireStream(ctx, w, streamID, lastIdx, protocolVersion) if stream == nil { return } @@ -883,9 +888,9 @@ func (c *streamableServerConn) hangResponse(ctx context.Context, done <-chan str // all messages, so that no delivery or storage of new messages occurs while // the stream is still replaying. // -// supportsPrimeClose indicates whether the client supports the prime and close -// events (SEP-1699, added in protocol version 2025-11-25 or later). -func (c *streamableServerConn) acquireStream(ctx context.Context, w http.ResponseWriter, streamID string, lastIdx int, supportsPrimeClose bool) (*stream, chan struct{}) { +// protocolVersion is the protocol version for this stream, used to determine +// feature support (e.g. prime and close events were added in 2025-11-25). +func (c *streamableServerConn) acquireStream(ctx context.Context, w http.ResponseWriter, streamID string, lastIdx int, protocolVersion string) (*stream, chan struct{}) { // if tempStream is set, the stream is done and we're just replaying messages. // // We record a temporary stream to claim exclusive replay rights. The spec @@ -986,7 +991,7 @@ func (c *streamableServerConn) acquireStream(ctx context.Context, w http.Respons s.w = w s.done = make(chan struct{}) s.lastIdx = lastIdx - s.supportsPrimeClose = supportsPrimeClose + s.protocolVersion = protocolVersion return s, s.done } @@ -1091,7 +1096,6 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques if isInitialize && initializeProtocolVersion != "" { effectiveVersion = initializeProtocolVersion } - supportsPrimeClose := effectiveVersion >= protocolVersion20251125 // If we don't have any calls, we can just publish the incoming messages and return. // No need to track a logical stream. @@ -1143,13 +1147,15 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques stream.w = w done := make(chan struct{}) stream.done = done - stream.supportsPrimeClose = supportsPrimeClose + stream.protocolVersion = effectiveVersion if c.jsonResponse { // JSON mode: collect messages in pendingJSONMessages until done. + // Set pendingJSONMessages to a non-nil value to signal that this is an + // application/json stream. stream.pendingJSONMessages = []json.RawMessage{} } else { // SSE mode: write a priming event if supported. - if c.eventStore != nil && supportsPrimeClose { + if c.eventStore != nil && effectiveVersion >= protocolVersion20251125 { // Write a priming event, as defined by [§2.1.6] of the spec. // // [§2.1.6]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server From 3430e2242ee2ef165d17915554101505303dfc13 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Thu, 11 Dec 2025 20:17:46 -0500 Subject: [PATCH 154/418] mcp: add Capabilities fields to ServerOptions and ClientOptions (#713) This allows users to configure default capabilities, disable defaults, or suppress listChanged notifications by setting ListChanged: false. Key changes: - Add ServerOptions.Capabilities and ClientOptions.Capabilities fields. - Merge user-provided capabilities with dynamically-added ones (tools, prompts, resources, sampling, elicitation). - Check ListChanged before sending list-changed notifications. - Deprecate HasPrompts, HasResources, HasTools on ServerOptions. - Remove unreleased ElicitationModes field from ClientOptions. - Add clone methods to capability structs using shallowClone helper. - Add synctest-based tests for notification behavior (go1.25). - Add TestClientCapabilitiesOverWire and TestServerCapabilitiesOverWire - Document the new Capabilities API in docs/ Updates #706 --- docs/client.md | 64 ++++++++- docs/rough_edges.md | 9 ++ docs/server.md | 50 +++++++ internal/docs/client.src.md | 61 ++++++++- internal/docs/rough_edges.src.md | 9 ++ internal/docs/server.src.md | 47 +++++++ mcp/capabilities_go125_test.go | 180 +++++++++++++++++++++++++ mcp/client.go | 140 ++++++++++++++++---- mcp/client_test.go | 221 +++++++++++++++++++++++++++++-- mcp/elicitation_test.go | 16 ++- mcp/error_test.go | 8 +- mcp/protocol.go | 44 +++++- mcp/server.go | 105 ++++++++++++++- mcp/server_test.go | 180 +++++++++++++++++++++++-- 14 files changed, 1074 insertions(+), 60 deletions(-) create mode 100644 mcp/capabilities_go125_test.go diff --git a/docs/client.md b/docs/client.md index 6ce20c9..565fae9 100644 --- a/docs/client.md +++ b/docs/client.md @@ -4,6 +4,9 @@ 1. [Roots](#roots) 1. [Sampling](#sampling) 1. [Elicitation](#elicitation) +1. [Capabilities](#capabilities) + 1. [Capability inference](#capability-inference) + 1. [Explicit capabilities](#explicit-capabilities) ## Roots @@ -130,7 +133,9 @@ allows servers to request user inputs. It is implemented in the SDK as follows: **Client-side**: To add the `elicitation` capability to a client, set [`ClientOptions.ElicitationHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ElicitationHandler). The elicitation handler must return a result that matches the requested schema; -otherwise, elicitation returns an error. +otherwise, elicitation returns an error. If your handler supports [URL mode +elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#url-mode-elicitation-requests), +you must declare that capability explicitly (see [Capabilities](#capabilities)) **Server-side**: To use elicitation from the server, call [`ServerSession.Elicit`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.Elicit). @@ -171,3 +176,60 @@ func Example_elicitation() { // Output: value } ``` + +## Capabilities + +Client capabilities are advertised to servers during the initialization +handshake. [By default](rough_edges.md), the SDK advertises the `logging` +capability. Additional capabilities are automatically added when server +features are added (e.g. via `AddTool`), or when handlers are set in the +`ServerOptions` struct (e.g., setting `CompletionHandler` adds the +`completions` capability), or may be configured explicitly. + +### Capability inference + +When handlers are set on `ClientOptions` (e.g., `CreateMessageHandler` or +`ElicitationHandler`), the corresponding capability is automatically added if +not already present, with a default configuration. + +For elicitation, if the handler is set but no `Capabilities.Elicitation` is +specified, the client defaults to form elicitation. To enable URL elicitation +or both modes, [configure `Capabilities.Elicitation` +explicitly](#explicit-capabilities). + +See the [`ClientCapabilities` +documentation](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientCapabilities) +for further details on inference. + +### Explicit capabilities + +To explicitly declare capabilities, or to override the [default inferred +capability](#capability-inference), set +[`ClientOptions.Capabilities`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.Capabilities). +This sets the initial client capabilities, before any capabilities are added +based on configured handlers. If a capability is already present in +`Capabilities`, adding a handler will not change its configuration. + +This allows you to: + +- **Disable default capabilities**: Pass an empty `&ClientCapabilities{}` to + disable all defaults, including roots. +- **Disable listChanged notifications**: Set `ListChanged: false` on a + capability to prevent the client from sending list-changed notifications + when roots are added or removed. +- **Configure elicitation modes**: Specify which elicitation modes (form, URL) + the client supports. + +```go +// Configure elicitation modes and disable roots. +client := mcp.NewClient(impl, &mcp.ClientOptions{ + Capabilities: &mcp.ClientCapabilities{ + Elicitation: &mcp.ElicitationCapabilities{ + Form: &mcp.FormElicitationCapabilities{}, + URL: &mcp.URLElicitationCapabilities{}, + }, + }, + ElicitationHandler: handler, +}) +``` + diff --git a/docs/rough_edges.md b/docs/rough_edges.md index d6dc826..98758cd 100644 --- a/docs/rough_edges.md +++ b/docs/rough_edges.md @@ -36,3 +36,12 @@ v2. **Workaround**: use `ClientCapabilities.RootsV2`, which aligns with the semantics of other capability fields. + +- Default capabilities should have been empty. Instead, servers default to + advertising `logging`, and clients default to advertising `roots` with + `listChanged: true`. This is confusing because a nil `Capabilities` field + does not mean "no capabilities". + + **Workaround**: to advertise no capabilities, set + `ServerOptions.Capabilities` or `ClientOptions.Capabilities` to an empty + `&ServerCapabilities{}` or `&ClientCapabilities{}` respectively. diff --git a/docs/server.md b/docs/server.md index f986c68..4c3c80b 100644 --- a/docs/server.md +++ b/docs/server.md @@ -7,6 +7,9 @@ 1. [Utilities](#utilities) 1. [Completion](#completion) 1. [Logging](#logging) +1. [Capabilities](#capabilities) + 1. [Capability inference](#capability-inference) + 1. [Explicit capabilities](#explicit-capabilities) 1. [Pagination](#pagination) ## Prompts @@ -535,6 +538,53 @@ func Example_logging() { } ``` +## Capabilities + +Server capabilities are advertised to clients during the initialization +handshake. By default, the SDK advertises only the `logging` capability. +Additional capabilities are automatically added when features are registered +(e.g., adding a tool adds the `tools` capability). + +### Capability inference + +When features such as tools, prompts, or resources are added to the server +(e.g., via `Server.AddTool`), their capability is automatically inferred, with +default value `{listChanged:true}`. Similarly, if the +`ServerOptions.SubscribeHandler` or `ServerOptions.CompletionHandler` are set, +the corresponding capability is added. + +### Explicit capabilities + +To explicitly declare capabilities, or to override the [default inferred +capability](#capability-inference), set +[`ServerOptions.Capabilities`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.Capabilities). +This sets the default server capabilities, before any capabilities are added +based on configured handlers. If a capability is already present as a field in +`Capabilities`, adding a feature or handler will not change its configuration. + +This allows you to: + +- **Disable default capabilities**: Pass an empty `&ServerCapabilities{}` to + disable all defaults, including logging. +- **Disable listChanged notifications**: Set `ListChanged: false` on a + capability to prevent the server from sending list-changed notifications + when features are added or removed. +- **Pre-declare capabilities**: Declare capabilities before features are + registered, useful for servers that load features dynamically. + +```go +// Disable listChanged notifications for tools +server := mcp.NewServer(impl, &mcp.ServerOptions{ + Capabilities: &mcp.ServerCapabilities{ + Logging: &mcp.LoggingCapabilities{}, + Tools: &mcp.ToolCapabilities{ListChanged: false}, + }, +}) +``` + +**Deprecated**: The `HasPrompts`, `HasResources`, and `HasTools` fields on +`ServerOptions` are deprecated. Use `Capabilities` instead. + ### Pagination Server-side feature lists may be diff --git a/internal/docs/client.src.md b/internal/docs/client.src.md index fc37d45..f6ae786 100644 --- a/internal/docs/client.src.md +++ b/internal/docs/client.src.md @@ -47,9 +47,68 @@ allows servers to request user inputs. It is implemented in the SDK as follows: **Client-side**: To add the `elicitation` capability to a client, set [`ClientOptions.ElicitationHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ElicitationHandler). The elicitation handler must return a result that matches the requested schema; -otherwise, elicitation returns an error. +otherwise, elicitation returns an error. If your handler supports [URL mode +elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#url-mode-elicitation-requests), +you must declare that capability explicitly (see [Capabilities](#capabilities)) **Server-side**: To use elicitation from the server, call [`ServerSession.Elicit`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.Elicit). %include ../../mcp/client_example_test.go elicitation - + +## Capabilities + +Client capabilities are advertised to servers during the initialization +handshake. [By default](rough_edges.md), the SDK advertises the `logging` +capability. Additional capabilities are automatically added when server +features are added (e.g. via `AddTool`), or when handlers are set in the +`ServerOptions` struct (e.g., setting `CompletionHandler` adds the +`completions` capability), or may be configured explicitly. + +### Capability inference + +When handlers are set on `ClientOptions` (e.g., `CreateMessageHandler` or +`ElicitationHandler`), the corresponding capability is automatically added if +not already present, with a default configuration. + +For elicitation, if the handler is set but no `Capabilities.Elicitation` is +specified, the client defaults to form elicitation. To enable URL elicitation +or both modes, [configure `Capabilities.Elicitation` +explicitly](#explicit-capabilities). + +See the [`ClientCapabilities` +documentation](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientCapabilities) +for further details on inference. + +### Explicit capabilities + +To explicitly declare capabilities, or to override the [default inferred +capability](#capability-inference), set +[`ClientOptions.Capabilities`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.Capabilities). +This sets the initial client capabilities, before any capabilities are added +based on configured handlers. If a capability is already present in +`Capabilities`, adding a handler will not change its configuration. + +This allows you to: + +- **Disable default capabilities**: Pass an empty `&ClientCapabilities{}` to + disable all defaults, including roots. +- **Disable listChanged notifications**: Set `ListChanged: false` on a + capability to prevent the client from sending list-changed notifications + when roots are added or removed. +- **Configure elicitation modes**: Specify which elicitation modes (form, URL) + the client supports. + +```go +// Configure elicitation modes and disable roots. +client := mcp.NewClient(impl, &mcp.ClientOptions{ + Capabilities: &mcp.ClientCapabilities{ + Elicitation: &mcp.ElicitationCapabilities{ + Form: &mcp.FormElicitationCapabilities{}, + URL: &mcp.URLElicitationCapabilities{}, + }, + }, + ElicitationHandler: handler, +}) +``` + diff --git a/internal/docs/rough_edges.src.md b/internal/docs/rough_edges.src.md index 4566032..4dc3219 100644 --- a/internal/docs/rough_edges.src.md +++ b/internal/docs/rough_edges.src.md @@ -35,3 +35,12 @@ v2. **Workaround**: use `ClientCapabilities.RootsV2`, which aligns with the semantics of other capability fields. + +- Default capabilities should have been empty. Instead, servers default to + advertising `logging`, and clients default to advertising `roots` with + `listChanged: true`. This is confusing because a nil `Capabilities` field + does not mean "no capabilities". + + **Workaround**: to advertise no capabilities, set + `ServerOptions.Capabilities` or `ClientOptions.Capabilities` to an empty + `&ServerCapabilities{}` or `&ClientCapabilities{}` respectively. diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index c09ba63..c6946db 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -243,6 +243,53 @@ Call [`ClientSession.SetLevel`](https://pkg.go.dev/github.com/modelcontextprotoc %include ../../mcp/server_example_test.go logging - +## Capabilities + +Server capabilities are advertised to clients during the initialization +handshake. By default, the SDK advertises only the `logging` capability. +Additional capabilities are automatically added when features are registered +(e.g., adding a tool adds the `tools` capability). + +### Capability inference + +When features such as tools, prompts, or resources are added to the server +(e.g., via `Server.AddTool`), their capability is automatically inferred, with +default value `{listChanged:true}`. Similarly, if the +`ServerOptions.SubscribeHandler` or `ServerOptions.CompletionHandler` are set, +the corresponding capability is added. + +### Explicit capabilities + +To explicitly declare capabilities, or to override the [default inferred +capability](#capability-inference), set +[`ServerOptions.Capabilities`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.Capabilities). +This sets the default server capabilities, before any capabilities are added +based on configured handlers. If a capability is already present as a field in +`Capabilities`, adding a feature or handler will not change its configuration. + +This allows you to: + +- **Disable default capabilities**: Pass an empty `&ServerCapabilities{}` to + disable all defaults, including logging. +- **Disable listChanged notifications**: Set `ListChanged: false` on a + capability to prevent the server from sending list-changed notifications + when features are added or removed. +- **Pre-declare capabilities**: Declare capabilities before features are + registered, useful for servers that load features dynamically. + +```go +// Disable listChanged notifications for tools +server := mcp.NewServer(impl, &mcp.ServerOptions{ + Capabilities: &mcp.ServerCapabilities{ + Logging: &mcp.LoggingCapabilities{}, + Tools: &mcp.ToolCapabilities{ListChanged: false}, + }, +}) +``` + +**Deprecated**: The `HasPrompts`, `HasResources`, and `HasTools` fields on +`ServerOptions` are deprecated. Use `Capabilities` instead. + ### Pagination Server-side feature lists may be diff --git a/mcp/capabilities_go125_test.go b/mcp/capabilities_go125_test.go new file mode 100644 index 0000000..4f959df --- /dev/null +++ b/mcp/capabilities_go125_test.go @@ -0,0 +1,180 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +//go:build go1.25 + +package mcp + +import ( + "context" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + "github.com/google/jsonschema-go/jsonschema" +) + +// TestServerListChangedNotifications verifies that listChanged notifications +// are correctly sent or suppressed based on capability configuration. +func TestServerListChangedNotifications(t *testing.T) { + tool := &Tool{Name: "test-tool", InputSchema: &jsonschema.Schema{Type: "object"}} + + testCases := []struct { + name string + serverOpts *ServerOptions + wantNotifyCount int64 + }{ + { + name: "Default: notification sent", + serverOpts: nil, + wantNotifyCount: 1, + }, + { + name: "ListChanged false: notification suppressed", + serverOpts: &ServerOptions{ + Capabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: false}, + }, + }, + wantNotifyCount: 0, + }, + { + name: "ListChanged true: notification sent", + serverOpts: &ServerOptions{ + Capabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: true}, + }, + }, + wantNotifyCount: 1, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := context.Background() + + // Create server. + impl := &Implementation{Name: "testServer", Version: "v1.0.0"} + server := NewServer(impl, tc.serverOpts) + + // Track notifications. + var notifyCount atomic.Int64 + + // Connect client and server. + cTransport, sTransport := NewInMemoryTransports() + ss, err := server.Connect(ctx, sTransport, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, &ClientOptions{ + ToolListChangedHandler: func(ctx context.Context, req *ToolListChangedRequest) { + notifyCount.Add(1) + }, + }) + cs, err := client.Connect(ctx, cTransport, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + // Add a tool, which may or may not trigger notification. + server.AddTool(tool, nil) + + // Sleep an arbitrary time longer than the debounce delay (synctest + // makes this practical). + time.Sleep(1 * time.Second) + + // Wait for all goroutines to be blocked (notification delivered). + synctest.Wait() + + if got, want := notifyCount.Load(), tc.wantNotifyCount; got != want { + t.Errorf("notification count: got %d, want %d", got, want) + } + }) + }) + } +} + +// TestClientListChangedNotifications verifies that roots listChanged notifications +// are correctly sent or suppressed based on client capability configuration. +func TestClientListChangedNotifications(t *testing.T) { + root := &Root{URI: "file:///test"} + + testCases := []struct { + name string + clientOpts *ClientOptions + wantNotifyCount int64 + }{ + { + name: "Default: notification sent", + clientOpts: nil, + wantNotifyCount: 1, + }, + { + name: "ListChanged false: notification suppressed", + clientOpts: &ClientOptions{ + Capabilities: &ClientCapabilities{ + RootsV2: &RootCapabilities{ListChanged: false}, + }, + }, + wantNotifyCount: 0, + }, + { + name: "ListChanged true: notification sent", + clientOpts: &ClientOptions{ + Capabilities: &ClientCapabilities{ + RootsV2: &RootCapabilities{ListChanged: true}, + }, + }, + wantNotifyCount: 1, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := context.Background() + + // Track notifications. + var notifyCount atomic.Int64 + + // Create server with roots list changed handler. + server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, &ServerOptions{ + RootsListChangedHandler: func(ctx context.Context, req *RootsListChangedRequest) { + notifyCount.Add(1) + }, + }) + + // Connect client and server. + cTransport, sTransport := NewInMemoryTransports() + ss, err := server.Connect(ctx, sTransport, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, tc.clientOpts) + cs, err := client.Connect(ctx, cTransport, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + // Add a root, which may or may not trigger notification. + client.AddRoots(root) + + // Wait for all goroutines to be blocked (notification delivered). + synctest.Wait() + + if got, want := notifyCount.Load(), tc.wantNotifyCount; got != want { + t.Errorf("notification count: got %d, want %d", got, want) + } + }) + }) + } +} diff --git a/mcp/client.go b/mcp/client.go index 612b12f..2dc1a86 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -63,17 +63,57 @@ func NewClient(impl *Implementation, opts *ClientOptions) *Client { type ClientOptions struct { // CreateMessageHandler handles incoming requests for sampling/createMessage. // - // Setting CreateMessageHandler to a non-nil value causes the client to - // advertise the sampling capability. + // Setting CreateMessageHandler to a non-nil value automatically causes the + // client to advertise the sampling capability, with default value + // &SamplingCapabilities{}. If [ClientOptions.Capabilities] is set and has a + // non nil value for [ClientCapabilities.Sampling], that value overrides the + // inferred capability. CreateMessageHandler func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) // ElicitationHandler handles incoming requests for elicitation/create. // - // Setting ElicitationHandler to a non-nil value causes the client to - // advertise the elicitation capability. + // Setting ElicitationHandler to a non-nil value automatically causes the + // client to advertise the elicitation capability, with default value + // &ElicitationCapabilities{}. If [ClientOptions.Capabilities] is set and has + // a non nil value for [ClientCapabilities.ELicitattion], that value + // overrides the inferred capability. ElicitationHandler func(context.Context, *ElicitRequest) (*ElicitResult, error) - // ElicitationModes specifies the elicitation modes supported by the client. - // If ElicitationHandler is set and ElicitationModes is empty, it defaults to ["form"]. - ElicitationModes []string + // Capabilities optionally configures the client's default capabilities, + // before any capabilities are inferred from other configuration. + // + // If Capabilities is nil, the default client capabilities are + // {"roots":{"listChanged":true}}, for historical reasons. Setting + // Capabilities to a non-nil value overrides this default. As a special case, + // to work around #607, Capabilities.Roots is ignored: set + // Capabilities.RootsV2 to configure the roots capability. This allows the + // "roots" capability to be disabled entirely. + // + // For example: + // - To disable the "roots" capability, use &ClientCapabilities{} + // - To configure "roots", but disable "listChanged" notifications, use + // &ClientCapabilities{RootsV2:&RootCapabilities{}}. + // + // # Interaction with capability inference + // + // Sampling and elicitation capabilities are automatically added when their + // corresponding handlers are set, with the default value described at + // [ClientOptions.CreateMessageHandler] and + // [ClientOptions.ElicitationHandler]. If the Sampling or Elicitation fields + // are set in the Capabilities field, their values override the inferred + // value. + // + // For example, to to configure elicitation modes: + // + // Capabilities: &ClientCapabilities{ + // Elicitation: &ElicitationCapabilities{ + // Form: &FormElicitationCapabilities{}, + // URL: &URLElicitationCapabilities{}, + // }, + // } + // + // Conversely, if Capabilities does not set a field (for example, if the + // Elicitation field is nil), the inferred elicitation capability will be + // used. + Capabilities *ClientCapabilities // ElicitationCompleteHandler handles incoming notifications for notifications/elicitation/complete. ElicitationCompleteHandler func(context.Context, *ElicitationCompleteNotificationRequest) // Handlers for notifications from the server. @@ -129,27 +169,43 @@ type ClientSessionOptions struct { protocolVersion string } -func (c *Client) capabilities() *ClientCapabilities { - caps := &ClientCapabilities{} - // Due to an oversight (#607), roots require special handling. - caps.Roots.ListChanged = true - caps.RootsV2 = &RootCapabilities{ - ListChanged: true, - } - if c.opts.CreateMessageHandler != nil { - caps.Sampling = &SamplingCapabilities{} - } - if c.opts.ElicitationHandler != nil { - caps.Elicitation = &ElicitationCapabilities{} - modes := c.opts.ElicitationModes - if len(modes) == 0 || slices.Contains(modes, "form") { - // Technically, the empty ElicitationCapabilities value is equivalent to - // {"form":{}} for backward compatibility, but we explicitly set the form - // capability. - caps.Elicitation.Form = &FormElicitationCapabilities{} +func (c *Client) capabilities(protocolVersion string) *ClientCapabilities { + // Start with user-provided capabilities as defaults, or use SDK defaults. + var caps *ClientCapabilities + if c.opts.Capabilities != nil { + // Deep copy the user-provided capabilities to avoid mutation. + caps = c.opts.Capabilities.clone() + } else { + // SDK defaults: roots with listChanged. + // (this was the default behavior at v1.0.0, and so cannot be changed) + caps = &ClientCapabilities{ + RootsV2: &RootCapabilities{ + ListChanged: true, + }, } - if slices.Contains(modes, "url") { - caps.Elicitation.URL = &URLElicitationCapabilities{} + } + + // Sync Roots from RootsV2 for backward compatibility (#607). + if caps.RootsV2 != nil { + caps.Roots = *caps.RootsV2 + } + + // Augment with sampling capability if handler is set. + if c.opts.CreateMessageHandler != nil { + if caps.Sampling == nil { + caps.Sampling = &SamplingCapabilities{} + } + } + + // Augment with elicitation capability if handler is set. + if c.opts.ElicitationHandler != nil { + if caps.Elicitation == nil { + caps.Elicitation = &ElicitationCapabilities{} + // Form elicitation was added in 2025-11-25; for older versions, + // {} is treated the same as {"form":{}}. + if protocolVersion >= protocolVersion20251125 { + caps.Elicitation.Form = &FormElicitationCapabilities{} + } } } return caps @@ -175,7 +231,7 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp params := &InitializeParams{ ProtocolVersion: protocolVersion, ClientInfo: c.impl, - Capabilities: c.capabilities(), + Capabilities: c.capabilities(protocolVersion), } req := &InitializeRequest{Session: cs, Params: params} res, err := handleSend[*InitializeResult](ctx, methodInitialize, req) @@ -345,12 +401,38 @@ func changeAndNotify[P Params](c *Client, notification string, params P, change // Lock for the change, but not for the notification. c.mu.Lock() if change() { - sessions = slices.Clone(c.sessions) + // Check if listChanged is enabled for this notification type. + if c.shouldSendListChangedNotification(notification) { + sessions = slices.Clone(c.sessions) + } } c.mu.Unlock() notifySessions(sessions, notification, params, c.logger) } +// shouldSendListChangedNotification checks if the client's capabilities allow +// sending the given list-changed notification. +func (c *Client) shouldSendListChangedNotification(notification string) bool { + // Get effective capabilities (considering user-provided defaults). + caps := c.opts.Capabilities + + switch notification { + case notificationRootsListChanged: + // If user didn't specify capabilities, default behavior sends notifications. + if caps == nil { + return true + } + // Check RootsV2 first (preferred), then fall back to Roots. + if caps.RootsV2 != nil { + return caps.RootsV2.ListChanged + } + return caps.Roots.ListChanged + default: + // Unknown notification, allow by default. + return true + } +} + func (c *Client) listRoots(_ context.Context, req *ListRootsRequest) (*ListRootsResult, error) { c.mu.Lock() defer c.mu.Unlock() diff --git a/mcp/client_test.go b/mcp/client_test.go index 0522c06..ad8c0f1 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -196,10 +196,11 @@ func TestClientCapabilities(t *testing.T) { name string configureClient func(s *Client) clientOpts ClientOptions + protocolVersion string // defaults to latestProtocolVersion if empty wantCapabilities *ClientCapabilities }{ { - name: "With initial capabilities", + name: "default", configureClient: func(s *Client) {}, wantCapabilities: &ClientCapabilities{ Roots: RootCapabilities{ListChanged: true}, @@ -207,7 +208,7 @@ func TestClientCapabilities(t *testing.T) { }, }, { - name: "With sampling", + name: "with sampling", configureClient: func(s *Client) {}, clientOpts: ClientOptions{ CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) { @@ -221,14 +222,14 @@ func TestClientCapabilities(t *testing.T) { }, }, { - name: "With form elicitation", + name: "with elicitation", configureClient: func(s *Client) {}, clientOpts: ClientOptions{ - ElicitationModes: []string{"form"}, ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) { return nil, nil }, }, + protocolVersion: protocolVersion20251125, wantCapabilities: &ClientCapabilities{ Roots: RootCapabilities{ListChanged: true}, RootsV2: &RootCapabilities{ListChanged: true}, @@ -238,10 +239,31 @@ func TestClientCapabilities(t *testing.T) { }, }, { - name: "With URL elicitation", + name: "with elicitation (old protocol)", configureClient: func(s *Client) {}, clientOpts: ClientOptions{ - ElicitationModes: []string{"url"}, + ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) { + return nil, nil + }, + }, + protocolVersion: protocolVersion20250618, + wantCapabilities: &ClientCapabilities{ + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, + Elicitation: &ElicitationCapabilities{}, + }, + }, + { + name: "with URL elicitation", + configureClient: func(s *Client) {}, + clientOpts: ClientOptions{ + Capabilities: &ClientCapabilities{ + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, + Elicitation: &ElicitationCapabilities{ + URL: &URLElicitationCapabilities{}, + }, + }, ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) { return nil, nil }, @@ -255,10 +277,17 @@ func TestClientCapabilities(t *testing.T) { }, }, { - name: "With both form and URL elicitation", + name: "with form and URL elicitation", configureClient: func(s *Client) {}, clientOpts: ClientOptions{ - ElicitationModes: []string{"form", "url"}, + Capabilities: &ClientCapabilities{ + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, + Elicitation: &ElicitationCapabilities{ + Form: &FormElicitationCapabilities{}, + URL: &URLElicitationCapabilities{}, + }, + }, ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) { return nil, nil }, @@ -272,16 +301,190 @@ func TestClientCapabilities(t *testing.T) { }, }, }, + { + name: "no capabilities", + configureClient: func(s *Client) {}, + clientOpts: ClientOptions{ + Capabilities: &ClientCapabilities{}, + }, + wantCapabilities: &ClientCapabilities{}, + }, + { + name: "no roots", + configureClient: func(s *Client) {}, + clientOpts: ClientOptions{ + Capabilities: &ClientCapabilities{ + Sampling: &SamplingCapabilities{}, + }, + }, + wantCapabilities: &ClientCapabilities{ + Sampling: &SamplingCapabilities{}, + }, + }, + { + name: "roots-no list", + configureClient: func(s *Client) {}, + clientOpts: ClientOptions{ + Capabilities: &ClientCapabilities{ + RootsV2: &RootCapabilities{ListChanged: false}, + }, + }, + wantCapabilities: &ClientCapabilities{ + RootsV2: &RootCapabilities{ListChanged: false}, + }, + }, + { + name: "custom capabilities with sampling", + configureClient: func(s *Client) {}, + clientOpts: ClientOptions{ + Capabilities: &ClientCapabilities{ + RootsV2: &RootCapabilities{ListChanged: true}, + }, + CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) { + return nil, nil + }, + }, + wantCapabilities: &ClientCapabilities{ + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, + Sampling: &SamplingCapabilities{}, + }, + }, + { + name: "elicitation override", + configureClient: func(s *Client) {}, + clientOpts: ClientOptions{ + Capabilities: &ClientCapabilities{ + Elicitation: &ElicitationCapabilities{ + URL: &URLElicitationCapabilities{}, + }, + }, + ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) { + return nil, nil + }, + }, + wantCapabilities: &ClientCapabilities{ + Elicitation: &ElicitationCapabilities{ + URL: &URLElicitationCapabilities{}, + }, + }, + }, + { + name: "custom capabilities with experimental", + configureClient: func(s *Client) {}, + clientOpts: ClientOptions{ + Capabilities: &ClientCapabilities{ + Experimental: map[string]any{"custom": "value"}, + RootsV2: &RootCapabilities{ListChanged: true}, + }, + }, + wantCapabilities: &ClientCapabilities{ + Experimental: map[string]any{"custom": "value"}, + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, + }, + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { client := NewClient(testImpl, &tc.clientOpts) tc.configureClient(client) - gotCapabilities := client.capabilities() + protocolVersion := tc.protocolVersion + if protocolVersion == "" { + protocolVersion = latestProtocolVersion + } + gotCapabilities := client.capabilities(protocolVersion) if diff := cmp.Diff(tc.wantCapabilities, gotCapabilities); diff != "" { t.Errorf("capabilities() mismatch (-want +got):\n%s", diff) } }) } } + +func TestClientCapabilitiesOverWire(t *testing.T) { + testCases := []struct { + name string + clientOpts *ClientOptions + wantCapabilities *ClientCapabilities + }{ + { + name: "Default capabilities", + clientOpts: nil, + wantCapabilities: &ClientCapabilities{ + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, + }, + }, + { + name: "Custom Capabilities with roots listChanged false", + clientOpts: &ClientOptions{ + Capabilities: &ClientCapabilities{ + RootsV2: &RootCapabilities{ListChanged: false}, + }, + }, + wantCapabilities: &ClientCapabilities{ + Roots: RootCapabilities{ListChanged: false}, + RootsV2: &RootCapabilities{ListChanged: false}, + }, + }, + { + name: "Dynamic sampling capability", + clientOpts: &ClientOptions{ + Capabilities: &ClientCapabilities{ + RootsV2: &RootCapabilities{ListChanged: true}, + }, + CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) { + return nil, nil + }, + }, + wantCapabilities: &ClientCapabilities{ + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, + Sampling: &SamplingCapabilities{}, + }, + }, + { + name: "Empty capabilities disables defaults", + clientOpts: &ClientOptions{ + Capabilities: &ClientCapabilities{}, + }, + wantCapabilities: &ClientCapabilities{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + + // Create client. + impl := &Implementation{Name: "testClient", Version: "v1.0.0"} + client := NewClient(impl, tc.clientOpts) + + // Connect client and server. + cTransport, sTransport := NewInMemoryTransports() + server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) + ss, err := server.Connect(ctx, sTransport, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + cs, err := client.Connect(ctx, cTransport, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + // Check that the server received the expected capabilities. + initParams := ss.InitializeParams() + if initParams == nil { + t.Fatal("InitializeParams is nil") + } + + if diff := cmp.Diff(tc.wantCapabilities, initParams.Capabilities); diff != "" { + t.Errorf("Capabilities mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/mcp/elicitation_test.go b/mcp/elicitation_test.go index 8da033e..52ae1d7 100644 --- a/mcp/elicitation_test.go +++ b/mcp/elicitation_test.go @@ -105,7 +105,13 @@ func TestElicitationURLMode(t *testing.T) { defer ss.Close() c := NewClient(testImpl, &ClientOptions{ - ElicitationModes: []string{"url"}, + Capabilities: &ClientCapabilities{ + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, + Elicitation: &ElicitationCapabilities{ + URL: &URLElicitationCapabilities{}, + }, + }, ElicitationHandler: tc.handler, }) cs, err := c.Connect(ctx, ct, nil) @@ -143,7 +149,13 @@ func TestElicitationCompleteNotification(t *testing.T) { var elicitationCompleteCh = make(chan *ElicitationCompleteParams, 1) c := NewClient(testImpl, &ClientOptions{ - ElicitationModes: []string{"url"}, + Capabilities: &ClientCapabilities{ + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, + Elicitation: &ElicitationCapabilities{ + URL: &URLElicitationCapabilities{}, + }, + }, ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) { return &ElicitResult{Action: "accept"}, nil }, diff --git a/mcp/error_test.go b/mcp/error_test.go index 694ef10..dad47aa 100644 --- a/mcp/error_test.go +++ b/mcp/error_test.go @@ -213,7 +213,13 @@ func TestURLElicitationRequired(t *testing.T) { // Create client with elicitation handler and middleware. client := NewClient(testImpl, &ClientOptions{ - ElicitationModes: []string{"url"}, + Capabilities: &ClientCapabilities{ + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, + Elicitation: &ElicitationCapabilities{ + URL: &URLElicitationCapabilities{}, + }, + }, ElicitationHandler: func(ctx context.Context, req *ElicitRequest) (*ElicitResult, error) { elicitCalled = true elicitURL = req.Params.URL diff --git a/mcp/protocol.go b/mcp/protocol.go index 563b379..bfac145 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -188,6 +188,10 @@ type RootCapabilities struct { // this schema, but this is not a closed set: any client can define its own, // additional capabilities. type ClientCapabilities struct { + + // NOTE: any addition to ClientCapabilities must also be reflected in + // [ClientCapabilities.clone]. + // Experimental reports non-standard capabilities that the client supports. Experimental map[string]any `json:"experimental,omitempty"` // Roots describes the client's support for roots. @@ -200,7 +204,7 @@ type ClientCapabilities struct { // changes to the roots list. ListChanged bool `json:"listChanged,omitempty"` } `json:"roots,omitempty"` - // RootsV2 is present if the client supports roots. + // RootsV2 is present if the client supports roots. When capabilities are explicitly configured via [ClientOptions.Capabilities] RootsV2 *RootCapabilities `json:"-"` // Sampling is present if the client supports sampling from an LLM. Sampling *SamplingCapabilities `json:"sampling,omitempty"` @@ -208,6 +212,29 @@ type ClientCapabilities struct { Elicitation *ElicitationCapabilities `json:"elicitation,omitempty"` } +// clone returns a deep copy of the ClientCapabilities. +func (c *ClientCapabilities) clone() *ClientCapabilities { + cp := *c + cp.RootsV2 = shallowClone(c.RootsV2) + cp.Sampling = shallowClone(c.Sampling) + if c.Elicitation != nil { + x := *c.Elicitation + x.Form = shallowClone(c.Elicitation.Form) + x.URL = shallowClone(c.Elicitation.URL) + cp.Elicitation = &x + } + return &cp +} + +// shallowClone returns a shallow clone of *p, or nil if p is nil. +func shallowClone[T any](p *T) *T { + if p == nil { + return nil + } + x := *p + return &x +} + func (c *ClientCapabilities) toV2() *clientCapabilitiesV2 { return &clientCapabilitiesV2{ ClientCapabilities: *c, @@ -1259,6 +1286,10 @@ type ToolCapabilities struct { // ServerCapabilities describes capabilities that a server supports. type ServerCapabilities struct { + + // NOTE: any addition to ServerCapabilities must also be reflected in + // [ServerCapabilities.clone]. + // Experimental reports non-standard capabilities that the server supports. Experimental map[string]any `json:"experimental,omitempty"` // Completions is present if the server supports argument autocompletion @@ -1274,6 +1305,17 @@ type ServerCapabilities struct { Tools *ToolCapabilities `json:"tools,omitempty"` } +// clone returns a deep copy of the ServerCapabilities. +func (c *ServerCapabilities) clone() *ServerCapabilities { + cp := *c + cp.Completions = shallowClone(c.Completions) + cp.Logging = shallowClone(c.Logging) + cp.Prompts = shallowClone(c.Prompts) + cp.Resources = shallowClone(c.Resources) + cp.Tools = shallowClone(c.Tools) + return &cp +} + const ( methodCallTool = "tools/call" notificationCancelled = "notifications/cancelled" diff --git a/mcp/server.go b/mcp/server.go index 776924a..1f7edf9 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -81,14 +81,51 @@ type ServerOptions struct { SubscribeHandler func(context.Context, *SubscribeRequest) error // Function called when a client session unsubscribes from a resource. UnsubscribeHandler func(context.Context, *UnsubscribeRequest) error + + // Capabilities optionally configures the server's default capabilities, + // before any capabilities are inferred from other configuration or server + // features. + // + // If Capabilities is nil, the default server capabilities are {"logging":{}}, + // for historical reasons. Setting Capabilities to a non-nil value overrides + // this default. For example, setting Capabilities to `&ServerCapabilities{}` + // disables the logging capability. + // + // # Interaction with capability inference + // + // "tools", "prompts", and "resources" capabilities are automatically added when + // tools, prompts, or resources are added to the server (for example, via + // [Server.AddPrompt]), with default value `{"listChanged":true}`. Similarly, + // if the [ClientOptions.SubscribeHandler] or + // [ClientOptions.CompletionHandler] are set, the inferred capabilities are + // adjusted accordingly. + // + // Any non-nil field in Capabilities overrides the inferred value. + // For example: + // + // - To advertise the "tools" capability, even if no tools are added, set + // Capabilities.Tools to &ToolCapabilities{ListChanged:true}. + // - To disable tool list notifications, set Capabilities.Tools to + // &ToolCapabilities{}. + // + // Conversely, if Capabilities does not set a field (for example, if the + // Prompts field is nil), the inferred capability will be used. + Capabilities *ServerCapabilities + // If true, advertises the prompts capability during initialization, // even if no prompts have been registered. + // + // Deprecated: Use Capabilities instead. HasPrompts bool // If true, advertises the resources capability during initialization, // even if no resources have been registered. + // + // Deprecated: Use Capabilities instead. HasResources bool // If true, advertises the tools capability during initialization, // even if no tools have been registered. + // + // Deprecated: Use Capabilities instead. HasTools bool // GetSessionID provides the next session ID to use for an incoming request. @@ -465,24 +502,49 @@ func (s *Server) capabilities() *ServerCapabilities { s.mu.Lock() defer s.mu.Unlock() - caps := &ServerCapabilities{ - Logging: &LoggingCapabilities{}, + // Start with user-provided capabilities as defaults, or use SDK defaults. + var caps *ServerCapabilities + if s.opts.Capabilities != nil { + // Deep copy the user-provided capabilities to avoid mutation. + caps = s.opts.Capabilities.clone() + } else { + // SDK defaults: only logging capability. + caps = &ServerCapabilities{ + Logging: &LoggingCapabilities{}, + } } + + // Augment with tools capability if tools exist or legacy HasTools is set. if s.opts.HasTools || s.tools.len() > 0 { - caps.Tools = &ToolCapabilities{ListChanged: true} + if caps.Tools == nil { + caps.Tools = &ToolCapabilities{ListChanged: true} + } } + + // Augment with prompts capability if prompts exist or legacy HasPrompts is set. if s.opts.HasPrompts || s.prompts.len() > 0 { - caps.Prompts = &PromptCapabilities{ListChanged: true} + if caps.Prompts == nil { + caps.Prompts = &PromptCapabilities{ListChanged: true} + } } + + // Augment with resources capability if resources/templates exist or legacy HasResources is set. if s.opts.HasResources || s.resources.len() > 0 || s.resourceTemplates.len() > 0 { - caps.Resources = &ResourceCapabilities{ListChanged: true} + if caps.Resources == nil { + caps.Resources = &ResourceCapabilities{ListChanged: true} + } if s.opts.SubscribeHandler != nil { caps.Resources.Subscribe = true } } + + // Augment with completions capability if handler is set. if s.opts.CompletionHandler != nil { - caps.Completions = &CompletionCapabilities{} + if caps.Completions == nil { + caps.Completions = &CompletionCapabilities{} + } } + return caps } @@ -512,7 +574,7 @@ const notificationDelay = 10 * time.Millisecond func (s *Server) changeAndNotify(notification string, change func() bool) { s.mu.Lock() defer s.mu.Unlock() - if change() { + if change() && s.shouldSendListChangedNotification(notification) { // Reset the outstanding delayed call, if any. if t := s.pendingNotifications[notification]; t == nil { s.pendingNotifications[notification] = time.AfterFunc(notificationDelay, func() { s.notifySessions(notification) }) @@ -532,6 +594,35 @@ func (s *Server) notifySessions(n string) { notifySessions(sessions, n, changeNotificationParams[n], s.opts.Logger) } +// shouldSendListChangedNotification checks if the server's capabilities allow +// sending the given list-changed notification. +func (s *Server) shouldSendListChangedNotification(notification string) bool { + // Get effective capabilities (considering user-provided defaults). + caps := s.opts.Capabilities + + switch notification { + case notificationToolListChanged: + // If user didn't specify capabilities, default behavior sends notifications. + if caps == nil || caps.Tools == nil { + return true + } + return caps.Tools.ListChanged + case notificationPromptListChanged: + if caps == nil || caps.Prompts == nil { + return true + } + return caps.Prompts.ListChanged + case notificationResourceListChanged: + if caps == nil || caps.Resources == nil { + return true + } + return caps.Resources.ListChanged + default: + // Unknown notification, allow by default. + return true + } +} + // Sessions returns an iterator that yields the current set of server sessions. // // There is no guarantee that the iterator observes sessions that are added or diff --git a/mcp/server_test.go b/mcp/server_test.go index b578cbf..090ccdc 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -245,14 +245,14 @@ func TestServerCapabilities(t *testing.T) { wantCapabilities *ServerCapabilities }{ { - name: "No capabilities", + name: "no capabilities", configureServer: func(s *Server) {}, wantCapabilities: &ServerCapabilities{ Logging: &LoggingCapabilities{}, }, }, { - name: "With prompts", + name: "with prompts", configureServer: func(s *Server) { s.AddPrompt(&Prompt{Name: "p"}, nil) }, @@ -262,7 +262,7 @@ func TestServerCapabilities(t *testing.T) { }, }, { - name: "With resources", + name: "with resources", configureServer: func(s *Server) { s.AddResource(&Resource{URI: "file:///r"}, nil) }, @@ -272,7 +272,7 @@ func TestServerCapabilities(t *testing.T) { }, }, { - name: "With resource templates", + name: "with resource templates", configureServer: func(s *Server) { s.AddResourceTemplate(&ResourceTemplate{URITemplate: "file:///rt"}, nil) }, @@ -282,7 +282,7 @@ func TestServerCapabilities(t *testing.T) { }, }, { - name: "With resource subscriptions", + name: "with resource subscriptions", configureServer: func(s *Server) { s.AddResourceTemplate(&ResourceTemplate{URITemplate: "file:///rt"}, nil) }, @@ -300,7 +300,7 @@ func TestServerCapabilities(t *testing.T) { }, }, { - name: "With tools", + name: "with tools", configureServer: func(s *Server) { s.AddTool(tool, nil) }, @@ -310,7 +310,7 @@ func TestServerCapabilities(t *testing.T) { }, }, { - name: "With completions", + name: "with completions", configureServer: func(s *Server) {}, serverOpts: ServerOptions{ CompletionHandler: func(context.Context, *CompleteRequest) (*CompleteResult, error) { @@ -323,7 +323,7 @@ func TestServerCapabilities(t *testing.T) { }, }, { - name: "With all capabilities", + name: "all capabilities", configureServer: func(s *Server) { s.AddPrompt(&Prompt{Name: "p"}, nil) s.AddResource(&Resource{URI: "file:///r"}, nil) @@ -350,7 +350,7 @@ func TestServerCapabilities(t *testing.T) { }, }, { - name: "With initial capabilities", + name: "has features", configureServer: func(s *Server) {}, serverOpts: ServerOptions{ HasPrompts: true, @@ -364,6 +364,83 @@ func TestServerCapabilities(t *testing.T) { Tools: &ToolCapabilities{ListChanged: true}, }, }, + { + name: "empty capabilities", + configureServer: func(s *Server) {}, + serverOpts: ServerOptions{ + Capabilities: &ServerCapabilities{}, + }, + wantCapabilities: &ServerCapabilities{}, + }, + { + name: "no logging", + configureServer: func(s *Server) {}, + serverOpts: ServerOptions{ + Capabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: true}, + }, + }, + wantCapabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: true}, + }, + }, + { + name: "no list", + configureServer: func(s *Server) {}, + serverOpts: ServerOptions{ + Capabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: false}, + Prompts: &PromptCapabilities{ListChanged: false}, + }, + }, + wantCapabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: false}, + Prompts: &PromptCapabilities{ListChanged: false}, + }, + }, + { + name: "adding tools-list", + configureServer: func(s *Server) { + s.AddTool(tool, nil) + }, + serverOpts: ServerOptions{ + Capabilities: &ServerCapabilities{ + Logging: &LoggingCapabilities{}, + }, + }, + wantCapabilities: &ServerCapabilities{ + Logging: &LoggingCapabilities{}, + Tools: &ToolCapabilities{ListChanged: true}, + }, + }, + { + name: "adding tools-no list", + configureServer: func(s *Server) { + s.AddTool(tool, nil) + }, + serverOpts: ServerOptions{ + Capabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: false}, + }, + }, + wantCapabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: false}, + }, + }, + { + name: "experimental preserved", + configureServer: func(s *Server) {}, + serverOpts: ServerOptions{ + Capabilities: &ServerCapabilities{ + Experimental: map[string]any{"custom": "value"}, + Logging: &LoggingCapabilities{}, + }, + }, + wantCapabilities: &ServerCapabilities{ + Experimental: map[string]any{"custom": "value"}, + Logging: &LoggingCapabilities{}, + }, + }, } for _, tc := range testCases { @@ -776,3 +853,88 @@ func TestToolForSchemas(t *testing.T) { }, "") } + +// TestServerCapabilitiesOverWire verifies that server capabilities are +// correctly sent over the wire during initialization. +func TestServerCapabilitiesOverWire(t *testing.T) { + tool := &Tool{Name: "test-tool", InputSchema: &jsonschema.Schema{Type: "object"}} + + testCases := []struct { + name string + serverOpts *ServerOptions + configureServer func(s *Server) + wantCapabilities *ServerCapabilities + }{ + { + name: "Default capabilities", + serverOpts: nil, + configureServer: func(s *Server) {}, + wantCapabilities: &ServerCapabilities{ + Logging: &LoggingCapabilities{}, + }, + }, + { + name: "Custom Capabilities with tools", + serverOpts: &ServerOptions{ + Capabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: false}, + }, + }, + configureServer: func(s *Server) {}, + wantCapabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: false}, + }, + }, + { + name: "Dynamic tool capability", + serverOpts: &ServerOptions{ + Capabilities: &ServerCapabilities{ + Logging: &LoggingCapabilities{}, + }, + }, + configureServer: func(s *Server) { + s.AddTool(tool, nil) + }, + wantCapabilities: &ServerCapabilities{ + Logging: &LoggingCapabilities{}, + Tools: &ToolCapabilities{ListChanged: true}, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + + // Create server. + impl := &Implementation{Name: "testServer", Version: "v1.0.0"} + server := NewServer(impl, tc.serverOpts) + tc.configureServer(server) + + // Connect client and server. + cTransport, sTransport := NewInMemoryTransports() + ss, err := server.Connect(ctx, sTransport, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil) + cs, err := client.Connect(ctx, cTransport, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + // Check that the client received the expected capabilities. + initResult := cs.InitializeResult() + if initResult == nil { + t.Fatal("InitializeResult is nil") + } + + if diff := cmp.Diff(tc.wantCapabilities, initResult.Capabilities); diff != "" { + t.Errorf("Capabilities mismatch (-want +got):\n%s", diff) + } + }) + } +} From e009bacfcb01f843335c767d0975c7066efa0423 Mon Sep 17 00:00:00 2001 From: Nikola Petrov Date: Fri, 12 Dec 2025 16:44:26 +0200 Subject: [PATCH 155/418] oauthex: fix content type check in getJSON (#721) The content type header contains full mimetype string which might contain parameters. The getJSON function doesn't account for this and so some responses fail to parse although they are perfectly acceptable. For example ``` curl -vv 'https://github.com/.well-known/oauth-authorization-server/login/oauth' ``` returns a content type header with the value ``` content-type: application/json; charset=utf-8 ``` which is perfectly valid but fails in the current logic. --- oauthex/oauth2.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/oauthex/oauth2.go b/oauthex/oauth2.go index f876be0..cdda695 100644 --- a/oauthex/oauth2.go +++ b/oauthex/oauth2.go @@ -13,6 +13,7 @@ import ( "encoding/json" "fmt" "io" + "mime" "net/http" "net/url" "strings" @@ -57,7 +58,9 @@ func getJSON[T any](ctx context.Context, c *http.Client, url string, limit int64 return nil, fmt.Errorf("bad status %s", res.Status) } // Specs require application/json. - if ct := res.Header.Get("Content-Type"); ct != "application/json" { + ct := res.Header.Get("Content-Type") + mediaType, _, err := mime.ParseMediaType(ct) + if err != nil || mediaType != "application/json" { return nil, fmt.Errorf("bad content type %q", ct) } From 875d1d30c2afd8261a0bb2fb3cb9df9e1515c526 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Fri, 12 Dec 2025 13:49:41 -0500 Subject: [PATCH 156/418] mcp: don't break the streamable client connection for transient errors (#723) When POST requests in the streamableClientConn return a transient error, return this error to the caller rather than permanently breaking the connection. This is achieved by using the special sentinel ErrRejected error to the jsonrpc2 layer. In doing so, the change revealed a pre-existing bug: ErrRejected had the same code as ErrConnectionClosing, and jsonrpc2.WireError implements errors.Is, so the two sentinel values could be conflated. This is fixed by using a new internal code. There's more to do for #683: we should also retry transient errors in handleSSE. For #683 --- internal/jsonrpc2/conn.go | 10 +- internal/jsonrpc2/wire.go | 2 +- mcp/streamable.go | 32 +++++- mcp/streamable_client_test.go | 178 ++++++++++++++++++++++++++++++---- 4 files changed, 191 insertions(+), 31 deletions(-) diff --git a/internal/jsonrpc2/conn.go b/internal/jsonrpc2/conn.go index 9ed924a..627ffe7 100644 --- a/internal/jsonrpc2/conn.go +++ b/internal/jsonrpc2/conn.go @@ -792,13 +792,9 @@ func (c *Connection) write(ctx context.Context, msg Message) error { err = c.writer.Write(ctx, msg) } - // For rejected requests, we don't set the writeErr (which would break the - // connection). They can just be returned to the caller. - if errors.Is(err, ErrRejected) { - return err - } - - if err != nil && ctx.Err() == nil { + // For cancelled or rejected requests, we don't set the writeErr (which would + // break the connection). They can just be returned to the caller. + if err != nil && ctx.Err() == nil && !errors.Is(err, ErrRejected) { // The call to Write failed, and since ctx.Err() is nil we can't attribute // the failure (even indirectly) to Context cancellation. The writer appears // to be broken, and future writes are likely to also fail. diff --git a/internal/jsonrpc2/wire.go b/internal/jsonrpc2/wire.go index 8be2872..c0a41bf 100644 --- a/internal/jsonrpc2/wire.go +++ b/internal/jsonrpc2/wire.go @@ -47,7 +47,7 @@ var ( // Such failures do not indicate that the connection is broken, but rather // should be returned to the caller to indicate that the specific request is // invalid in the current context. - ErrRejected = NewError(-32004, "rejected by transport") + ErrRejected = NewError(-32005, "rejected by transport") ) const wireVersion = "2.0" diff --git a/mcp/streamable.go b/mcp/streamable.go index d46bd2f..b4b2fa3 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1657,11 +1657,18 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e resp, err := c.client.Do(req) if err != nil { - return fmt.Errorf("%s: %v", requestSummary, err) + // Any error from client.Do means the request didn't reach the server. + // Wrap with ErrRejected so the jsonrpc2 connection doesn't set writeErr + // and permanently break the connection. + return fmt.Errorf("%w: %s: %v", jsonrpc2.ErrRejected, requestSummary, err) } if err := c.checkResponse(requestSummary, resp); err != nil { - c.fail(err) + // Only fail the connection for non-transient errors. + // Transient errors (wrapped with ErrRejected) should not break the connection. + if !errors.Is(err, jsonrpc2.ErrRejected) { + c.fail(err) + } return err } @@ -1826,8 +1833,13 @@ func (c *streamableClientConn) checkResponse(requestSummary string, resp *http.R // session is already gone. return fmt.Errorf("%s: failed to connect (session ID: %v): %w", requestSummary, c.sessionID, errSessionMissing) } + // Transient server errors (502, 503, 504, 429) should not break the connection. + // Wrap them with ErrRejected so the jsonrpc2 layer doesn't set writeErr. + if isTransientHTTPStatus(resp.StatusCode) { + return fmt.Errorf("%w: %s: %v", jsonrpc2.ErrRejected, requestSummary, http.StatusText(resp.StatusCode)) + } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("%s: failed to connect: %v", requestSummary, http.StatusText(resp.StatusCode)) + return fmt.Errorf("%s: %v", requestSummary, http.StatusText(resp.StatusCode)) } return nil } @@ -2012,3 +2024,17 @@ func calculateReconnectDelay(attempt int) time.Duration { return backoffDuration + jitter } + +// isTransientHTTPStatus reports whether the HTTP status code indicates a +// transient server error that should not permanently break the connection. +func isTransientHTTPStatus(statusCode int) bool { + switch statusCode { + case http.StatusInternalServerError, // 500 + http.StatusBadGateway, // 502 + http.StatusServiceUnavailable, // 503 + http.StatusGatewayTimeout, // 504 + http.StatusTooManyRequests: // 429 + return true + } + return false +} diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index dcdda32..e292332 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -12,6 +12,7 @@ import ( "net/http/httptest" "strings" "sync" + "sync/atomic" "testing" "time" @@ -29,13 +30,15 @@ type streamableRequestKey struct { type header map[string]string +// TODO: replace body and status fields with responseFunc; add helpers to reduce duplication. type streamableResponse struct { - header header // response headers - status int // or http.StatusOK - body string // or "" - optional bool // if set, request need not be sent - wantProtocolVersion string // if "", unchecked - done chan struct{} // if set, receive from this channel before terminating the request + header header // response headers + status int // or http.StatusOK; ignored if responseFunc is set + body string // or ""; ignored if responseFunc is set + responseFunc func(r *jsonrpc.Request) (string, int) // if set, overrides body and status + optional bool // if set, request need not be sent + wantProtocolVersion string // if "", unchecked + done chan struct{} // if set, receive from this channel before terminating the request } type fakeResponses map[streamableRequestKey]*streamableResponse @@ -44,17 +47,17 @@ type fakeStreamableServer struct { t *testing.T responses fakeResponses - callMu sync.Mutex - calls map[streamableRequestKey]int + calledMu sync.Mutex + called map[streamableRequestKey]bool } func (s *fakeStreamableServer) missingRequests() []streamableRequestKey { - s.callMu.Lock() - defer s.callMu.Unlock() + s.calledMu.Lock() + defer s.calledMu.Unlock() var unused []streamableRequestKey for k, resp := range s.responses { - if s.calls[k] == 0 && !resp.optional { + if !s.called[k] && !resp.optional { unused = append(unused, k) } } @@ -67,6 +70,7 @@ func (s *fakeStreamableServer) ServeHTTP(w http.ResponseWriter, req *http.Reques sessionID: req.Header.Get(sessionIDHeader), lastEventID: req.Header.Get("Last-Event-ID"), // TODO: extract this to a constant, like sessionIDHeader } + var jsonrpcReq *jsonrpc.Request if req.Method == http.MethodPost { body, err := io.ReadAll(req.Body) if err != nil { @@ -82,15 +86,16 @@ func (s *fakeStreamableServer) ServeHTTP(w http.ResponseWriter, req *http.Reques } if r, ok := msg.(*jsonrpc.Request); ok { key.jsonrpcMethod = r.Method + jsonrpcReq = r } } - s.callMu.Lock() - if s.calls == nil { - s.calls = make(map[streamableRequestKey]int) + s.calledMu.Lock() + if s.called == nil { + s.called = make(map[streamableRequestKey]bool) } - s.calls[key]++ - s.callMu.Unlock() + s.called[key] = true + s.calledMu.Unlock() resp, ok := s.responses[key] if !ok { @@ -98,20 +103,27 @@ func (s *fakeStreamableServer) ServeHTTP(w http.ResponseWriter, req *http.Reques http.Error(w, "no response", http.StatusInternalServerError) return } - for k, v := range resp.header { - w.Header().Set(k, v) - } + + // Determine body and status, potentially using responseFunc for dynamic responses. + body := resp.body status := resp.status + if resp.responseFunc != nil { + body, status = resp.responseFunc(jsonrpcReq) + } if status == 0 { status = http.StatusOK } + + for k, v := range resp.header { + w.Header().Set(k, v) + } w.WriteHeader(status) w.(http.Flusher).Flush() // flush response headers if v := req.Header.Get(protocolVersionHeader); v != resp.wantProtocolVersion && resp.wantProtocolVersion != "" { s.t.Errorf("%v: bad protocol version header: got %q, want %q", key, v, resp.wantProtocolVersion) } - w.Write([]byte(resp.body)) + w.Write([]byte(body)) w.(http.Flusher).Flush() // flush response if resp.done != nil { @@ -555,3 +567,129 @@ data: { "jsonrpc": "2.0", "method": "notifications/message", "params": { "level" }) } } + +// TestStreamableClientTransientErrors verifies that transient errors (timeouts, +// 5xx HTTP status codes) do not permanently break the client connection. +// This tests the fix for issue #683. +func TestStreamableClientTransientErrors(t *testing.T) { + ctx := context.Background() + + tests := []struct { + transientStatus int // HTTP status to return for the transient call + wantCallError bool // whether the transient call should error + wantSessionBroken bool // whether the session should be broken after + wantErrorContains string // substring expected in error message + }{ + { + transientStatus: http.StatusServiceUnavailable, + wantCallError: true, + wantSessionBroken: false, + wantErrorContains: "Service Unavailable", + }, + { + transientStatus: http.StatusBadGateway, + wantCallError: true, + wantSessionBroken: false, + wantErrorContains: "Bad Gateway", + }, + { + transientStatus: http.StatusGatewayTimeout, + wantCallError: true, + wantSessionBroken: false, + wantErrorContains: "Gateway Timeout", + }, + { + transientStatus: http.StatusTooManyRequests, + wantCallError: true, + wantSessionBroken: false, + wantErrorContains: "Too Many Requests", + }, + { + transientStatus: http.StatusUnauthorized, + wantCallError: true, + wantSessionBroken: true, + wantErrorContains: "Unauthorized", + }, + { + transientStatus: http.StatusNotFound, + wantCallError: true, + wantSessionBroken: true, + wantErrorContains: "not found", // NotFound has special handling + }, + } + + for _, test := range tests { + t.Run(http.StatusText(test.transientStatus), func(t *testing.T) { + var returnedError atomic.Bool + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodInitialize, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "123", + }, + body: jsonBody(t, initResp), + }, + {"POST", "123", notificationInitialized, ""}: { + status: http.StatusAccepted, + wantProtocolVersion: latestProtocolVersion, + }, + {"GET", "123", "", ""}: { + status: http.StatusMethodNotAllowed, + }, + {"POST", "123", methodListTools, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "123", + }, + responseFunc: func(r *jsonrpc.Request) (string, int) { + // First call returns transient error, subsequent calls succeed. + if !returnedError.Swap(true) && test.transientStatus != 0 { + return "", test.transientStatus + } + return jsonBody(t, resp(r.ID.Raw().(int64), &ListToolsResult{Tools: []*Tool{}}, nil)), 0 + }, + optional: true, + }, + {"DELETE", "123", "", ""}: {optional: true}, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, nil) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + defer session.Close() + + // First call: should trigger transient error. + _, err = session.ListTools(ctx, nil) + if test.wantCallError { + if err == nil { + t.Error("ListTools succeeded unexpectedly, want error") + } else if test.wantErrorContains != "" && !strings.Contains(err.Error(), test.wantErrorContains) { + t.Errorf("ListTools error = %q, want containing %q", err.Error(), test.wantErrorContains) + } + } else if err != nil { + t.Errorf("ListTools failed unexpectedly: %v", err) + } + + // Second call: verifies whether the session is still usable. + _, err = session.ListTools(ctx, nil) + if test.wantSessionBroken { + if err == nil { + t.Error("second ListTools succeeded unexpectedly, want session broken") + } + } else { + if err != nil { + t.Errorf("second ListTools failed unexpectedly: %v (session should survive transient errors)", err) + } + } + }) + } +} From 76e6854375e2edf3bebd69c99ee757f3d65005bd Mon Sep 17 00:00:00 2001 From: Jonathan Hefner Date: Tue, 16 Dec 2025 17:21:03 -0600 Subject: [PATCH 157/418] docs: add GitHub Pages redirect to latest release documentation (#702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Context:** I am working on getting documentation up for each MCP SDK at `https://modelcontextprotocol.github.io/*-sdk/`. I realize the Go ecosystem has its own idioms for generating and hosting documentation, so I am proposing we make https://modelcontextprotocol.github.io/go-sdk/ redirect to the latest tag of https://github.com/modelcontextprotocol/go-sdk/blob/v1.1.0/docs/README.md. --- Adds a redirect page at https://modelcontextprotocol.github.io/go-sdk/ that automatically redirects visitors to the `docs/README.md` of the latest release tag. Uses Jekyll templating with `site.github.latest_release` to dynamically resolve the current release version at build time. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 --- .github/workflows/publish-docs.yml | 24 ++++++++++++++++++++++++ docs/index.html | 15 +++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 .github/workflows/publish-docs.yml create mode 100644 docs/index.html diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml new file mode 100644 index 0000000..d599019 --- /dev/null +++ b/.github/workflows/publish-docs.yml @@ -0,0 +1,24 @@ +name: Publish Docs to GitHub Pages + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: write + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./docs + publish_branch: gh-pages + enable_jekyll: true diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..8ec5ea6 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,15 @@ +--- +--- + + + + + MCP Go SDK Documentation + + + + +

Redirecting to MCP Go SDK documentation ({{ site.github.latest_release.tag_name }})...

+

For API reference, see pkg.go.dev.

+ + From 411d5a049ae1006cf01ab9f5ac49d249ab3e8e2b Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Fri, 19 Dec 2025 13:44:10 -0800 Subject: [PATCH 158/418] mcp: switch icon theme to named string type (#733) To be consistent with logging level, use a named string type for icon themes. This improves readability / discoverability, and is more consistent. Updates #552 For #725 --- examples/server/everything/main.go | 2 +- mcp/conformance_test.go | 2 +- mcp/content_test.go | 4 ++-- mcp/protocol.go | 15 +++++++++++++-- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/examples/server/everything/main.go b/examples/server/everything/main.go index 9bd6a7c..e7634ce 100644 --- a/examples/server/everything/main.go +++ b/examples/server/everything/main.go @@ -291,6 +291,6 @@ func mcpIcons() []mcp.Icon { Source: "data:image/png;base64," + base64.StdEncoding.EncodeToString(mcpIconData), MIMEType: "image/png", Sizes: []string{"48x48"}, - Theme: "light", // or "dark" or empty + Theme: mcp.IconThemeLight, }} } diff --git a/mcp/conformance_test.go b/mcp/conformance_test.go index b2801db..8b557ae 100644 --- a/mcp/conformance_test.go +++ b/mcp/conformance_test.go @@ -152,7 +152,7 @@ var iconObj = Icon{ Source: "foobar", MIMEType: "image/png", Sizes: []string{"48x48", "96x96"}, - Theme: "light", + Theme: IconThemeLight, } // runServerTest runs the server conformance test. diff --git a/mcp/content_test.go b/mcp/content_test.go index ae13c30..094950e 100644 --- a/mcp/content_test.go +++ b/mcp/content_test.go @@ -121,7 +121,7 @@ func TestContent(t *testing.T) { Description: "This resource demonstrates all fields", MIMEType: "text/plain", Meta: mcp.Meta{"custom": "metadata"}, - Icons: []mcp.Icon{{Source: "foobar", MIMEType: "image/png", Sizes: []string{"48x48"}, Theme: "light"}}, + Icons: []mcp.Icon{{Source: "foobar", MIMEType: "image/png", Sizes: []string{"48x48"}, Theme: mcp.IconThemeLight}}, }, `{"type":"resource_link","mimeType":"text/plain","uri":"https://example.com/resource","name":"Example Resource","title":"A comprehensive example resource","description":"This resource demonstrates all fields","_meta":{"custom":"metadata"},"icons":[{"src":"foobar","mimeType":"image/png","sizes":["48x48"],"theme":"light"}]}`, }, @@ -217,7 +217,7 @@ func TestContentUnmarshal(t *testing.T) { // Meta: mcp.Meta{"custom": "metadata"}, Size: &valInt64, Annotations: &mcp.Annotations{Audience: []mcp.Role{"user", "assistant"}, LastModified: "2025-01-12T15:00:58Z", Priority: 0.5}, - Icons: []mcp.Icon{{Source: "foobar", MIMEType: "image/png", Sizes: []string{"48x48"}, Theme: "light"}}, + Icons: []mcp.Icon{{Source: "foobar", MIMEType: "image/png", Sizes: []string{"48x48"}, Theme: mcp.IconThemeLight}}, }, }, } diff --git a/mcp/protocol.go b/mcp/protocol.go index bfac145..26c8982 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -750,6 +750,16 @@ type ProgressNotificationParams struct { func (*ProgressNotificationParams) isParams() {} +// IconTheme specifies the theme an icon is designed for. +type IconTheme string + +const ( + // IconThemeLight indicates the icon is designed for a light background. + IconThemeLight IconTheme = "light" + // IconThemeDark indicates the icon is designed for a dark background. + IconThemeDark IconTheme = "dark" +) + // Icon provides visual identifiers for their resources, tools, prompts, and implementations // See [/specification/draft/basic/index#icons] for notes on icons // @@ -763,8 +773,9 @@ type Icon struct { MIMEType string `json:"mimeType,omitempty"` // Optional size specification (e.g., ["48x48"], ["any"] for scalable formats like SVG, or ["48x48", "96x96"] for multiple sizes) Sizes []string `json:"sizes,omitempty"` - // Optional Theme of the icon, e.g., "light" or "dark" - Theme string `json:"theme,omitempty"` + // Optional theme specifier. "light" indicates the icon is designed for a light + // background, "dark" indicates the icon is designed for a dark background. + Theme IconTheme `json:"theme,omitempty"` } // A prompt or prompt template that the server offers. From 1d5938cad47cd630a7f16d7768301a69f11ad337 Mon Sep 17 00:00:00 2001 From: La002 <92678081+La002@users.noreply.github.com> Date: Wed, 31 Dec 2025 03:44:02 +0530 Subject: [PATCH 159/418] docs: adds SDK version support matrix (#737) Adds a version compatibility table to the README documenting which SDK versions support which MCP spec versions. Fixes #674 --------- Co-authored-by: i586147 --- README.md | 9 +++++++++ internal/readme/README.src.md | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/README.md b/README.md index 2a2d408..d224ede 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,15 @@ The SDK consists of several importable packages: The SDK endeavors to implement the full MCP spec. The [`docs/`](/docs/) directory contains feature documentation, mapping the MCP spec to the packages above. +## Version Compatibility + +The following table shows which versions of the Go SDK support which versions of the MCP specification: + +| SDK Version | Latest MCP Spec | All Supported MCP Specs | +|-----------------|-------------------|------------------------------------------------| +| v1.2.0+ | 2025-06-18 | 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05 | +| v1.0.0 - v1.1.0 | 2025-06-18 | 2025-06-18, 2025-03-26, 2024-11-05 | + ## Getting started To get started creating an MCP server, create an `mcp.Server` instance, add diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md index cdc2cd6..e23fe6d 100644 --- a/internal/readme/README.src.md +++ b/internal/readme/README.src.md @@ -28,6 +28,15 @@ The SDK consists of several importable packages: The SDK endeavors to implement the full MCP spec. The [`docs/`](/docs/) directory contains feature documentation, mapping the MCP spec to the packages above. +## Version Compatibility + +The following table shows which versions of the Go SDK support which versions of the MCP specification: + +| SDK Version | Latest MCP Spec | All Supported MCP Specs | +|-----------------|-------------------|------------------------------------------------| +| v1.2.0+ | 2025-06-18 | 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05 | +| v1.0.0 - v1.1.0 | 2025-06-18 | 2025-06-18, 2025-03-26, 2024-11-05 | + ## Getting started To get started creating an MCP server, create an `mcp.Server` instance, add From 27b3354f800628f556a4b8de13095c9bb087743b Mon Sep 17 00:00:00 2001 From: ZAIN ULHASSAN <49031989+hassan123789@users.noreply.github.com> Date: Thu, 1 Jan 2026 01:16:20 +0900 Subject: [PATCH 160/418] mcp: fix SSEClientTransport to report HTTP errors properly (#740) Fix SSEClientTransport.Connect to check HTTP status code before attempting to parse SSE events. Fixes #714 --- mcp/sse.go | 8 +++++++ mcp/sse_test.go | 55 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/mcp/sse.go b/mcp/sse.go index 7f64491..a668c6d 100644 --- a/mcp/sse.go +++ b/mcp/sse.go @@ -351,6 +351,14 @@ func (c *SSEClientTransport) Connect(ctx context.Context) (Connection, error) { return nil, err } + // Check HTTP status code before attempting to parse SSE events. + // This ensures proper error reporting for authentication failures (401), + // authorization failures (403), and other HTTP errors. + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + resp.Body.Close() + return nil, fmt.Errorf("failed to connect: %s", http.StatusText(resp.StatusCode)) + } + msgEndpoint, err := func() (*url.URL, error) { var evt Event for evt, err = range scanEvents(resp.Body) { diff --git a/mcp/sse_test.go b/mcp/sse_test.go index 25435ff..132a031 100644 --- a/mcp/sse_test.go +++ b/mcp/sse_test.go @@ -131,3 +131,58 @@ type roundTripperFunc func(*http.Request) (*http.Response, error) func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +func TestSSEClientTransport_HTTPErrors(t *testing.T) { + tests := []struct { + name string + statusCode int + wantErrContain string + }{ + { + name: "401 Unauthorized", + statusCode: http.StatusUnauthorized, + wantErrContain: "Unauthorized", + }, + { + name: "403 Forbidden", + statusCode: http.StatusForbidden, + wantErrContain: "Forbidden", + }, + { + name: "404 Not Found", + statusCode: http.StatusNotFound, + wantErrContain: "Not Found", + }, + { + name: "500 Internal Server Error", + statusCode: http.StatusInternalServerError, + wantErrContain: "Internal Server Error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a test server that returns the specified status code + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, http.StatusText(tt.statusCode), tt.statusCode) + })) + defer httpServer.Close() + + clientTransport := &SSEClientTransport{ + Endpoint: httpServer.URL, + } + + c := NewClient(testImpl, nil) + _, err := c.Connect(context.Background(), clientTransport, nil) + + if err == nil { + t.Fatalf("expected error, got nil") + } + + errStr := err.Error() + if !bytes.Contains([]byte(errStr), []byte(tt.wantErrContain)) { + t.Errorf("error message %q does not contain %q", errStr, tt.wantErrContain) + } + }) + } +} From 9c4d349ec047d382e2d3a80a2432805eb90f5af3 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Sun, 4 Jan 2026 09:18:01 -0500 Subject: [PATCH 161/418] go.mod: upgrade to jsonschema v0.4.2 (#732) Visible changes: - Inference on structs populates PropertyOrder - An empty map for Schema.Properties marshals as "{}" --- go.mod | 2 +- go.sum | 4 +-- mcp/server_test.go | 35 +++++++++++++++--- .../spec-sep-973-additional-metadata.txtar | 12 +++---- mcp/testdata/conformance/server/tools.txtar | 36 +++++++++---------- 5 files changed, 58 insertions(+), 31 deletions(-) diff --git a/go.mod b/go.mod index d5917a1..09b1be7 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.23.0 require ( github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/go-cmp v0.7.0 - github.com/google/jsonschema-go v0.3.0 + github.com/google/jsonschema-go v0.4.2 github.com/yosida95/uritemplate/v3 v3.0.2 golang.org/x/oauth2 v0.30.0 golang.org/x/tools v0.34.0 diff --git a/go.sum b/go.sum index 32ceedf..dc70202 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeD github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= -github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= diff --git a/mcp/server_test.go b/mcp/server_test.go index 090ccdc..1cd9d88 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -812,10 +812,34 @@ func TestToolForSchemas(t *testing.T) { var ( falseSchema = &schema{Not: &schema{}} - inSchema = &schema{Type: "object", AdditionalProperties: falseSchema, Properties: map[string]*schema{"p": {Type: "integer"}}} - inSchema2 = &schema{Type: "object", AdditionalProperties: falseSchema, Properties: map[string]*schema{"p": {Type: "string"}}} - outSchema = &schema{Type: "object", AdditionalProperties: falseSchema, Properties: map[string]*schema{"b": {Type: "boolean"}}} - outSchema2 = &schema{Type: "object", AdditionalProperties: falseSchema, Properties: map[string]*schema{"b": {Type: "integer"}}} + inSchema = &schema{ + Type: "object", + AdditionalProperties: falseSchema, + Properties: map[string]*schema{"p": {Type: "integer"}}, + PropertyOrder: []string{"p"}, + } + inSchema2 = &schema{ + Type: "object", + AdditionalProperties: falseSchema, + Properties: map[string]*schema{"p": {Type: "string"}}, + } + inSchema3 = &schema{ + Type: "object", + AdditionalProperties: falseSchema, + Properties: map[string]*schema{}, // empty map is preserved + } + outSchema = &schema{ + Type: "object", + AdditionalProperties: falseSchema, + Properties: map[string]*schema{"b": {Type: "boolean"}}, + PropertyOrder: []string{"b"}, + } + outSchema2 = &schema{ + Type: "object", + AdditionalProperties: falseSchema, + Properties: map[string]*schema{"b": {Type: "integer"}}, + PropertyOrder: []string{"b"}, + } ) // Infer both schemas. @@ -829,6 +853,8 @@ func TestToolForSchemas(t *testing.T) { testToolForSchema[in, any](t, &Tool{}, `{"p":"x"}`, 0, inSchema, nil, `want "integer"`) // Tool sets input schema: that is what's used. testToolForSchema[in, any](t, &Tool{InputSchema: inSchema2}, `{"p":3}`, 0, inSchema2, nil, `want "string"`) + // Tool sets input schema, empty properties map. + testToolForSchema[in, any](t, &Tool{InputSchema: inSchema3}, `{}`, 0, inSchema3, nil, "") // Tool sets output schema: that is what's used, and validation happens. testToolForSchema[in, any](t, &Tool{OutputSchema: outSchema2}, `{"p":3}`, out{true}, inSchema, outSchema2, `want "integer"`) @@ -850,6 +876,7 @@ func TestToolForSchemas(t *testing.T) { "AsOf": {Type: "string"}, "Source": {Type: "string"}, }, + PropertyOrder: []string{"Summary", "AsOf", "Source"}, }, "") } diff --git a/mcp/testdata/conformance/server/spec-sep-973-additional-metadata.txtar b/mcp/testdata/conformance/server/spec-sep-973-additional-metadata.txtar index 5441ab8..2add92e 100644 --- a/mcp/testdata/conformance/server/spec-sep-973-additional-metadata.txtar +++ b/mcp/testdata/conformance/server/spec-sep-973-additional-metadata.txtar @@ -92,15 +92,15 @@ infoWithIcon "description": "return resourceLink content with Icon", "inputSchema": { "type": "object", - "required": [ - "In" - ], "properties": { "In": { "type": "string", "description": "the input" } }, + "required": [ + "In" + ], "additionalProperties": false }, "name": "contentTool", @@ -110,14 +110,14 @@ infoWithIcon "description": "say hi", "inputSchema": { "type": "object", - "required": [ - "Name" - ], "properties": { "Name": { "type": "string" } }, + "required": [ + "Name" + ], "additionalProperties": false }, "name": "greetWithIcon", diff --git a/mcp/testdata/conformance/server/tools.txtar b/mcp/testdata/conformance/server/tools.txtar index b582dda..ba0facc 100644 --- a/mcp/testdata/conformance/server/tools.txtar +++ b/mcp/testdata/conformance/server/tools.txtar @@ -69,14 +69,14 @@ inc "description": "say hi", "inputSchema": { "type": "object", - "required": [ - "Name" - ], "properties": { "Name": { "type": "string" } }, + "required": [ + "Name" + ], "additionalProperties": false }, "name": "greet" @@ -95,70 +95,70 @@ inc "name": "inc", "outputSchema": { "type": "object", - "required": [ - "y" - ], "properties": { "y": { "type": "integer" } }, + "required": [ + "y" + ], "additionalProperties": false } }, { "inputSchema": { "type": "object", - "required": [ - "In" - ], "properties": { "In": { "type": "string", "description": "the input" } }, + "required": [ + "In" + ], "additionalProperties": false }, "name": "structured", "outputSchema": { "type": "object", - "required": [ - "Out" - ], "properties": { "Out": { "type": "string", "description": "the output" } }, + "required": [ + "Out" + ], "additionalProperties": false } }, { "inputSchema": { "type": "object", - "required": [ - "Now" - ], "properties": { "Now": { "type": "string" } }, + "required": [ + "Now" + ], "additionalProperties": false }, "name": "tomorrow", "outputSchema": { "type": "object", - "required": [ - "Tomorrow" - ], "properties": { "Tomorrow": { "type": "string" } }, + "required": [ + "Tomorrow" + ], "additionalProperties": false } } From e66c23fae41d07db8b12d625291d28451653e7d4 Mon Sep 17 00:00:00 2001 From: Liu Shuang Date: Mon, 5 Jan 2026 05:16:06 +0800 Subject: [PATCH 162/418] mcp: fix connectStandaloneSSE checking Content-Type header (#736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue encountered: the MCP server request was unexpectedly redirected to an HTML page. Because the client didn’t validate the response Content-Type, it still entered handleSSE, which then kept treating the HTML as an SSE stream and repeatedly re-requesting the same page—creating an infinite request loop. --- mcp/streamable.go | 8 ++++++++ mcp/streamable_client_test.go | 19 +++++++++++-------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index b4b2fa3..52269af 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1555,6 +1555,14 @@ func (c *streamableClientConn) connectStandaloneSSE() { resp.Body.Close() return } + if resp.Header.Get("Content-Type") != "text/event-stream" { + // modelcontextprotocol/go-sdk#736: some servers return 200 OK or redirect with + // non-SSE content type instead of text/event-stream for the standalone + // SSE stream. + c.logger.Warn(fmt.Sprintf("got Content-Type %s instead of text/event-stream for standalone SSE stream", resp.Header.Get("Content-Type"))) + resp.Body.Close() + return + } if resp.StatusCode >= 400 && resp.StatusCode < 500 && !c.strict { // modelcontextprotocol/go-sdk#393,#610: some servers return NotFound or // other status codes instead of MethodNotAllowed for the standalone SSE diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index e292332..1228423 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -17,6 +17,7 @@ import ( "time" "github.com/google/go-cmp/cmp" + "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) @@ -258,14 +259,16 @@ func TestStreamableClientGETHandling(t *testing.T) { tests := []struct { status int wantErrorContaining string + contentType string }{ - {http.StatusOK, ""}, - {http.StatusMethodNotAllowed, ""}, - // The client error status code is not treated as an error in non-strict - // mode. - {http.StatusNotFound, ""}, - {http.StatusBadRequest, ""}, - {http.StatusInternalServerError, "standalone SSE"}, + {http.StatusOK, "", "text/event-stream"}, + {http.StatusMethodNotAllowed, "", "text/event-stream"}, + //// The client error status code is not treated as an error in non-strict + //// mode. + {http.StatusNotFound, "", "text/event-stream"}, + {http.StatusBadRequest, "", "text/event-stream"}, + {http.StatusInternalServerError, "standalone SSE", "text/event-stream"}, + {http.StatusOK, "", "text/html; charset=utf-8"}, } for _, test := range tests { @@ -286,7 +289,7 @@ func TestStreamableClientGETHandling(t *testing.T) { }, {"GET", "123", "", ""}: { header: header{ - "Content-Type": "text/event-stream", + "Content-Type": test.contentType, }, status: test.status, wantProtocolVersion: latestProtocolVersion, From 61be5488032ff6a623b7ac8f98489e6fb2d35ac1 Mon Sep 17 00:00:00 2001 From: FanOne Date: Mon, 5 Jan 2026 23:41:31 +0800 Subject: [PATCH 163/418] feat: deprecated logger in client & add Logger in ClientOption (#738) Fix #708 1. deprecated logger in client 2. add Logger in ClientOption 3. add unit test for Logger --- mcp/client.go | 29 ++++++++++++++++++++--------- mcp/client_test.go | 18 ++++++++++++++++++ mcp/server.go | 2 +- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index 2dc1a86..66121f7 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -18,6 +18,7 @@ import ( "time" "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) @@ -25,8 +26,9 @@ import ( // A Client is an MCP client, which may be connected to an MCP server // using the [Client.Connect] method. type Client struct { - impl *Implementation - opts ClientOptions + impl *Implementation + opts ClientOptions + // Deprecated logger *slog.Logger // TODO: file proposal to export this mu sync.Mutex roots *featureSet[*Root] @@ -42,25 +44,34 @@ type Client struct { // The first argument must not be nil. // // If non-nil, the provided options configure the Client. -func NewClient(impl *Implementation, opts *ClientOptions) *Client { +func NewClient(impl *Implementation, options *ClientOptions) *Client { if impl == nil { panic("nil Implementation") } - c := &Client{ + var opts ClientOptions + if options != nil { + opts = *options + } + options = nil // prevent reuse + + if opts.Logger == nil { // ensure we have a logger + opts.Logger = ensureLogger(nil) + } + + return &Client{ impl: impl, + opts: opts, logger: ensureLogger(nil), // ensure we have a logger roots: newFeatureSet(func(r *Root) string { return r.URI }), sendingMethodHandler_: defaultSendingMethodHandler, receivingMethodHandler_: defaultReceivingMethodHandler[*ClientSession], } - if opts != nil { - c.opts = *opts - } - return c } // ClientOptions configures the behavior of the client. type ClientOptions struct { + // Logger may be set to a non-nil value to enable logging of client activity. + Logger *slog.Logger // CreateMessageHandler handles incoming requests for sampling/createMessage. // // Setting CreateMessageHandler to a non-nil value automatically causes the @@ -407,7 +418,7 @@ func changeAndNotify[P Params](c *Client, notification string, params P, change } } c.mu.Unlock() - notifySessions(sessions, notification, params, c.logger) + notifySessions(sessions, notification, params, c.opts.Logger) } // shouldSendListChangedNotification checks if the client's capabilities allow diff --git a/mcp/client_test.go b/mcp/client_test.go index ad8c0f1..2f7abc0 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -7,6 +7,7 @@ package mcp import ( "context" "fmt" + "log/slog" "testing" "github.com/google/go-cmp/cmp" @@ -165,6 +166,23 @@ func TestClientPaginateBasic(t *testing.T) { } } +func TestClientLogger(t *testing.T) { + // Case 1: No logger provided + c1 := NewClient(&Implementation{Name: "test", Version: "1.0"}, nil) + if c1.opts.Logger == nil { + t.Error("expected default logger, got nil") + } + + // Case 2: Logger provided + logger := slog.Default() + c2 := NewClient(&Implementation{Name: "test", Version: "1.0"}, &ClientOptions{ + Logger: logger, + }) + if c2.opts.Logger != logger { + t.Error("expected provided logger, got different one") + } +} + func TestClientPaginateVariousPageSizes(t *testing.T) { ctx := context.Background() for i := 1; i < len(allItems)+1; i++ { diff --git a/mcp/server.go b/mcp/server.go index 1f7edf9..f24a6a8 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -58,7 +58,7 @@ type Server struct { type ServerOptions struct { // Optional instructions for connected clients. Instructions string - // If non-nil, log server activity. + // Logger may be set to a non-nil value to enable logging of server activity. Logger *slog.Logger // If non-nil, called when "notifications/initialized" is received. InitializedHandler func(context.Context, *InitializedRequest) From a225d4dc7ded92f5492651a1bc60499b3be27044 Mon Sep 17 00:00:00 2001 From: FanOne <294350394@qq.com> Date: Tue, 6 Jan 2026 01:29:40 +0800 Subject: [PATCH 164/418] delete the old logger in client (#744) Fix #708 Link PR #738 deleted the old logger in client. --- mcp/client.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index 66121f7..57c30fb 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -26,10 +26,8 @@ import ( // A Client is an MCP client, which may be connected to an MCP server // using the [Client.Connect] method. type Client struct { - impl *Implementation - opts ClientOptions - // Deprecated - logger *slog.Logger // TODO: file proposal to export this + impl *Implementation + opts ClientOptions mu sync.Mutex roots *featureSet[*Root] sessions []*ClientSession @@ -61,7 +59,6 @@ func NewClient(impl *Implementation, options *ClientOptions) *Client { return &Client{ impl: impl, opts: opts, - logger: ensureLogger(nil), // ensure we have a logger roots: newFeatureSet(func(r *Root) string { return r.URI }), sendingMethodHandler_: defaultSendingMethodHandler, receivingMethodHandler_: defaultReceivingMethodHandler[*ClientSession], From b4f957ff3c279051f9bcc88aa08e897add012a95 Mon Sep 17 00:00:00 2001 From: zhaoxin <845876811@qq.com> Date: Sun, 11 Jan 2026 20:56:47 +0800 Subject: [PATCH 165/418] mcp: add DisableListening option to StreamableClientTransport (#729) mcp: add DisableStandaloneSSE option to control standalone SSE stream Currently, StreamableClientTransport always establishes a standalone SSE connection after initialization to receive server-initiated messages. While this is useful for receiving notifications like ToolListChangedNotification, it causes problems in several real-world scenarios: 1. **Unnecessary resource usage**: Many use cases (for me), especially in scientific computing scenarios, only require simple request-response communication. Clients don't need server-initiated notifications, making the persistent SSE connection wasteful. 2. **Server compatibility issues**: Some third-party MCP servers don't properly handle GET requests for SSE streams. When the client automatically tries to establish the SSE connection, it fails or hangs, blocking the entire connection process. This is particularly problematic when using third-party servers that cannot be modified. This issue is similar to what was discussed in issue #634. 3. **Lack of user control**: The MCP specification states that the standalone SSE stream is optional ("The client MAY issue an HTTP GET"), but the current SDK implementation doesn't provide a way to opt out. This implementation is also inconsistent with other MCP SDKs like github.com/mark3labs/mcp-go, which provides getListeningEnabled control (defaults to false, requires explicit enablement). Fixes #728 Co-authored-by: zhaoxin --- mcp/streamable.go | 53 +++++++++++---- mcp/streamable_client_test.go | 118 ++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 13 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 52269af..167d684 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1389,6 +1389,26 @@ type StreamableClientTransport struct { // It defaults to 5. To disable retries, use a negative number. MaxRetries int + // DisableStandaloneSSE controls whether the client establishes a standalone SSE stream + // for receiving server-initiated messages. + // + // When false (the default), after initialization the client sends an HTTP GET request + // to establish a persistent server-sent events (SSE) connection. This allows the server + // to send messages to the client at any time, such as ToolListChangedNotification or + // other server-initiated requests and notifications. The connection persists for the + // lifetime of the session and automatically reconnects if interrupted. + // + // When true, the client does not establish the standalone SSE stream. The client will + // only receive responses to its own POST requests. Server-initiated messages will not + // be received. + // + // According to the MCP specification, the standalone SSE stream is optional. + // Setting DisableStandaloneSSE to true is useful when: + // - You only need request-response communication and don't need server-initiated notifications + // - The server doesn't properly handle GET requests for SSE streams + // - You want to avoid maintaining a persistent connection + DisableStandaloneSSE bool + // TODO(rfindley): propose exporting these. // If strict is set, the transport is in 'strict mode', where any violation // of the MCP spec causes a failure. @@ -1453,16 +1473,17 @@ func (t *StreamableClientTransport) Connect(ctx context.Context) (Connection, er // middleware), yet only cancel the standalone stream when the connection is closed. connCtx, cancel := context.WithCancel(xcontext.Detach(ctx)) conn := &streamableClientConn{ - url: t.Endpoint, - client: client, - incoming: make(chan jsonrpc.Message, 10), - done: make(chan struct{}), - maxRetries: maxRetries, - strict: t.strict, - logger: ensureLogger(t.logger), // must be non-nil for safe logging - ctx: connCtx, - cancel: cancel, - failed: make(chan struct{}), + url: t.Endpoint, + client: client, + incoming: make(chan jsonrpc.Message, 10), + done: make(chan struct{}), + maxRetries: maxRetries, + strict: t.strict, + logger: ensureLogger(t.logger), // must be non-nil for safe logging + ctx: connCtx, + cancel: cancel, + failed: make(chan struct{}), + disableStandaloneSSE: t.DisableStandaloneSSE, } return conn, nil } @@ -1477,6 +1498,10 @@ type streamableClientConn struct { strict bool // from [StreamableClientTransport.strict] logger *slog.Logger // from [StreamableClientTransport.logger] + // disableStandaloneSSE controls whether to disable the standalone SSE stream + // for receiving server-to-client notifications when no request is in flight. + disableStandaloneSSE bool // from [StreamableClientTransport.DisableStandaloneSSE] + // Guard calls to Close, as it may be called multiple times. closeOnce sync.Once closeErr error @@ -1518,7 +1543,7 @@ func (c *streamableClientConn) sessionUpdated(state clientSessionState) { c.mu.Unlock() // Start the standalone SSE stream as soon as we have the initialized - // result. + // result, if continuous listening is enabled. // // § 2.2: The client MAY issue an HTTP GET to the MCP endpoint. This can be // used to open an SSE stream, allowing the server to communicate to the @@ -1528,9 +1553,11 @@ func (c *streamableClientConn) sessionUpdated(state clientSessionState) { // initialized, we don't know whether the server requires a sessionID. // // § 2.5: A server using the Streamable HTTP transport MAY assign a session - // ID at initialization time, by including it in an Mcp-Session-Id header + // ID at initialization time, by including it in a Mcp-Session-Id header // on the HTTP response containing the InitializeResult. - c.connectStandaloneSSE() + if !c.disableStandaloneSSE { + c.connectStandaloneSSE() + } } func (c *streamableClientConn) connectStandaloneSSE() { diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 1228423..2dfb719 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -696,3 +696,121 @@ func TestStreamableClientTransientErrors(t *testing.T) { }) } } + +func TestStreamableClientDisableStandaloneSSE(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + disableStandaloneSSE bool + expectGETRequest bool + }{ + { + name: "default behavior (standalone SSE enabled)", + disableStandaloneSSE: false, + expectGETRequest: true, + }, + { + name: "standalone SSE disabled", + disableStandaloneSSE: true, + expectGETRequest: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + getRequestKey := streamableRequestKey{"GET", "123", "", ""} + + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodInitialize, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "123", + }, + body: jsonBody(t, initResp), + }, + {"POST", "123", notificationInitialized, ""}: { + status: http.StatusAccepted, + wantProtocolVersion: latestProtocolVersion, + }, + getRequestKey: { + header: header{ + "Content-Type": "text/event-stream", + }, + wantProtocolVersion: latestProtocolVersion, + optional: !test.expectGETRequest, + }, + {"DELETE", "123", "", ""}: { + optional: true, + }, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{ + Endpoint: httpServer.URL, + DisableStandaloneSSE: test.disableStandaloneSSE, + } + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, nil) + if err != nil { + t.Fatalf("client.Connect() failed: %v", err) + } + + // Give some time for the standalone SSE connection to be established (if enabled) + time.Sleep(100 * time.Millisecond) + + // Verify the connection state + streamableConn, ok := session.mcpConn.(*streamableClientConn) + if !ok { + t.Fatalf("Expected *streamableClientConn, got %T", session.mcpConn) + } + + if got, want := streamableConn.disableStandaloneSSE, test.disableStandaloneSSE; got != want { + t.Errorf("disableStandaloneSSE field: got %v, want %v", got, want) + } + + // Clean up + if err := session.Close(); err != nil { + t.Errorf("closing session: %v", err) + } + + // Check if GET request was received + fake.calledMu.Lock() + getRequestReceived := false + if fake.called != nil { + getRequestReceived = fake.called[getRequestKey] + } + fake.calledMu.Unlock() + + if got, want := getRequestReceived, test.expectGETRequest; got != want { + t.Errorf("GET request received: got %v, want %v", got, want) + } + + // If we expected a GET request, verify it was actually received + if test.expectGETRequest { + if missing := fake.missingRequests(); len(missing) > 0 { + // Filter out optional requests + var requiredMissing []streamableRequestKey + for _, key := range missing { + if resp, ok := fake.responses[key]; ok && !resp.optional { + requiredMissing = append(requiredMissing, key) + } + } + if len(requiredMissing) > 0 { + t.Errorf("did not receive expected requests: %v", requiredMissing) + } + } + } else { + // If we didn't expect a GET request, verify it wasn't sent + if getRequestReceived { + t.Error("GET request was sent unexpectedly when DisableStandaloneSSE is true") + } + } + }) + } +} From 13488f7da1ed8eda47413df3420ab64026696798 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Thu, 15 Jan 2026 11:46:13 -0500 Subject: [PATCH 166/418] Export GetError and SetError methods (#753) Exported GetError and SetError. Fixes #64 Co-authored-by: Claude --- mcp/mcp_test.go | 4 ++-- mcp/protocol.go | 10 +++++----- mcp/server.go | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index e272cb4..ce352ae 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -2124,7 +2124,7 @@ func TestToolErrorMiddleware(t *testing.T) { res, err := h(ctx, method, req) if err == nil { if ctr, ok := res.(*CallToolResult); ok { - middleErr = ctr.getError() + middleErr = ctr.GetError() } } return res, err @@ -2161,7 +2161,7 @@ func TestToolErrorMiddleware(t *testing.T) { t.Fatal("want error, got none") } // Clients can't see the error, because it isn't marshaled. - if err := res.getError(); err != nil { + if err := res.GetError(); err != nil { t.Fatalf("got %v, want nil", err) } if middleErr != errTestFailure { diff --git a/mcp/protocol.go b/mcp/protocol.go index 26c8982..bea776f 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -116,17 +116,17 @@ type CallToolResult struct { err error } -// TODO(#64): consider exposing setError (and getError), by adding an error -// field on CallToolResult. -func (r *CallToolResult) setError(err error) { +// SetError sets the error for the tool result and populates the Content field +// with the error text. It also sets IsError to true. +func (r *CallToolResult) SetError(err error) { r.Content = []Content{&TextContent{Text: err.Error()}} r.IsError = true r.err = err } -// getError returns the error set with setError, or nil if none. +// GetError returns the error set with SetError, or nil if none. // This function always returns nil on clients. -func (r *CallToolResult) getError() error { +func (r *CallToolResult) GetError() error { return r.err } diff --git a/mcp/server.go b/mcp/server.go index f24a6a8..1485b88 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -339,7 +339,7 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHan } // For regular errors, embed them in the tool result as per MCP spec var errRes CallToolResult - errRes.setError(err) + errRes.SetError(err) return &errRes, nil } From 3381035a5011e56a1ab480e6c9d4214674e5c8fd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Jan 2026 13:49:00 +0000 Subject: [PATCH 167/418] .github/workflows: add nightly conformance tests Add a conformance test job to the nightly GitHub workflow that runs the SDK conformance tests using ./scripts/conformance.sh. The job: - Runs on ubuntu-latest - Sets up Go 1.25 - Sets up Node.js 20 (required for npx) - Executes the conformance test script This ensures the SDK is regularly tested against the official MCP conformance test suite to catch regressions early. --- .github/workflows/nightly.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 9a90793..0aa95df 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -18,3 +18,19 @@ jobs: with: go-version-file: go.mod go-package: ./... + + conformance: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 + with: + go-version: "^1.25" + - name: Set up Node.js + uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + with: + node-version: "20" + - name: Run conformance tests + run: ./scripts/conformance.sh From c2c7edc3f026a63ce82fa6a2bf5359c48b441502 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 21 Jan 2026 02:16:46 +0100 Subject: [PATCH 168/418] perf: add schema caching to avoid repeated reflection (#685) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR adds schema caching that dramatically reduces the cost of tool registration in stateless server patterns. **Contributors:** Thanks to @omgitsads and @almaleksia for collaborating on and researching this solution! ## Problem In stateless deployments like `github/github-mcp-server`, a new `*mcp.Server` is created for each incoming request. This means `AddTool` is called repeatedly for the same tools, causing: 1. **For typed handlers**: `jsonschema.ForType()` reflection is called every time 2. **For pre-defined schemas**: `schema.Resolve()` is called every time ## Solution Add a `schemaCache` that stores: - Schemas by `reflect.Type` (for auto-generated schemas from typed handlers) - Resolved schemas by schema pointer (for pre-defined schemas) The cache is: - Concurrent-safe using `sync.Map` - Unbounded (typical MCP servers have <100 tools) - Global (lives across server instances) ## Benchmark Results ``` BenchmarkAddToolTypedHandler-22 977080 1223 ns/op 1208 B/op 21 allocs/op BenchmarkAddToolTypedHandlerNoCache-22 6764 161463 ns/op 39262 B/op 1072 allocs/op ``` | Metric | With Cache | Without Cache | Improvement | |--------|------------|---------------|-------------| | Time | 1,223 ns/op | 161,463 ns/op | **132x faster** | | Allocations | 21 allocs | 1,072 allocs | **51x fewer** | | Memory | 1,208 B/op | 39,262 B/op | **32x less** | ## Files Changed - `mcp/schema_cache.go` - New cache implementation - `mcp/server.go` - Modified `setSchema` to use cache - `mcp/schema_cache_test.go` - Unit tests for caching behavior - `mcp/schema_cache_benchmark_test.go` - Benchmarks ## Impact for Integrators **Automatic** - no code changes required. Integrators using: - Typed handlers (`AddTool[In, Out]`) → cache by type - Pre-defined schemas (`Tool{InputSchema: schema}`) → cache by pointer Both patterns benefit from caching after the first call. --- ## Real-World Performance Validation The following benchmarks were conducted using `github/github-mcp-server` (a production MCP server with ~130 tools) to validate the performance impact in a real-world scenario. ### Test Environment - **Server:** github-mcp-server (stateless HTTP deployment) - **Tools registered per request:** ~130 tools - **Test methodology:** Python benchmark client, 30 iterations (latency), 100 iterations (stress) - **Date:** December 4, 2025 ### Configurations Tested | Configuration | Description | |--------------|-------------| | **main (mcp-go)** | Original implementation using mcp-go library | | **go-sdk (no cache)** | go-sdk WITHOUT schema caching (broken) | | **go-sdk (with cache)** | go-sdk WITH this PR's schema caching fix | ### Latency Test Results (n=30) #### Operation: `initialize` | Configuration | P50 | P99 | Status | |--------------|-----|-----|--------| | main (mcp-go) | 11.48ms | 14.00ms | ✅ Baseline | | go-sdk (no cache) | 20.47ms | 25.30ms | 🔴 **+78% REGRESSION** | | go-sdk (with cache) | 10.36ms | 14.33ms | ✅ **FIXED (-10%)** | #### Operation: `tools/list` | Configuration | P50 | P99 | Status | |--------------|-----|-----|--------| | main (mcp-go) | 13.46ms | 22.47ms | ✅ Baseline | | go-sdk (no cache) | 22.98ms | 29.11ms | 🔴 **+71% REGRESSION** | | go-sdk (with cache) | 14.15ms | 15.91ms | ✅ **FIXED (+5%)** | #### Operation: `prompts/list` | Configuration | P50 | P99 | Status | |--------------|-----|-----|--------| | main (mcp-go) | 11.56ms | 13.81ms | ✅ Baseline | | go-sdk (no cache) | 21.00ms | 26.42ms | 🔴 **+82% REGRESSION** | | go-sdk (with cache) | 10.77ms | 15.25ms | ✅ **FIXED (-7%)** | ### Stress Test Results (n=100) | Configuration | P50 (initialize) | P50 (tools/list) | P50 (prompts/list) | |--------------|------------------|------------------|-------------------| | main (mcp-go) | 12.06ms | 14.30ms | 11.34ms | | go-sdk (no cache) | 20.06ms | 23.44ms | 19.59ms | | go-sdk (with cache) | **11.83ms** | **14.58ms** | **11.05ms** | ### Memory/Allocation Comparison (from pprof) | Configuration | Total Allocations | Comparison | |--------------|-------------------|------------| | main (mcp-go) | 355.91 MB | Baseline | | go-sdk (no cache) | **1208.70 MB** | 🔴 **3.4x MORE allocations** | #### Top Allocation Sources - go-sdk WITHOUT cache (broken) | Function | Size | % | Issue | |----------|------|---|-------| | `jsonschema.UnmarshalJSON` | 324.60 MB | 27% | 🚨 Schema re-parsing | | `encoding/json.Unmarshal` | 341.10 MB | 28% | JSON deserialization | | `jsonschema.resolve` | 219.51 MB | 18% | 🚨 Schema re-resolution | | `jsonschema.MarshalJSON` | 92.52 MB | 8% | Schema JSON encoding | ### Root Cause Analysis The `google/jsonschema-go` library regenerates JSON schemas on every request instead of caching them. In a server with ~130 tools, this causes: - **70-80% latency regression** on all MCP operations - **3.4x more memory allocations** per request - **~70% of all allocations** are schema-related operations ### Key Findings 1. ✅ **REGRESSION CONFIRMED:** go-sdk without schema caching is 70-80% slower than mcp-go 2. ✅ **FIX VERIFIED:** Schema caching restores performance to baseline (or better) 3. ✅ **MEMORY IMPACT:** 3.4x reduction in allocations with caching 4. ✅ **PRODUCTION READY:** Fixed version performs equivalently to mcp-go baseline --------- Co-authored-by: Adam Holt <4619+omgitsads@users.noreply.github.com> Co-authored-by: Ksenia Bobrova <1885174+almaleksia@users.noreply.github.com> --- docs/server.md | 17 +++ internal/docs/server.src.md | 17 +++ mcp/schema_cache.go | 69 ++++++++++++ mcp/schema_cache_test.go | 208 ++++++++++++++++++++++++++++++++++++ mcp/server.go | 93 ++++++++++++---- mcp/server_test.go | 2 +- 6 files changed, 384 insertions(+), 22 deletions(-) create mode 100644 mcp/schema_cache.go create mode 100644 mcp/schema_cache_test.go diff --git a/docs/server.md b/docs/server.md index 4c3c80b..e8c19f5 100644 --- a/docs/server.md +++ b/docs/server.md @@ -403,6 +403,23 @@ _See [mcp/tool_example_test.go](../mcp/tool_example_test.go) for the full example, or [examples/server/toolschemas](examples/server/toolschemas/main.go) for more examples of customizing tool schemas._ +**Stateless server deployments:** Some deployments create a new +[`Server`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server) +for each incoming request, re-registering tools every time. To avoid repeated +schema generation, create a +[`SchemaCache`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#SchemaCache) +and share it across server instances: + +```go +var schemaCache = mcp.NewSchemaCache() // create once at startup + +func handleRequest(w http.ResponseWriter, r *http.Request) { + s := mcp.NewServer(impl, &mcp.ServerOptions{SchemaCache: schemaCache}) + mcp.AddTool(s, myTool, myHandler) + // ... +} +``` + ## Utilities ### Completion diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index c6946db..694fc0f 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -193,6 +193,23 @@ _See [mcp/tool_example_test.go](../mcp/tool_example_test.go) for the full example, or [examples/server/toolschemas](examples/server/toolschemas/main.go) for more examples of customizing tool schemas._ +**Stateless server deployments:** Some deployments create a new +[`Server`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server) +for each incoming request, re-registering tools every time. To avoid repeated +schema generation, create a +[`SchemaCache`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#SchemaCache) +and share it across server instances: + +```go +var schemaCache = mcp.NewSchemaCache() // create once at startup + +func handleRequest(w http.ResponseWriter, r *http.Request) { + s := mcp.NewServer(impl, &mcp.ServerOptions{SchemaCache: schemaCache}) + mcp.AddTool(s, myTool, myHandler) + // ... +} +``` + ## Utilities ### Completion diff --git a/mcp/schema_cache.go b/mcp/schema_cache.go new file mode 100644 index 0000000..5fb032d --- /dev/null +++ b/mcp/schema_cache.go @@ -0,0 +1,69 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +import ( + "reflect" + "sync" + + "github.com/google/jsonschema-go/jsonschema" +) + +// A SchemaCache caches JSON schemas to avoid repeated reflection and resolution. +// +// This is useful for stateless server deployments (one [Server] per request) +// where tools are re-registered on every request. Without caching, each +// [AddTool] call triggers expensive reflection-based schema generation. +// +// A SchemaCache is safe for concurrent use by multiple goroutines. +// +// # Trade-offs +// +// The cache is unbounded: it stores one entry per unique Go type or schema +// pointer. For typical MCP servers with a fixed set of tools, memory usage +// is negligible. However, if tool input types are generated dynamically, +// the cache will grow without bound. +// +// The cache uses pointer identity for pre-defined schemas. If a schema's +// contents change but the pointer remains the same, stale resolved schemas +// may be returned. In practice, this is not an issue because tool schemas +// are typically defined once at startup. +type SchemaCache struct { + byType sync.Map // reflect.Type -> *cachedSchema + bySchema sync.Map // *jsonschema.Schema -> *jsonschema.Resolved +} + +type cachedSchema struct { + schema *jsonschema.Schema + resolved *jsonschema.Resolved +} + +// NewSchemaCache creates a new [SchemaCache]. +func NewSchemaCache() *SchemaCache { + return &SchemaCache{} +} + +func (c *SchemaCache) getByType(t reflect.Type) (*jsonschema.Schema, *jsonschema.Resolved, bool) { + if v, ok := c.byType.Load(t); ok { + cs := v.(*cachedSchema) + return cs.schema, cs.resolved, true + } + return nil, nil, false +} + +func (c *SchemaCache) setByType(t reflect.Type, schema *jsonschema.Schema, resolved *jsonschema.Resolved) { + c.byType.Store(t, &cachedSchema{schema: schema, resolved: resolved}) +} + +func (c *SchemaCache) getBySchema(schema *jsonschema.Schema) (*jsonschema.Resolved, bool) { + if v, ok := c.bySchema.Load(schema); ok { + return v.(*jsonschema.Resolved), true + } + return nil, false +} + +func (c *SchemaCache) setBySchema(schema *jsonschema.Schema, resolved *jsonschema.Resolved) { + c.bySchema.Store(schema, resolved) +} diff --git a/mcp/schema_cache_test.go b/mcp/schema_cache_test.go new file mode 100644 index 0000000..bba5e24 --- /dev/null +++ b/mcp/schema_cache_test.go @@ -0,0 +1,208 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +import ( + "context" + "reflect" + "testing" + + "github.com/google/jsonschema-go/jsonschema" +) + +func TestSchemaCacheByType(t *testing.T) { + cache := NewSchemaCache() + + type TestInput struct { + Name string `json:"name"` + } + + rt := reflect.TypeFor[TestInput]() + + if _, _, ok := cache.getByType(rt); ok { + t.Error("expected cache miss for new type") + } + + schema := &jsonschema.Schema{Type: "object"} + resolved, err := schema.Resolve(nil) + if err != nil { + t.Fatalf("failed to resolve schema: %v", err) + } + cache.setByType(rt, schema, resolved) + + gotSchema, gotResolved, ok := cache.getByType(rt) + if !ok { + t.Error("expected cache hit after set") + } + if gotSchema != schema { + t.Error("schema mismatch") + } + if gotResolved != resolved { + t.Error("resolved schema mismatch") + } +} + +func TestSchemaCacheBySchema(t *testing.T) { + cache := NewSchemaCache() + + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "query": {Type: "string"}, + }, + } + + if _, ok := cache.getBySchema(schema); ok { + t.Error("expected cache miss for new schema") + } + + resolved, err := schema.Resolve(nil) + if err != nil { + t.Fatalf("failed to resolve schema: %v", err) + } + cache.setBySchema(schema, resolved) + + gotResolved, ok := cache.getBySchema(schema) + if !ok { + t.Error("expected cache hit after set") + } + if gotResolved != resolved { + t.Error("resolved schema mismatch") + } + + // Different pointer should miss (cache uses pointer identity). + schema2 := &jsonschema.Schema{Type: "object"} + if _, ok = cache.getBySchema(schema2); ok { + t.Error("expected cache miss for different schema pointer") + } +} + +func TestSetSchemaCachesGeneratedSchemas(t *testing.T) { + cache := NewSchemaCache() + + type TestInput struct { + Query string `json:"query"` + } + + rt := reflect.TypeFor[TestInput]() + + var sfield1 any + var rfield1 *jsonschema.Resolved + if _, err := setSchema[TestInput](&sfield1, &rfield1, cache); err != nil { + t.Fatalf("setSchema failed: %v", err) + } + + cachedSchema, cachedResolved, ok := cache.getByType(rt) + if !ok { + t.Fatal("schema not cached after first setSchema call") + } + + var sfield2 any + var rfield2 *jsonschema.Resolved + if _, err := setSchema[TestInput](&sfield2, &rfield2, cache); err != nil { + t.Fatalf("setSchema failed on second call: %v", err) + } + + if sfield2.(*jsonschema.Schema) != cachedSchema { + t.Error("expected cached schema to be returned") + } + if rfield2 != cachedResolved { + t.Error("expected cached resolved schema to be returned") + } +} + +func TestSetSchemaCachesProvidedSchemas(t *testing.T) { + cache := NewSchemaCache() + + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "query": {Type: "string"}, + }, + } + + var sfield1 any = schema + var rfield1 *jsonschema.Resolved + if _, err := setSchema[map[string]any](&sfield1, &rfield1, cache); err != nil { + t.Fatalf("setSchema failed: %v", err) + } + + cachedResolved, ok := cache.getBySchema(schema) + if !ok { + t.Fatal("resolved schema not cached after first setSchema call") + } + if rfield1 != cachedResolved { + t.Error("expected same resolved schema") + } + + var sfield2 any = schema + var rfield2 *jsonschema.Resolved + if _, err := setSchema[map[string]any](&sfield2, &rfield2, cache); err != nil { + t.Fatalf("setSchema failed on second call: %v", err) + } + + if rfield2 != cachedResolved { + t.Error("expected cached resolved schema to be returned") + } +} + +func TestSetSchemaNilCache(t *testing.T) { + type TestInput struct { + Query string `json:"query"` + } + + var sfield1 any + var rfield1 *jsonschema.Resolved + if _, err := setSchema[TestInput](&sfield1, &rfield1, nil); err != nil { + t.Fatalf("setSchema failed: %v", err) + } + + var sfield2 any + var rfield2 *jsonschema.Resolved + if _, err := setSchema[TestInput](&sfield2, &rfield2, nil); err != nil { + t.Fatalf("setSchema failed on second call: %v", err) + } + + if sfield1 == nil || sfield2 == nil { + t.Error("expected schemas to be generated") + } + if rfield1 == nil || rfield2 == nil { + t.Error("expected resolved schemas to be generated") + } +} + +func TestAddToolWithSharedCache(t *testing.T) { + cache := NewSchemaCache() + + type GreetInput struct { + Name string `json:"name" jsonschema:"the name to greet"` + } + + type GreetOutput struct { + Message string `json:"message"` + } + + handler := func(ctx context.Context, req *CallToolRequest, in GreetInput) (*CallToolResult, GreetOutput, error) { + return &CallToolResult{}, GreetOutput{Message: "Hello, " + in.Name}, nil + } + + tool := &Tool{ + Name: "greet", + Description: "Greet someone", + } + + // Simulate stateless server pattern: new server per request, shared cache. + for i := 0; i < 3; i++ { + s := NewServer(&Implementation{Name: "test", Version: "1.0"}, &ServerOptions{ + SchemaCache: cache, + }) + AddTool(s, tool, handler) + } + + rt := reflect.TypeFor[GreetInput]() + if _, _, ok := cache.getByType(rt); !ok { + t.Error("expected schema to be cached by type after multiple AddTool calls") + } +} diff --git a/mcp/server.go b/mcp/server.go index 1485b88..687e799 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -127,6 +127,11 @@ type ServerOptions struct { // // Deprecated: Use Capabilities instead. HasTools bool + // SchemaCache, if non-nil, caches JSON schemas to avoid repeated + // reflection. This is useful for stateless server deployments where + // a new [Server] is created for each request. See [SchemaCache] for + // trade-offs and usage guidance. + SchemaCache *SchemaCache // GetSessionID provides the next session ID to use for an incoming request. // If nil, a default randomly generated ID will be used. @@ -275,7 +280,7 @@ func (s *Server) AddTool(t *Tool, h ToolHandler) { s.changeAndNotify(notificationToolListChanged, func() bool { s.tools.add(st); return true }) } -func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHandler, error) { +func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out], cache *SchemaCache) (*Tool, ToolHandler, error) { tt := *t // Special handling for an "any" input: treat as an empty object. @@ -284,7 +289,7 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHan } var inputResolved *jsonschema.Resolved - if _, err := setSchema[In](&tt.InputSchema, &inputResolved); err != nil { + if _, err := setSchema[In](&tt.InputSchema, &inputResolved, cache); err != nil { return nil, nil, fmt.Errorf("input schema: %w", err) } @@ -299,7 +304,7 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHan ) if t.OutputSchema != nil || reflect.TypeFor[Out]() != reflect.TypeFor[any]() { var err error - elemZero, err = setSchema[Out](&tt.OutputSchema, &outputResolved) + elemZero, err = setSchema[Out](&tt.OutputSchema, &outputResolved, cache) if err != nil { return nil, nil, fmt.Errorf("output schema: %v", err) } @@ -400,29 +405,75 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out]) (*Tool, ToolHan // pointer: if the user provided the schema, they may have intentionally // derived it from the pointer type, and handling of zero values is up to them. // +// If cache is non-nil, schemas are cached to avoid repeated reflection. +// // TODO(rfindley): we really shouldn't ever return 'null' results. Maybe we // should have a jsonschema.Zero(schema) helper? -func setSchema[T any](sfield *any, rfield **jsonschema.Resolved) (zero any, err error) { - var internalSchema *jsonschema.Schema - if *sfield == nil { - rt := reflect.TypeFor[T]() - if rt.Kind() == reflect.Pointer { - rt = rt.Elem() - zero = reflect.Zero(rt).Interface() - } - // TODO: we should be able to pass nil opts here. - internalSchema, err = jsonschema.ForType(rt, &jsonschema.ForOptions{}) - if err == nil { - *sfield = internalSchema - } - } else if err := remarshal(*sfield, &internalSchema); err != nil { - return zero, err +func setSchema[T any](sfield *any, rfield **jsonschema.Resolved, cache *SchemaCache) (zero any, err error) { + rt := reflect.TypeFor[T]() + if rt.Kind() == reflect.Pointer { + rt = rt.Elem() + zero = reflect.Zero(rt).Interface() } + + var internalSchema *jsonschema.Schema + + if *sfield == nil { + // No schema provided: check cache, or generate via reflection. + if cache != nil { + if schema, resolved, ok := cache.getByType(rt); ok { + *sfield = schema + *rfield = resolved + return zero, nil + } + } + + internalSchema, err = jsonschema.ForType(rt, &jsonschema.ForOptions{}) + if err != nil { + return zero, err + } + *sfield = internalSchema + + resolved, err := internalSchema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true}) + if err != nil { + return zero, err + } + *rfield = resolved + if cache != nil { + cache.setByType(rt, internalSchema, resolved) + } + return zero, nil + } + + // Schema was provided: check cache by pointer, or resolve it. + if providedSchema, ok := (*sfield).(*jsonschema.Schema); ok { + if cache != nil { + if resolved, ok := cache.getBySchema(providedSchema); ok { + *rfield = resolved + return zero, nil + } + } + internalSchema = providedSchema + } else { + // Schema provided as different type (e.g., map): remarshal to *Schema. + if err := remarshal(*sfield, &internalSchema); err != nil { + return zero, err + } + } + + resolved, err := internalSchema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true}) if err != nil { return zero, err } - *rfield, err = internalSchema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true}) - return zero, err + *rfield = resolved + + if cache != nil { + if providedSchema, ok := (*sfield).(*jsonschema.Schema); ok { + cache.setBySchema(providedSchema, resolved) + } + } + + return zero, nil } // AddTool adds a tool and typed tool handler to the server. @@ -445,7 +496,7 @@ func setSchema[T any](sfield *any, rfield **jsonschema.Resolved) (zero any, err // tools to conform to the MCP spec. See [ToolHandlerFor] for a detailed // description of this automatic behavior. func AddTool[In, Out any](s *Server, t *Tool, h ToolHandlerFor[In, Out]) { - tt, hh, err := toolForErr(t, h) + tt, hh, err := toolForErr(t, h, s.opts.SchemaCache) if err != nil { panic(fmt.Sprintf("AddTool: tool %q: %v", t.Name, err)) } diff --git a/mcp/server_test.go b/mcp/server_test.go index 1cd9d88..0f86f76 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -640,7 +640,7 @@ func testToolForSchema[In, Out any](t *testing.T, tool *Tool, in string, out Out th := func(context.Context, *CallToolRequest, In) (*CallToolResult, Out, error) { return nil, out, nil } - gott, goth, err := toolForErr(tool, th) + gott, goth, err := toolForErr(tool, th, nil) if err != nil { t.Fatal(err) } From 52e03de6c86c413ddee4bf4991e558ab97a630b6 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 22 Jan 2026 09:43:45 +0100 Subject: [PATCH 169/418] =?UTF-8?q?mcp:=20add=20Allow=20header=20to=20405?= =?UTF-8?q?=20responses=20per=20RFC=209110=20=C2=A715.5.6=20(#757)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 405 Method Not Allowed responses MUST include an Allow header listing supported methods, per RFC 9110 Section 15.5.6. This fixes issues with strict HTTP gateways (like Apigee) that treat 405 responses without an Allow header as malformed, returning 502 Bad Gateway errors. ## Changes - **SSEHandler**: Add `Allow: GET, POST` header for unsupported methods - **StreamableHTTPHandler**: Add Allow header for GET-without-session case: - Stateless mode: `Allow: POST, DELETE` (GET is never valid) - Stateful mode: `Allow: GET, POST, DELETE` (GET is valid once you have a session) - Add tests to verify Allow header presence in all 405 responses ## RFC 9110 Reference [Section 15.5.6 (405 Method Not Allowed)](https://httpwg.org/specs/rfc9110.html#status.405): > The origin server MUST generate an Allow header field in a 405 response containing a list of the target resource's currently supported methods. ## Testing All existing tests pass, plus new tests added: - `TestSSE405AllowHeader` - verifies SSE handler compliance - `TestStreamable405AllowHeader` - verifies Streamable handler compliance in both stateful and stateless modes --------- Co-authored-by: omgitsads <4619+omgitsads@users.noreply.github.com> --- mcp/sse.go | 3 +- mcp/sse_test.go | 35 +++++++++++++++ mcp/streamable.go | 21 +++++++-- mcp/streamable_test.go | 99 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 153 insertions(+), 5 deletions(-) diff --git a/mcp/sse.go b/mcp/sse.go index a668c6d..ae65c16 100644 --- a/mcp/sse.go +++ b/mcp/sse.go @@ -202,7 +202,8 @@ func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { } if req.Method != http.MethodGet { - http.Error(w, "invalid method", http.StatusMethodNotAllowed) + w.Header().Set("Allow", "GET, POST") + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } diff --git a/mcp/sse_test.go b/mcp/sse_test.go index 132a031..8746cc8 100644 --- a/mcp/sse_test.go +++ b/mcp/sse_test.go @@ -186,3 +186,38 @@ func TestSSEClientTransport_HTTPErrors(t *testing.T) { }) } } + +// TestSSE405AllowHeader verifies RFC 9110 §15.5.6 compliance: +// 405 Method Not Allowed responses MUST include an Allow header. +func TestSSE405AllowHeader(t *testing.T) { + server := NewServer(testImpl, nil) + + handler := NewSSEHandler(func(req *http.Request) *Server { return server }, nil) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + methods := []string{"PUT", "PATCH", "DELETE", "OPTIONS"} + for _, method := range methods { + t.Run(method, func(t *testing.T) { + req, err := http.NewRequest(method, httpServer.URL, nil) + if err != nil { + t.Fatal(err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if got, want := resp.StatusCode, http.StatusMethodNotAllowed; got != want { + t.Errorf("status code: got %d, want %d", got, want) + } + + allow := resp.Header.Get("Allow") + if allow != "GET, POST" { + t.Errorf("Allow header: got %q, want %q", allow, "GET, POST") + } + }) + } +} diff --git a/mcp/streamable.go b/mcp/streamable.go index 167d684..f2a2895 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -275,12 +275,27 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque switch req.Method { case http.MethodPost, http.MethodGet: if req.Method == http.MethodGet && (h.opts.Stateless || sessionID == "") { - http.Error(w, "GET requires an active session", http.StatusMethodNotAllowed) + if h.opts.Stateless { + // Per MCP spec: server MUST return 405 if it doesn't offer SSE stream. + // In stateless mode, GET (SSE streaming) is not supported. + // RFC 9110 §15.5.6: 405 responses MUST include Allow header. + w.Header().Set("Allow", "POST") + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + } else { + // In stateful mode, GET is supported but requires a session ID. + // This is a precondition error, similar to DELETE without session. + http.Error(w, "Bad Request: GET requires an Mcp-Session-Id header", http.StatusBadRequest) + } return } default: - w.Header().Set("Allow", "GET, POST, DELETE") - http.Error(w, "Method Not Allowed: streamable MCP servers support GET, POST, and DELETE requests", http.StatusMethodNotAllowed) + // RFC 9110 §15.5.6: 405 responses MUST include Allow header. + if h.opts.Stateless { + w.Header().Set("Allow", "POST") + } else { + w.Header().Set("Allow", "GET, POST, DELETE") + } + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index b1c3f07..56c0a49 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1829,7 +1829,9 @@ func TestStreamableGET(t *testing.T) { if err != nil { t.Fatal(err) } - if got, want := resp.StatusCode, http.StatusMethodNotAllowed; got != want { + // GET without session should return 400 Bad Request (not 405) because + // GET is a valid method - it just requires a session ID. + if got, want := resp.StatusCode, http.StatusBadRequest; got != want { t.Errorf("initial GET: got status %d, want %d", got, want) } defer resp.Body.Close() @@ -1877,6 +1879,101 @@ func TestStreamableGET(t *testing.T) { } } +// TestStreamable405AllowHeader verifies RFC 9110 §15.5.6 compliance: +// 405 Method Not Allowed responses MUST include an Allow header. +func TestStreamable405AllowHeader(t *testing.T) { + server := NewServer(testImpl, nil) + + tests := []struct { + name string + stateless bool + method string + wantStatus int + wantAllow string + }{ + { + name: "unsupported method stateful", + stateless: false, + method: "PUT", + wantStatus: http.StatusMethodNotAllowed, + wantAllow: "GET, POST, DELETE", + }, + { + name: "GET in stateless mode", + stateless: true, + method: "GET", + wantStatus: http.StatusMethodNotAllowed, + wantAllow: "POST", + }, + { + // DELETE without session returns 400 Bad Request (not 405) + // because DELETE is a valid method, just requires a session ID. + name: "DELETE without session stateless", + stateless: true, + method: "DELETE", + wantStatus: http.StatusBadRequest, + wantAllow: "", // No Allow header for 400 responses + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &StreamableHTTPOptions{Stateless: tt.stateless} + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, opts) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + defer httpServer.Close() + + req, err := http.NewRequest(tt.method, httpServer.URL, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Accept", "application/json, text/event-stream") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if got := resp.StatusCode; got != tt.wantStatus { + t.Errorf("status code: got %d, want %d", got, tt.wantStatus) + } + + allow := resp.Header.Get("Allow") + if allow != tt.wantAllow { + t.Errorf("Allow header: got %q, want %q", allow, tt.wantAllow) + } + }) + } +} + +// TestStreamableGETWithoutSession verifies that GET without session ID in stateful mode +// returns 400 Bad Request (not 405), since GET is a supported method that requires a session. +func TestStreamableGETWithoutSession(t *testing.T) { + server := NewServer(testImpl, nil) + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + defer httpServer.Close() + + req, err := http.NewRequest("GET", httpServer.URL, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Accept", "text/event-stream") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + // GET without session should return 400 Bad Request, not 405 Method Not Allowed, + // because GET is a valid method - it just requires a session ID. + if got, want := resp.StatusCode, http.StatusBadRequest; got != want { + t.Errorf("status code: got %d, want %d", got, want) + } +} + func TestStreamableClientContextPropagation(t *testing.T) { type contextKey string const testKey = contextKey("test-key") From 6b75899fd7dbc168b44b9403b7556be077f88fee Mon Sep 17 00:00:00 2001 From: Maciek Kisiel Date: Fri, 23 Jan 2026 11:09:14 +0000 Subject: [PATCH 170/418] mcp: fix a race condition in logging Reading the buffer outside of a critical section can lead to data corruption. --- mcp/logging.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/mcp/logging.go b/mcp/logging.go index 208427e..96a96b8 100644 --- a/mcp/logging.go +++ b/mcp/logging.go @@ -10,6 +10,7 @@ import ( "context" "encoding/json" "log/slog" + "slices" "sync" "time" ) @@ -165,8 +166,7 @@ func (h *LoggingHandler) Handle(ctx context.Context, r slog.Record) error { func (h *LoggingHandler) handle(ctx context.Context, r slog.Record) error { // Observe the rate limit. - // TODO(jba): use golang.org/x/time/rate. (We can't here because it would require adding - // golang.org/x/time to the go.mod file.) + // TODO(jba): use golang.org/x/time/rate. h.mu.Lock() skip := time.Since(h.lastMessageSent) < h.opts.MinInterval h.mu.Unlock() @@ -175,6 +175,7 @@ func (h *LoggingHandler) handle(ctx context.Context, r slog.Record) error { } var err error + var data json.RawMessage // Make the buffer reset atomic with the record write. // We are careful here in the unlikely event that the handler panics. // We don't want to hold the lock for the entire function, because Notify is @@ -185,6 +186,8 @@ func (h *LoggingHandler) handle(ctx context.Context, r slog.Record) error { defer h.mu.Unlock() h.buf.Reset() err = h.handler.Handle(ctx, r) + // Clone the buffer as Bytes() references the internal buffer. + data = json.RawMessage(slices.Clone(h.buf.Bytes())) }() if err != nil { return err @@ -197,7 +200,7 @@ func (h *LoggingHandler) handle(ctx context.Context, r slog.Record) error { params := &LoggingMessageParams{ Logger: h.opts.LoggerName, Level: slogLevelToMCP(r.Level), - Data: json.RawMessage(h.buf.Bytes()), + Data: data, } // We pass the argument context to Notify, even though slog.Handler.Handle's // documentation says not to. From fe6d3cf74ae02acac75540b9cc34c3e78348a0aa Mon Sep 17 00:00:00 2001 From: adam jones Date: Wed, 28 Jan 2026 08:17:41 +0000 Subject: [PATCH 171/418] chore: update licensing to Apache 2.0 for new contributions (#750) As part of MCP joining the Linux Foundation, this PR updates licensing to Apache 2.0 for new contributions while grandfathering existing MIT code. See modelcontextprotocol/modelcontextprotocol#1994 --------- Co-authored-by: Maciek Kisiel --- CONTRIBUTING.md | 10 +- LICENSE | 197 +++++++++++++++++++++++++++++++++- README.md | 4 +- internal/readme/README.src.md | 4 +- 4 files changed, 205 insertions(+), 10 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b340182..303f6c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -123,14 +123,14 @@ Changes should be high quality and well tested, and should generally follow the messages should follow the [format used by the Go project](https://go.dev/wiki/CommitMessage). -Unless otherwise noted, the Go source files are distributed under the MIT-style -license found in the LICENSE file. All Go files in the SDK should have a -copyright header following the format below: +Unless otherwise noted, the Go source files are distributed under the license +found in the LICENSE file. New contributions are licensed under Apache 2.0. All +Go files in the SDK should have a copyright header following the format below: ```go // Copyright 2025 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. ``` ### Adding and updating dependencies diff --git a/LICENSE b/LICENSE index 508be92..5791499 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,193 @@ +The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0. + +Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License. + +No rights beyond those granted by the applicable original license are conveyed for such contributions. + +--- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright + owner or by an individual or Legal Entity authorized to submit on behalf + of the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + +--- + MIT License -Copyright (c) 2025 Go MCP SDK Authors +Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -19,3 +206,11 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +Creative Commons Attribution 4.0 International (CC-BY-4.0) + +Documentation in this project (excluding specifications) is licensed under +CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for +the full license text. diff --git a/README.md b/README.md index d224ede..5ab6569 100644 --- a/README.md +++ b/README.md @@ -150,5 +150,5 @@ is a thriving ecosystem of Go MCP clients and servers. ## License -This project is licensed under the MIT License - see the [LICENSE](./LICENSE) -file for details. +This project is licensed under Apache 2.0 for new contributions, with existing +code under MIT - see the [LICENSE](./LICENSE) file for details. diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md index e23fe6d..6a83231 100644 --- a/internal/readme/README.src.md +++ b/internal/readme/README.src.md @@ -73,5 +73,5 @@ is a thriving ecosystem of Go MCP clients and servers. ## License -This project is licensed under the MIT License - see the [LICENSE](./LICENSE) -file for details. +This project is licensed under Apache 2.0 for new contributions, with existing +code under MIT - see the [LICENSE](./LICENSE) file for details. From b29ab52878a105fc59a540487437490a82d9ef8a Mon Sep 17 00:00:00 2001 From: FanOne <294350394@qq.com> Date: Thu, 29 Jan 2026 00:06:06 +0800 Subject: [PATCH 172/418] mcp: validation only for accept action (#766) Fix: #758 - validate schema only for accept action - add unit test --- mcp/client.go | 2 +- mcp/elicitation_test.go | 68 +++++++++++++++++++++++++++++++++++++++++ mcp/server.go | 4 +++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/mcp/client.go b/mcp/client.go index 57c30fb..a167047 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -590,7 +590,7 @@ func (c *Client) elicit(ctx context.Context, req *ElicitRequest) (*ElicitResult, return nil, err } // Validate elicitation result content against requested schema. - if schema != nil && res.Content != nil { + if res.Action == "accept" && schema != nil && res.Content != nil { resolved, err := schema.Resolve(nil) if err != nil { return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: fmt.Sprintf("failed to resolve requested schema: %v", err)} diff --git a/mcp/elicitation_test.go b/mcp/elicitation_test.go index 52ae1d7..bfa71e7 100644 --- a/mcp/elicitation_test.go +++ b/mcp/elicitation_test.go @@ -201,3 +201,71 @@ func TestElicitationCompleteNotification(t *testing.T) { t.Fatal("timed out waiting for elicitation complete notification") } } + +func TestElicitationNoValidationWithoutAccept(t *testing.T) { + ctx := context.Background() + + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "test": {Type: "string"}, + }, + Required: []string{"test"}, + } + + testCases := []struct { + name string + action string + content map[string]any + wantAction string + }{ + { + name: "cancel action", + action: "cancel", + content: nil, // Empty content should be ignored + wantAction: "cancel", + }, + { + name: "decline action", + action: "decline", + content: map[string]any{}, // Empty content should be ignored + wantAction: "decline", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ct, st := NewInMemoryTransports() + s := NewServer(testImpl, nil) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + c := NewClient(testImpl, &ClientOptions{ + ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: tc.action, Content: tc.content}, nil + }, + }) + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + res, err := ss.Elicit(ctx, &ElicitParams{ + Message: "Test bug", + RequestedSchema: schema, + }) + + if err != nil { + t.Fatalf("Elicit failed: %v", err) + } + + if res.Action != tc.wantAction { + t.Errorf("Expected action %q, got %q", tc.wantAction, res.Action) + } + }) + } +} diff --git a/mcp/server.go b/mcp/server.go index 687e799..d68a8c2 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1218,6 +1218,10 @@ func (ss *ServerSession) Elicit(ctx context.Context, params *ElicitParams) (*Eli return nil, err } + if res.Action != "accept" { + return res, nil + } + if params.RequestedSchema == nil { return res, nil } From 66d6190c4ede40c6c12800da769a9934be7c2b79 Mon Sep 17 00:00:00 2001 From: Maciek Kisiel Date: Tue, 27 Jan 2026 10:33:39 +0000 Subject: [PATCH 173/418] chore: update dependencies to newest versions, require Go 1.24 --- go.mod | 8 ++++---- go.sum | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index 09b1be7..3b5b3b8 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,12 @@ module github.com/modelcontextprotocol/go-sdk -go 1.23.0 +go 1.24.0 require ( - github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/go-cmp v0.7.0 github.com/google/jsonschema-go v0.4.2 github.com/yosida95/uritemplate/v3 v3.0.2 - golang.org/x/oauth2 v0.30.0 - golang.org/x/tools v0.34.0 + golang.org/x/oauth2 v0.34.0 + golang.org/x/tools v0.41.0 ) diff --git a/go.sum b/go.sum index dc70202..3432579 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,12 @@ -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= From bba5acdc8b93e308c45170198b29a3d155ad649f Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Fri, 30 Jan 2026 17:13:30 +0100 Subject: [PATCH 174/418] mcp: update JSON marshaling to not HTML-escape messages (#769) It's causing some servers to fail: #767. I checked with Typescript implementation and it doesn't do any escaping. It's still a behavior change, so we'll expose a way to bring back the previous behavior via an env var. Fixes #238 Fixes #767 --- copyright_test.go | 3 +- internal/jsonrpc2/messages.go | 42 ++++++++++-- internal/mcpgodebug/mcpgodebug.go | 52 +++++++++++++++ internal/mcpgodebug/mcpgodebug_test.go | 88 ++++++++++++++++++++++++++ 4 files changed, 176 insertions(+), 9 deletions(-) create mode 100644 internal/mcpgodebug/mcpgodebug.go create mode 100644 internal/mcpgodebug/mcpgodebug_test.go diff --git a/copyright_test.go b/copyright_test.go index 287e594..bda479f 100644 --- a/copyright_test.go +++ b/copyright_test.go @@ -17,8 +17,7 @@ import ( func TestCopyrightHeaders(t *testing.T) { var re = regexp.MustCompile(`Copyright \d{4} The Go MCP SDK Authors. All rights reserved. -Use of this source code is governed by an MIT-style -license that can be found in the LICENSE file.`) +Use of this source code is governed by (the license\n|an MIT-style\nlicense )that can be found in the LICENSE file.`) err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error { if err != nil { diff --git a/internal/jsonrpc2/messages.go b/internal/jsonrpc2/messages.go index 791e698..6e58a6c 100644 --- a/internal/jsonrpc2/messages.go +++ b/internal/jsonrpc2/messages.go @@ -5,9 +5,12 @@ package jsonrpc2 import ( + "bytes" "encoding/json" "errors" "fmt" + + "github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug" ) // ID is a Request identifier, which is defined by the spec to be a string, integer, or null. @@ -145,9 +148,9 @@ func toWireError(err error) *WireError { func EncodeMessage(msg Message) ([]byte, error) { wire := wireCombined{VersionTag: wireVersion} msg.marshal(&wire) - data, err := json.Marshal(&wire) + data, err := jsonMarshal(&wire) if err != nil { - return data, fmt.Errorf("marshaling jsonrpc message: %w", err) + return nil, fmt.Errorf("marshaling jsonrpc message: %w", err) } return data, nil } @@ -158,11 +161,14 @@ func EncodeMessage(msg Message) ([]byte, error) { func EncodeIndent(msg Message, prefix, indent string) ([]byte, error) { wire := wireCombined{VersionTag: wireVersion} msg.marshal(&wire) - data, err := json.MarshalIndent(&wire, prefix, indent) - if err != nil { - return data, fmt.Errorf("marshaling jsonrpc message: %w", err) + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent(prefix, indent) + if err := enc.Encode(&wire); err != nil { + return nil, fmt.Errorf("marshaling jsonrpc message: %w", err) } - return data, nil + return bytes.TrimRight(buf.Bytes(), "\n"), nil } func DecodeMessage(data []byte) (Message, error) { @@ -204,9 +210,31 @@ func marshalToRaw(obj any) (json.RawMessage, error) { if obj == nil { return nil, nil } - data, err := json.Marshal(obj) + data, err := jsonMarshal(obj) if err != nil { return nil, err } return json.RawMessage(data), nil } + +// jsonescaping is a compatibility parameter that allows to restore +// JSON escaping in the JSON marshaling, which stopped being the default +// in the 1.4.0 version of the SDK. See the documentation for the +// mcpgodebug package for instructions how to enable it. +// The option will be removed in the 1.6.0 version of the SDK. +var jsonescaping = mcpgodebug.Value("jsonescaping") + +// jsonMarshal marshals obj to JSON like json.Marshal but without HTML escaping. +func jsonMarshal(obj any) ([]byte, error) { + if jsonescaping == "1" { + return json.Marshal(obj) + } + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(obj); err != nil { + return nil, err + } + // json.Encoder.Encode adds a trailing newline. Trim it to be consistent with json.Marshal. + return bytes.TrimRight(buf.Bytes(), "\n"), nil +} diff --git a/internal/mcpgodebug/mcpgodebug.go b/internal/mcpgodebug/mcpgodebug.go new file mode 100644 index 0000000..7f8f7ca --- /dev/null +++ b/internal/mcpgodebug/mcpgodebug.go @@ -0,0 +1,52 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +// Package mcpgodebug provides a mechanism to configure compatibility parameters +// via the MCPGODEBUG environment variable. +// +// The value of MCPGODEBUG is a comma-separated list of key=value pairs. +// For example: +// +// MCPGODEBUG=someoption=1,otheroption=value +package mcpgodebug + +import ( + "fmt" + "os" + "strings" +) + +const compatibilityEnvKey = "MCPGODEBUG" + +var compatibilityParams map[string]string + +func init() { + var err error + compatibilityParams, err = parseCompatibility(os.Getenv(compatibilityEnvKey)) + if err != nil { + panic(err) + } +} + +// Value returns the value of the compatibility parameter with the given key. +// It returns an empty string if the key is not set. +func Value(key string) string { + return compatibilityParams[key] +} + +func parseCompatibility(envValue string) (map[string]string, error) { + if envValue == "" { + return nil, nil + } + + params := make(map[string]string) + for part := range strings.SplitSeq(envValue, ",") { + k, v, ok := strings.Cut(part, "=") + if !ok { + return nil, fmt.Errorf("MCPGODEBUG: invalid format: %q", part) + } + params[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + return params, nil +} diff --git a/internal/mcpgodebug/mcpgodebug_test.go b/internal/mcpgodebug/mcpgodebug_test.go new file mode 100644 index 0000000..9bbbc32 --- /dev/null +++ b/internal/mcpgodebug/mcpgodebug_test.go @@ -0,0 +1,88 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package mcpgodebug + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestParseCompatibility_Success(t *testing.T) { + tests := []struct { + name string + envVal string + want map[string]string + }{ + { + name: "Basic", + envVal: "foo=bar,baz=qux", + want: map[string]string{ + "foo": "bar", + "baz": "qux", + }, + }, + { + name: "Empty", + envVal: "", + want: nil, + }, + { + name: "WithWhitespace", + envVal: " foo = bar \t, baz = qux ", + want: map[string]string{ + "foo": "bar", + "baz": "qux", + }, + }, + { + name: "WithEqualsSignInValue", + envVal: "foo=bar=baz", + want: map[string]string{ + "foo": "bar=baz", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseCompatibility(tt.envVal) + if err != nil { + t.Fatalf("parseCompatibility() failed: %v", err) + } + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("parseCompatibility() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestParseCompatibility_Failure(t *testing.T) { + tests := []struct { + name string + envVal string + }{ + { + name: "NoEqualsSign", + envVal: "invalidformat", + }, + { + name: "MixedValidAndInvalid", + envVal: "foo=bar,baz", + }, + { + name: "EmptyPart", + envVal: "foo=bar,,baz=qux", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseCompatibility(tt.envVal) + if err == nil { + t.Error("parseCompatibility() expected error, got nil") + } + }) + } +} From 367175d97bc14638fe490e245aa549a06b459433 Mon Sep 17 00:00:00 2001 From: Maciek Kisiel Date: Mon, 26 Jan 2026 15:59:59 +0000 Subject: [PATCH 175/418] conformance: prepare the conformance test suite - move the conformance related files to a dedicated directory - simplify parts of the conformance script - create a scaffolding for a new GitHub action to run conformance tests - use the new --output-dir flag from the conformance suite for saving results on demand --- .github/workflows/conformance.yml | 38 +++++++++++++++++++ conformance/baseline.yml | 4 ++ .../everything-server}/main.go | 0 scripts/conformance.sh | 33 ++++++++-------- 4 files changed, 57 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/conformance.yml create mode 100644 conformance/baseline.yml rename {examples/server/conformance => conformance/everything-server}/main.go (100%) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml new file mode 100644 index 0000000..f9a84f9 --- /dev/null +++ b/.github/workflows/conformance.yml @@ -0,0 +1,38 @@ +name: Conformance Tests + +on: + # TODO: enable automatic triggering when tested. + # push: + # branches: [main] + # pull_request: + workflow_dispatch: + +concurrency: + group: conformance-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + server-conformance: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 + with: + go-version: "^1.25" + - name: Start everything-server + run: | + go run ./conformance/everything-server/main.go -http=":3001" & + # Wait for the server to be ready. + timeout 15 bash -c 'until curl -s http://localhost:3001/mcp; do sleep 0.5; done' + - name: "Run conformance tests" + uses: modelcontextprotocol/conformance@c2f3fdaf781dcd5a862cb0d2f6454c1c210bf0f0 # v0.1.11 + with: + mode: server + url: http://localhost:3001/mcp + suite: active + expected-failures: ./conformance/baseline.yml diff --git a/conformance/baseline.yml b/conformance/baseline.yml new file mode 100644 index 0000000..fd4e80a --- /dev/null +++ b/conformance/baseline.yml @@ -0,0 +1,4 @@ +server: + # All tests should pass. +client: + # Tests are expected to fail. List will be populated after running the tests. diff --git a/examples/server/conformance/main.go b/conformance/everything-server/main.go similarity index 100% rename from examples/server/conformance/main.go rename to conformance/everything-server/main.go diff --git a/scripts/conformance.sh b/scripts/conformance.sh index 9e7d300..0826086 100755 --- a/scripts/conformance.sh +++ b/scripts/conformance.sh @@ -12,6 +12,7 @@ SERVER_PID="" RESULT_DIR="" WORKDIR="" CONFORMANCE_REPO="" +FINAL_EXIT_CODE=0 usage() { echo "Usage: $0 [options]" @@ -50,11 +51,9 @@ done cleanup() { if [ -n "$SERVER_PID" ]; then + echo "Stopping server..." kill "$SERVER_PID" 2>/dev/null || true - fi - # Clean up the work directory unless --result_dir was specified. - if [ -z "$RESULT_DIR" ] && [ -n "$WORKDIR" ]; then - rm -rf "$WORKDIR" + wait "$SERVER_PID" 2>/dev/null || true fi } trap cleanup EXIT @@ -68,7 +67,7 @@ else fi # Build the conformance server. -go build -o "$WORKDIR/conformance-server" ./examples/server/conformance +go build -o "$WORKDIR/conformance-server" ./conformance/everything-server # Start the server in the background echo "Starting conformance server on port $PORT..." @@ -79,17 +78,10 @@ echo "Server pid is $SERVER_PID" # Wait for server to be ready echo "Waiting for server to be ready..." -for i in {1..30}; do - if curl -s "http://localhost:$PORT" > /dev/null 2>&1; then - echo "Server is ready." - break - fi - if [ "$i" -eq 30 ]; then - echo "Server failed to start within 15 seconds." - exit 1 - fi - sleep 0.5 -done +if ! timeout 15 bash -c "until curl -s http://localhost:$PORT > /dev/null 2>&1; do sleep 0.5; done"; then + echo "Server failed to start within 15 seconds." + exit 1 +fi # Run conformance tests from the work directory to avoid writing results to the repo. echo "Running conformance tests..." @@ -97,10 +89,13 @@ if [ -n "$CONFORMANCE_REPO" ]; then # Run from local checkout using npm run start. (cd "$WORKDIR" && \ npm --prefix "$CONFORMANCE_REPO" run start -- \ - server --url "http://localhost:$PORT") + server --url "http://localhost:$PORT" \ + ${RESULT_DIR:+--output-dir "$RESULT_DIR"}) || FINAL_EXIT_CODE=$? else (cd "$WORKDIR" && \ - npx @modelcontextprotocol/conformance@latest server --url "http://localhost:$PORT") + npx @modelcontextprotocol/conformance@latest \ + server --url "http://localhost:$PORT" \ + ${RESULT_DIR:+--output-dir "$RESULT_DIR"}) || FINAL_EXIT_CODE=$? fi echo "" @@ -109,3 +104,5 @@ if [ -n "$RESULT_DIR" ]; then else echo "Run with --result_dir to save results." fi + +exit $FINAL_EXIT_CODE From 634735bfa71e36a09cb88ea3abf7e034c32dfc72 Mon Sep 17 00:00:00 2001 From: Maciek Kisiel Date: Tue, 27 Jan 2026 15:45:50 +0000 Subject: [PATCH 176/418] conformance: create everything-client with basic scenarios and configure GitHub action to run it - implement all non-auth scenarios (client OAuth is not yet supported) - sse-retry is also implemented, but failing due to a bug in the SDK - configure client-conformance job in the conformance GitHub action - create a client-conformance.sh script and rename conformance.sh to server-conformance.sh --- .github/workflows/conformance.yml | 17 ++ .github/workflows/nightly.yml | 4 +- conformance/baseline.yml | 19 +- conformance/everything-client/main.go | 248 ++++++++++++++++++ scripts/client-conformance.sh | 85 ++++++ .../{conformance.sh => server-conformance.sh} | 0 6 files changed, 370 insertions(+), 3 deletions(-) create mode 100644 conformance/everything-client/main.go create mode 100755 scripts/client-conformance.sh rename scripts/{conformance.sh => server-conformance.sh} (100%) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index f9a84f9..66acdf8 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -36,3 +36,20 @@ jobs: url: http://localhost:3001/mcp suite: active expected-failures: ./conformance/baseline.yml + + client-conformance: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 + with: + go-version: "^1.25" + - name: "Run conformance tests" + uses: modelcontextprotocol/conformance@c2f3fdaf781dcd5a862cb0d2f6454c1c210bf0f0 # v0.1.11 + with: + mode: client + command: go run ./conformance/everything-client/main.go + suite: core + expected-failures: ./conformance/baseline.yml diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 0aa95df..e2b5a52 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -32,5 +32,5 @@ jobs: uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 with: node-version: "20" - - name: Run conformance tests - run: ./scripts/conformance.sh + - name: Run server conformance tests + run: ./scripts/server-conformance.sh diff --git a/conformance/baseline.yml b/conformance/baseline.yml index fd4e80a..c583549 100644 --- a/conformance/baseline.yml +++ b/conformance/baseline.yml @@ -1,4 +1,21 @@ server: # All tests should pass. client: - # Tests are expected to fail. List will be populated after running the tests. +- auth/basic-cimd +- auth/metadata-default +- auth/metadata-var1 +- auth/metadata-var2 +- auth/metadata-var3 +- auth/2025-03-26-oauth-metadata-backcompat +- auth/2025-03-26-oauth-endpoint-fallback +- auth/scope-from-www-authenticate +- auth/scope-from-scopes-supported +- auth/scope-omitted-when-undefined +- auth/scope-step-up +- auth/scope-retry-limit +- auth/token-endpoint-auth-basic +- auth/token-endpoint-auth-post +- auth/token-endpoint-auth-none +- auth/client-credentials-jwt +- auth/client-credentials-basic +- sse-retry diff --git a/conformance/everything-client/main.go b/conformance/everything-client/main.go new file mode 100644 index 0000000..9674dbb --- /dev/null +++ b/conformance/everything-client/main.go @@ -0,0 +1,248 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// The conformance client implements features required for MCP conformance testing. +// It mirrors the functionality of the TypeScript conformance client at +// https://github.com/modelcontextprotocol/typescript-sdk/blob/main/src/conformance/everything-client.ts +package main + +import ( + "context" + "fmt" + "log" + "os" + "slices" + "sort" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// scenarioHandler is the function signature for all conformance test scenarios. +// It takes a context and the server URL to connect to. +type scenarioHandler func(ctx context.Context, serverURL string) error + +var ( + // registry stores all registered scenario handlers. + registry = make(map[string]scenarioHandler) +) + +// registerScenario registers a new scenario handler with the given name. +// This function should be called during init() by scenario implementations. +func registerScenario(name string, handler scenarioHandler) { + if _, exists := registry[name]; exists { + log.Fatalf("Scenario %q is already registered", name) + } + registry[name] = handler +} + +func init() { + registerScenario("initialize", runBasicClient) + registerScenario("tools_call", runToolsCallClient) + registerScenario("elicitation-sep1034-client-defaults", runElicitationDefaultsClient) + registerScenario("sse-retry", runSSERetryClient) +} + +// ============================================================================ +// Basic scenarios +// ============================================================================ + +func runBasicClient(ctx context.Context, serverURL string) error { + session, err := connectToServer(ctx, serverURL) + if err != nil { + return err + } + defer session.Close() + + _, err = session.ListTools(ctx, nil) + if err != nil { + return fmt.Errorf("session.ListTools(): %v", err) + } + + return nil +} + +func runToolsCallClient(ctx context.Context, serverURL string) error { + session, err := connectToServer(ctx, serverURL) + if err != nil { + return err + } + defer session.Close() + + tools, err := session.ListTools(ctx, nil) + if err != nil { + return fmt.Errorf("session.ListTools(): %v", err) + } + + idx := slices.IndexFunc(tools.Tools, func(t *mcp.Tool) bool { + return t.Name == "add_numbers" + }) + if idx == -1 { + return fmt.Errorf("tool %q not found", "add_numbers") + } + + _, err = session.CallTool(ctx, &mcp.CallToolParams{ + Name: "add_numbers", + Arguments: map[string]any{"a": 5, "b": 3}, + }) + if err != nil { + return fmt.Errorf("session.CallTool('add_numbers'): %v", err) + } + + return nil +} + +// ============================================================================ +// Elicitation scenarios +// ============================================================================ + +func runElicitationDefaultsClient(ctx context.Context, serverURL string) error { + elicitationHandler := func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{ + Action: "accept", + Content: map[string]any{}, + }, nil + } + + session, err := connectToServer(ctx, serverURL, withClientOptions(&mcp.ClientOptions{ + ElicitationHandler: elicitationHandler, + })) + if err != nil { + return err + } + defer session.Close() + + tools, err := session.ListTools(ctx, nil) + if err != nil { + return fmt.Errorf("session.ListTools(): %v", err) + } + + var testToolName = "test_client_elicitation_defaults" + idx := slices.IndexFunc(tools.Tools, func(t *mcp.Tool) bool { + return t.Name == testToolName + }) + if idx == -1 { + return fmt.Errorf("tool %q not found", testToolName) + } + + _, err = session.CallTool(ctx, &mcp.CallToolParams{ + Name: testToolName, + Arguments: map[string]any{}, + }) + if err != nil { + return fmt.Errorf("session.CallTool(%q): %v", testToolName, err) + } + + return nil +} + +// ============================================================================ +// SSE retry scenario +// ============================================================================ + +func runSSERetryClient(ctx context.Context, serverURL string) error { + // TODO: this scenario is not passing yet. It requires a fix in the client SSE handling. + session, err := connectToServer(ctx, serverURL) + if err != nil { + return err + } + defer session.Close() + log.Printf("Connected to server %q", serverURL) + + tools, err := session.ListTools(ctx, nil) + if err != nil { + return fmt.Errorf("session.ListTools(): %v", err) + } + + var testToolName = "test_reconnection" + idx := slices.IndexFunc(tools.Tools, func(t *mcp.Tool) bool { + return t.Name == testToolName + }) + if idx == -1 { + return fmt.Errorf("tool %q not found", testToolName) + } + + _, err = session.CallTool(ctx, &mcp.CallToolParams{ + Name: testToolName, + Arguments: map[string]any{}, + }) + if err != nil { + return fmt.Errorf("session.CallTool(%q): %v", testToolName, err) + } + + return nil +} + +// ============================================================================ +// Main entry point +// ============================================================================ + +func main() { + if len(os.Args) != 2 { + printUsageAndExit("Usage: %s ", os.Args[0]) + } + + serverURL := os.Args[1] + scenarioName := os.Getenv("MCP_CONFORMANCE_SCENARIO") + + if scenarioName == "" { + printUsageAndExit("MCP_CONFORMANCE_SCENARIO not set") + } + + handler, ok := registry[scenarioName] + if !ok { + printUsageAndExit("Unknown scenario: %q", scenarioName) + } + + ctx := context.Background() + if err := handler(ctx, serverURL); err != nil { + log.Fatalf("Scenario %q failed: %v", scenarioName, err) + } +} + +func printUsageAndExit(format string, args ...any) { + var scenarios []string + for name := range registry { + scenarios = append(scenarios, name) + } + sort.Strings(scenarios) + + msg := fmt.Sprintf(format, args...) + log.Fatalf("%s\nAvailable scenarios:\n - %s", msg, strings.Join(scenarios, "\n - ")) +} + +type connectConfig struct { + clientOptions *mcp.ClientOptions +} + +type connectOption func(*connectConfig) + +func withClientOptions(opts *mcp.ClientOptions) connectOption { + return func(c *connectConfig) { + c.clientOptions = opts + } +} + +// connectToServer connects to the MCP server and returns a client session. +// The caller is responsible for closing the session. +func connectToServer(ctx context.Context, serverURL string, opts ...connectOption) (*mcp.ClientSession, error) { + config := &connectConfig{} + for _, opt := range opts { + opt(config) + } + + client := mcp.NewClient(&mcp.Implementation{ + Name: "test-client", + Version: "1.0.0", + }, config.clientOptions) + + transport := &mcp.StreamableClientTransport{Endpoint: serverURL} + + session, err := client.Connect(ctx, transport, nil) + if err != nil { + return nil, fmt.Errorf("client.Connect(): %v", err) + } + + return session, nil +} diff --git a/scripts/client-conformance.sh b/scripts/client-conformance.sh new file mode 100755 index 0000000..c093c75 --- /dev/null +++ b/scripts/client-conformance.sh @@ -0,0 +1,85 @@ +#!/bin/bash +# Copyright 2025 The Go MCP SDK Authors. All rights reserved. +# Use of this source code is governed by an MIT-style +# license that can be found in the LICENSE file. + +# Run MCP conformance tests against the Go SDK conformance client. + +set -e + +RESULT_DIR="" +WORKDIR="" +CONFORMANCE_REPO="" +FINAL_EXIT_CODE=0 + +usage() { + echo "Usage: $0 [options]" + echo "" + echo "Run MCP conformance tests against the Go SDK conformance client." + echo "" + echo "Options:" + echo " --result_dir Save results to the specified directory" + echo " --conformance_repo Run conformance tests from a local checkout" + echo " instead of using the latest npm release" + echo " --help Show this help message" +} + +# Parse arguments. +while [[ $# -gt 0 ]]; do + case $1 in + --result_dir) + RESULT_DIR="$2" + shift 2 + ;; + --conformance_repo) + CONFORMANCE_REPO="$2" + shift 2 + ;; + --help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" + usage + exit 1 + ;; + esac +done + +# Set up the work directory. +if [ -n "$RESULT_DIR" ]; then + mkdir -p "$RESULT_DIR" + WORKDIR="$RESULT_DIR" +else + WORKDIR=$(mktemp -d) +fi + +# Build the conformance server. +go build -o "$WORKDIR/conformance-client" ./conformance/everything-client + +# Run conformance tests from the work directory to avoid writing results to the repo. +echo "Running conformance tests..." +if [ -n "$CONFORMANCE_REPO" ]; then + # Run from local checkout using npm run start. + (cd "$WORKDIR" && \ + npm --prefix "$CONFORMANCE_REPO" run start -- \ + client --command "$WORKDIR/conformance-client" \ + --suite core \ + ${RESULT_DIR:+--output-dir "$RESULT_DIR"}) || FINAL_EXIT_CODE=$? +else + (cd "$WORKDIR" && \ + npx @modelcontextprotocol/conformance@latest \ + client --command "$WORKDIR/conformance-client" \ + --suite core \ + ${RESULT_DIR:+--output-dir "$RESULT_DIR"}) || FINAL_EXIT_CODE=$? +fi + +echo "" +if [ -n "$RESULT_DIR" ]; then + echo "See $RESULT_DIR for details." +else + echo "Run with --result_dir to save results." +fi + +exit $FINAL_EXIT_CODE diff --git a/scripts/conformance.sh b/scripts/server-conformance.sh similarity index 100% rename from scripts/conformance.sh rename to scripts/server-conformance.sh From cf88bda0c5a4276d977b0541628d235fd7e28c13 Mon Sep 17 00:00:00 2001 From: lif <1835304752@qq.com> Date: Tue, 3 Feb 2026 17:17:32 +0800 Subject: [PATCH 177/418] fix: enforce retry limit when SSE stream makes no progress (#742) Fixes #679 ## Changes - Track last event ID to detect progress in SSE streams - Only reset retry counter when Last-Event-ID advances - Fail connection after exceeding maxRetries without progress This implements Option 2 from the issue: progress-based retry limiting that prevents infinite retry loops while allowing long-running streams to continue as long as progress is made. --------- Signed-off-by: majiayu000 <1835304752@qq.com> Co-authored-by: Claude Opus 4.5 Co-authored-by: Maciej Kisiel --- mcp/streamable.go | 32 +++++++++--- mcp/streamable_client_test.go | 95 +++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 8 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index f2a2895..8152c48 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1823,15 +1823,14 @@ func (c *streamableClientConn) handleJSON(requestSummary string, resp *http.Resp // stream is complete when we receive its response. Otherwise, this is the // standalone stream. func (c *streamableClientConn) handleSSE(ctx context.Context, requestSummary string, resp *http.Response, forCall *jsonrpc2.Request) { + // Track the last event ID to detect progress. + // The retry counter is only reset when progress is made (lastEventID advances). + // This prevents infinite retry loops when a server repeatedly terminates + // connections without making progress (#679). + var prevLastEventID string + retriesWithoutProgress := 0 + for { - // Connection was successful. Continue the loop with the new response. - // - // TODO(#679): we should set a reasonable limit on the number of times - // we'll try getting a response for a given request, or enforce that we - // actually make progress. - // - // Eventually, if we don't get the response, we should stop trying and - // fail the request. lastEventID, reconnectDelay, clientClosed := c.processStream(ctx, requestSummary, resp, forCall) // If the connection was closed by the client, we're done. @@ -1845,6 +1844,23 @@ func (c *streamableClientConn) handleSSE(ctx context.Context, requestSummary str return } + // Check if we made progress (lastEventID advanced). + // Only reset the retry counter when actual progress is made. + if lastEventID != "" && lastEventID != prevLastEventID { + // Progress was made: reset the retry counter. + retriesWithoutProgress = 0 + prevLastEventID = lastEventID + } else { + // No progress: increment the retry counter. + retriesWithoutProgress++ + if retriesWithoutProgress > c.maxRetries { + if ctx.Err() == nil { + c.fail(fmt.Errorf("%s: exceeded %d retries without progress (session ID: %v)", requestSummary, c.maxRetries, c.sessionID)) + } + return + } + } + // The stream was interrupted or ended by the server. Attempt to reconnect. newResp, err := c.connectSSE(ctx, lastEventID, reconnectDelay, false) if err != nil { diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 2dfb719..e3adfe9 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -697,6 +697,101 @@ func TestStreamableClientTransientErrors(t *testing.T) { } } +// TestStreamableClientRetryWithoutProgress verifies that the client fails after +// exceeding the retry limit when no progress is made (Last-Event-ID does not advance). +// This tests the fix for issue #679. +func TestStreamableClientRetryWithoutProgress(t *testing.T) { + // Speed up reconnection delays for testing. + const tick = 10 * time.Millisecond + defer func(delay time.Duration) { + reconnectInitialDelay = delay + }(reconnectInitialDelay) + reconnectInitialDelay = tick + + // Use the fakeStreamableServer pattern like other tests to avoid race conditions. + ctx := context.Background() + const maxRetries = 2 + var retryCount atomic.Int32 + + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodInitialize, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "test-session", + }, + body: jsonBody(t, initResp), + }, + {"POST", "test-session", notificationInitialized, ""}: { + status: http.StatusAccepted, + wantProtocolVersion: latestProtocolVersion, + }, + {"GET", "test-session", "", ""}: { + // Disable standalone SSE stream to simplify the test. + status: http.StatusMethodNotAllowed, + }, + {"POST", "test-session", methodCallTool, ""}: { + header: header{ + "Content-Type": "text/event-stream", + }, + // Return SSE stream with fixed event ID. + body: `id: fixed_1 +data: {"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","data":"test"}} + +`, + }, + // Resumption attempts with the same event ID (no progress). + {"GET", "test-session", "", "fixed_1"}: { + header: header{ + "Content-Type": "text/event-stream", + }, + responseFunc: func(r *jsonrpc.Request) (string, int) { + retryCount.Add(1) + // Return the same event ID - no progress. + return `id: fixed_1 +data: {"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","data":"retry"}} + +`, http.StatusOK + }, + }, + {"DELETE", "test-session", "", ""}: {optional: true}, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{ + Endpoint: httpServer.URL, + MaxRetries: maxRetries, + } + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, nil) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + defer session.Close() + + // Make a call that will trigger reconnections without progress. + _, err = session.CallTool(ctx, &CallToolParams{Name: "test"}) + if err == nil { + t.Fatal("CallTool succeeded unexpectedly, want error due to exceeded retries") + } + + // Check that the error mentions exceeding retries without progress. + wantErr := "exceeded" + if !strings.Contains(err.Error(), wantErr) { + t.Errorf("CallTool error = %q, want containing %q", err.Error(), wantErr) + } + + // Verify that we actually retried the expected number of times. + // We expect maxRetries+1 attempts because we increment before checking the limit. + if got := retryCount.Load(); got != int32(maxRetries+1) { + t.Errorf("retry count = %d, want exactly %d", got, maxRetries+1) + } +} + func TestStreamableClientDisableStandaloneSSE(t *testing.T) { ctx := context.Background() From df4f0d56a49cad5b4770260869ab279a3be4c2d6 Mon Sep 17 00:00:00 2001 From: Suraj Bobade Date: Tue, 3 Feb 2026 18:53:59 +0530 Subject: [PATCH 178/418] mcp: use rand.Text and slog.DiscardHandler over internal implementation (#773) Address TODOs in code requiring go 1.24 as minimum version mcp/logging.go: use slog.DiscardHandler, over local implementation mcp/util.go: use rand.Text, over local implementation Fixes #772 --- examples/server/sequentialthinking/main.go | 16 +--------------- mcp/event_test.go | 3 ++- mcp/logging.go | 11 +---------- mcp/server.go | 3 ++- mcp/sse.go | 3 ++- mcp/streamable.go | 3 ++- mcp/util.go | 15 --------------- 7 files changed, 10 insertions(+), 44 deletions(-) diff --git a/examples/server/sequentialthinking/main.go b/examples/server/sequentialthinking/main.go index e0ae521..4775c33 100644 --- a/examples/server/sequentialthinking/main.go +++ b/examples/server/sequentialthinking/main.go @@ -234,7 +234,7 @@ func deepCopyThoughts(thoughts []*Thought) []*Thought { func StartThinking(ctx context.Context, _ *mcp.CallToolRequest, args StartThinkingArgs) (*mcp.CallToolResult, any, error) { sessionID := args.SessionID if sessionID == "" { - sessionID = randText() + sessionID = rand.Text() } estimatedSteps := args.EstimatedSteps @@ -480,20 +480,6 @@ func ThinkingHistory(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.Re }, nil } -// Copied from crypto/rand. -// TODO: once 1.24 is assured, just use crypto/rand. -const base32alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" - -func randText() string { - // ⌈log₃₂ 2¹²⁸⌉ = 26 chars - src := make([]byte, 26) - rand.Read(src) - for i := range src { - src[i] = base32alphabet[src[i]%32] - } - return string(src) -} - func main() { flag.Parse() diff --git a/mcp/event_test.go b/mcp/event_test.go index dacf30e..b774e10 100644 --- a/mcp/event_test.go +++ b/mcp/event_test.go @@ -6,6 +6,7 @@ package mcp import ( "context" + "crypto/rand" "fmt" "slices" "strings" @@ -281,7 +282,7 @@ func BenchmarkMemoryEventStore(b *testing.B) { for i := range sessionIDs { sessionIDs[i] = fmt.Sprint(i) for j := range 3 { - streamIDs[i][j] = randText() + streamIDs[i][j] = rand.Text() } } payload := make([]byte, test.datasize) diff --git a/mcp/logging.go b/mcp/logging.go index 96a96b8..b1bd82b 100644 --- a/mcp/logging.go +++ b/mcp/logging.go @@ -89,21 +89,12 @@ type LoggingHandler struct { handler slog.Handler } -// discardHandler is a slog.Handler that drops all logs. -// TODO: use slog.DiscardHandler when we require Go 1.24+. -type discardHandler struct{} - -func (discardHandler) Enabled(context.Context, slog.Level) bool { return false } -func (discardHandler) Handle(context.Context, slog.Record) error { return nil } -func (discardHandler) WithAttrs([]slog.Attr) slog.Handler { return discardHandler{} } -func (discardHandler) WithGroup(string) slog.Handler { return discardHandler{} } - // ensureLogger returns l if non-nil, otherwise a discard logger. func ensureLogger(l *slog.Logger) *slog.Logger { if l != nil { return l } - return slog.New(discardHandler{}) + return slog.New(slog.DiscardHandler) } // NewLoggingHandler creates a [LoggingHandler] that logs to the given [ServerSession] using a diff --git a/mcp/server.go b/mcp/server.go index d68a8c2..930410e 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -7,6 +7,7 @@ package mcp import ( "bytes" "context" + "crypto/rand" "encoding/base64" "encoding/gob" "encoding/json" @@ -175,7 +176,7 @@ func NewServer(impl *Implementation, options *ServerOptions) *Server { } if opts.GetSessionID == nil { - opts.GetSessionID = randText + opts.GetSessionID = rand.Text } if opts.Logger == nil { // ensure we have a logger diff --git a/mcp/sse.go b/mcp/sse.go index ae65c16..e57dad1 100644 --- a/mcp/sse.go +++ b/mcp/sse.go @@ -7,6 +7,7 @@ package mcp import ( "bytes" "context" + "crypto/rand" "fmt" "io" "net/http" @@ -216,7 +217,7 @@ func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") - sessionID = randText() + sessionID = rand.Text() endpoint, err := req.URL.Parse("?sessionid=" + sessionID) if err != nil { http.Error(w, "internal error: failed to create endpoint", http.StatusInternalServerError) diff --git a/mcp/streamable.go b/mcp/streamable.go index 8152c48..2fafc1e 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -11,6 +11,7 @@ package mcp import ( "bytes" "context" + crand "crypto/rand" "encoding/json" "errors" "fmt" @@ -1140,7 +1141,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // Important: don't publish the incoming messages until the stream is // registered, as the server may attempt to respond to imcoming messages as // soon as they're published. - stream, err := c.newStream(req.Context(), calls, randText()) + stream, err := c.newStream(req.Context(), calls, crand.Text()) if err != nil { http.Error(w, fmt.Sprintf("storing stream: %v", err), http.StatusInternalServerError) return diff --git a/mcp/util.go b/mcp/util.go index 5ada466..f4a1f31 100644 --- a/mcp/util.go +++ b/mcp/util.go @@ -5,7 +5,6 @@ package mcp import ( - "crypto/rand" "encoding/json" ) @@ -15,20 +14,6 @@ func assert(cond bool, msg string) { } } -// Copied from crypto/rand. -// TODO: once 1.24 is assured, just use crypto/rand. -const base32alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" - -func randText() string { - // ⌈log₃₂ 2¹²⁸⌉ = 26 chars - src := make([]byte, 26) - rand.Read(src) - for i := range src { - src[i] = base32alphabet[src[i]%32] - } - return string(src) -} - // remarshal marshals from to JSON, and then unmarshals into to, which must be // a pointer type. func remarshal(from, to any) error { From 7ecd8049c67d49e4f7a54391c6979464885cb495 Mon Sep 17 00:00:00 2001 From: FanOne <294350394@qq.com> Date: Tue, 3 Feb 2026 22:46:49 +0800 Subject: [PATCH 179/418] mcp: export session missing error (#771) Fix: #715 - export session missing error - modify `errSessionMissing` to `ErrSessionMissing` - add unit test about session missing error --- mcp/streamable.go | 19 ++++------------- mcp/streamable_client.go | 6 +++--- mcp/streamable_test.go | 44 ++++++++++++++++++++++++++++++++++++++++ mcp/transport.go | 4 ++++ 4 files changed, 55 insertions(+), 18 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 2fafc1e..37e9ff2 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1540,17 +1540,6 @@ type streamableClientConn struct { sessionID string } -// errSessionMissing distinguishes if the session is known to not be present on -// the server (see [streamableClientConn.fail]). -// -// TODO(rfindley): should we expose this error value (and its corresponding -// API) to the user? -// -// The spec says that if the server returns 404, clients should reestablish -// a session. For now, we delegate that to the user, but do they need a way to -// differentiate a 'NotFound' error from other errors? -var errSessionMissing = errors.New("session not found") - var _ clientConnection = (*streamableClientConn)(nil) func (c *streamableClientConn) sessionUpdated(state clientSessionState) { @@ -1629,7 +1618,7 @@ func (c *streamableClientConn) connectStandaloneSSE() { // If err is non-nil, it is terminal, and subsequent (or pending) Reads will // fail. // -// If err wraps errSessionMissing, the failure indicates that the session is no +// If err wraps ErrSessionMissing, the failure indicates that the session is no // longer present on the server, and no final DELETE will be performed when // closing the connection. func (c *streamableClientConn) fail(err error) { @@ -1896,9 +1885,9 @@ func (c *streamableClientConn) checkResponse(requestSummary string, resp *http.R // which it MUST respond to requests containing that session ID with HTTP // 404 Not Found." if resp.StatusCode == http.StatusNotFound { - // Return an errSessionMissing to avoid sending a redundant DELETE when the + // Return an ErrSessionMissing to avoid sending a redundant DELETE when the // session is already gone. - return fmt.Errorf("%s: failed to connect (session ID: %v): %w", requestSummary, c.sessionID, errSessionMissing) + return fmt.Errorf("%s: failed to connect (session ID: %v): %w", requestSummary, c.sessionID, ErrSessionMissing) } // Transient server errors (502, 503, 504, 429) should not break the connection. // Wrap them with ErrRejected so the jsonrpc2 layer doesn't set writeErr. @@ -2055,7 +2044,7 @@ func (c *streamableClientConn) connectSSE(ctx context.Context, lastEventID strin // Close implements the [Connection] interface. func (c *streamableClientConn) Close() error { c.closeOnce.Do(func() { - if errors.Is(c.failure(), errSessionMissing) { + if errors.Is(c.failure(), ErrSessionMissing) { // If the session is missing, no need to delete it. } else { req, err := http.NewRequestWithContext(c.ctx, http.MethodDelete, c.url, nil) diff --git a/mcp/streamable_client.go b/mcp/streamable_client.go index 41a1004..c2cc25b 100644 --- a/mcp/streamable_client.go +++ b/mcp/streamable_client.go @@ -161,7 +161,7 @@ The client must handle two response formats from POST requests: - DELETE: Terminate the session - Used by [streamableClientConn.Close] - - Skipped if session is already known to be gone ([errSessionMissing]) + - Skipped if session is already known to be gone ([ErrSessionMissing]) # Error Handling @@ -173,7 +173,7 @@ Errors are categorized and handled differently: - Triggers reconnection in [streamableClientConn.handleSSE] 2. Terminal (breaks the connection): - - 404 Not Found: Session terminated by server ([errSessionMissing]) + - 404 Not Found: Session terminated by server ([ErrSessionMissing]) - Message decode errors: Protocol violation - Context cancellation: Client closed connection - Mismatched session IDs: Protocol error @@ -183,7 +183,7 @@ Terminal errors are stored via [streamableClientConn.fail] and returned by subsequent [streamableClientConn.Read] calls. The [streamableClientConn.failed] channel signals that the connection is broken. -Special case: [errSessionMissing] indicates the server has terminated the session, +Special case: [ErrSessionMissing] indicates the server has terminated the session, so [streamableClientConn.Close] skips the DELETE request. # Protocol Version Header diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 56c0a49..05af216 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -2280,3 +2280,47 @@ data: {"jsonrpc":"2.0","method":"test2","params":{}} } } } + +func Test_ExportErrSessionMissing(t *testing.T) { + ctx := context.Background() + + // 1. Setup server + impl := &Implementation{Name: "test", Version: "1.0.0"} + server := NewServer(impl, nil) + handler := NewStreamableHTTPHandler(func(r *http.Request) *Server { return server }, nil) + ts := httptest.NewServer(handler) + defer ts.Close() + + // 2. Setup client + clientTransport := &StreamableClientTransport{ + Endpoint: ts.URL, + } + client := NewClient(impl, nil) + session, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + defer session.Close() + + // 3. Manually invalidate session on server + handler.mu.Lock() + if len(handler.sessions) != 1 { + handler.mu.Unlock() + t.Fatalf("expected 1 session, got %d", len(handler.sessions)) + } + for id := range handler.sessions { + delete(handler.sessions, id) + } + handler.mu.Unlock() + + // 4. Try to call a tool (or any request) + _, err = session.ListTools(ctx, nil) + if err == nil { + t.Fatal("expected error, got nil") + } + + // 5. Verify it's ErrSessionMissing + if !errors.Is(err, ErrSessionMissing) { + t.Errorf("expected error to wrap ErrSessionMissing, got: %v", err) + } +} diff --git a/mcp/transport.go b/mcp/transport.go index 25f1d5d..fa18653 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -24,6 +24,10 @@ import ( // is closed or in the process of closing. var ErrConnectionClosed = errors.New("connection closed") +// ErrSessionMissing is returned when the session is known to not be present on +// the server. +var ErrSessionMissing = errors.New("session not found") + // A Transport is used to create a bidirectional connection between MCP client // and server. // From c6e7337e7e220ea1927bb04a443992d9dda220eb Mon Sep 17 00:00:00 2001 From: awschmeder Date: Tue, 3 Feb 2026 01:04:07 -0800 Subject: [PATCH 180/418] fix: add JSON tags to ElicitationCapabilities fields The Form and URL fields in ElicitationCapabilities were missing JSON struct tags, causing them to serialize as "Form" and "URL" (capitalized) instead of the lowercase "form" and "url" required by the MCP protocol. This caused initialization failures when clients advertised elicitation capabilities, as servers would not recognize the incorrectly-cased keys. Discovered via downstream issue: https://github.com/docker/cagent/issues/1574 --- mcp/protocol.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mcp/protocol.go b/mcp/protocol.go index bea776f..2ac84de 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -988,8 +988,8 @@ type SamplingCapabilities struct{} // // If neither Form nor URL is set, the 'Form' capabilitiy is assumed. type ElicitationCapabilities struct { - Form *FormElicitationCapabilities - URL *URLElicitationCapabilities + Form *FormElicitationCapabilities `json:"form,omitempty"` + URL *URLElicitationCapabilities `json:"url,omitempty"` } // FormElicitationCapabilities describes capabilities for form elicitation. From 487ef65ef6bd5bcd8e2ed7eb0526179f3c9d04be Mon Sep 17 00:00:00 2001 From: Maciek Kisiel Date: Tue, 3 Feb 2026 09:49:24 +0000 Subject: [PATCH 181/418] conformance: mark the new `dns-rebinding-protection` scenario as failing --- conformance/baseline.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conformance/baseline.yml b/conformance/baseline.yml index c583549..27d07d6 100644 --- a/conformance/baseline.yml +++ b/conformance/baseline.yml @@ -1,5 +1,5 @@ server: - # All tests should pass. +- dns-rebinding-protection client: - auth/basic-cimd - auth/metadata-default From 8a39cf6ce01093c713d8ab53f3b242c36175eea0 Mon Sep 17 00:00:00 2001 From: Maciek Kisiel Date: Tue, 3 Feb 2026 16:12:51 +0000 Subject: [PATCH 182/418] conformance: add the remaining client auth test to baseline.yml and trigger conformance tests automatically --- .github/workflows/conformance.yml | 7 +++---- conformance/baseline.yml | 1 + 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 66acdf8..332975c 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -1,10 +1,9 @@ name: Conformance Tests on: - # TODO: enable automatic triggering when tested. - # push: - # branches: [main] - # pull_request: + push: + branches: [main] + pull_request: workflow_dispatch: concurrency: diff --git a/conformance/baseline.yml b/conformance/baseline.yml index 27d07d6..d731cd1 100644 --- a/conformance/baseline.yml +++ b/conformance/baseline.yml @@ -18,4 +18,5 @@ client: - auth/token-endpoint-auth-none - auth/client-credentials-jwt - auth/client-credentials-basic +- auth/pre-registration - sse-retry From 1fb6e204d700d935e94e8204607a23f01e71b0ab Mon Sep 17 00:00:00 2001 From: Alexandru Bumbacea Date: Thu, 5 Feb 2026 13:59:37 +0200 Subject: [PATCH 183/418] mcp: improve http transports error handling and make transport work with any size message (#734) We have a use case for messages larger than 1MB, hence making this configurable would help. In order to discover the source of our problems we had to spend some time investigating, as errors were silently discarded. So we also included changes to improving surfacing errors . --------- Co-authored-by: Maciej Kisiel --- mcp/event.go | 65 +++++++++++++++++++++++------------------- mcp/event_test.go | 72 +++++++++++++++++++++++++++++++++++++++++++++++ mcp/streamable.go | 8 ++++++ 3 files changed, 116 insertions(+), 29 deletions(-) diff --git a/mcp/event.go b/mcp/event.go index 5c322c4..62dd2ad 100644 --- a/mcp/event.go +++ b/mcp/event.go @@ -67,9 +67,7 @@ func writeEvent(w io.Writer, evt Event) (int, error) { // TODO(rfindley): consider a different API here that makes failure modes more // apparent. func scanEvents(r io.Reader) iter.Seq2[Event, error] { - scanner := bufio.NewScanner(r) - const maxTokenSize = 1 * 1024 * 1024 // 1 MiB max line size - scanner.Buffer(nil, maxTokenSize) + reader := bufio.NewReader(r) // TODO: investigate proper behavior when events are out of order, or have // non-standard names. @@ -94,31 +92,43 @@ func scanEvents(r io.Reader) iter.Seq2[Event, error] { evt Event dataBuf *bytes.Buffer // if non-nil, preceding field was also data ) - flushData := func() { + yieldEvent := func() bool { if dataBuf != nil { evt.Data = dataBuf.Bytes() dataBuf = nil } + if evt.Empty() { + return true + } + if !yield(evt, nil) { + return false + } + evt = Event{} + return true } - for scanner.Scan() { - line := scanner.Bytes() + for { + line, err := reader.ReadBytes('\n') + if err != nil && !errors.Is(err, io.EOF) { + yield(Event{}, fmt.Errorf("error reading event: %v", err)) + return + } + line = bytes.TrimRight(line, "\r\n") + isEOF := errors.Is(err, io.EOF) + if len(line) == 0 { - flushData() - // \n\n is the record delimiter - if !evt.Empty() && !yield(evt, nil) { + if !yieldEvent() { + return + } + if isEOF { return } - evt = Event{} continue } before, after, found := bytes.Cut(line, []byte{':'}) if !found { - yield(Event{}, fmt.Errorf("malformed line in SSE stream: %q", string(line))) + yield(Event{}, fmt.Errorf("%w: malformed line in SSE stream: %q", errMalformedEvent, string(line))) return } - if !bytes.Equal(before, dataKey) { - flushData() - } switch { case bytes.Equal(before, eventKey): evt.Name = strings.TrimSpace(string(after)) @@ -128,27 +138,19 @@ func scanEvents(r io.Reader) iter.Seq2[Event, error] { evt.Retry = strings.TrimSpace(string(after)) case bytes.Equal(before, dataKey): data := bytes.TrimSpace(after) - if dataBuf != nil { - dataBuf.WriteByte('\n') - dataBuf.Write(data) - } else { + if dataBuf == nil { dataBuf = new(bytes.Buffer) - dataBuf.Write(data) + } else { + dataBuf.WriteByte('\n') } + dataBuf.Write(data) } - } - if err := scanner.Err(); err != nil { - if errors.Is(err, bufio.ErrTooLong) { - err = fmt.Errorf("event exceeded max line length of %d", maxTokenSize) - } - if !yield(Event{}, err) { + + if isEOF { + yieldEvent() return } } - flushData() - if !evt.Empty() { - yield(evt, nil) - } } } @@ -310,6 +312,11 @@ func (s *MemoryEventStore) Append(_ context.Context, sessionID, streamID string, // index is no longer available. var ErrEventsPurged = errors.New("data purged") +// errMalformedEvent is returned when an SSE event cannot be parsed due to format violations. +// This is a hard error indicating corrupted data or protocol violations, as opposed to +// transient I/O errors which may be retryable. +var errMalformedEvent = errors.New("malformed event") + // After implements [EventStore.After]. func (s *MemoryEventStore) After(_ context.Context, sessionID, streamID string, index int) iter.Seq2[[]byte, error] { // Return the data items to yield. diff --git a/mcp/event_test.go b/mcp/event_test.go index b774e10..685e648 100644 --- a/mcp/event_test.go +++ b/mcp/event_test.go @@ -55,6 +55,78 @@ func TestScanEvents(t *testing.T) { input: "invalid line\n\n", wantErr: "malformed line", }, + { + name: "message with 2 data lines and another event", + input: "event: message\ndata: hello\ndata: hello\ndata: hello\n\nevent:keepalive", + want: []Event{ + {Name: "message", Data: []byte("hello\nhello\nhello")}, + {Name: "keepalive"}, + }, + }, + { + name: "event with multiple lines", + input: "event: message\ndata: hello\ndata: hello\ndata: hello\nid:1", + want: []Event{ + {Name: "message", ID: "1", Data: []byte("hello\nhello\nhello")}, + }, + }, + { + name: "multiple events, out of order keys", + input: strings.Join([]string{ + "event:message", + "data: hello0", + "\n", + "data: hello1", + "data: hello1", + "id:1", + "event:message", + "\n", + "event:message", + "data: hello3", + "data: hello3", + "id:3", + "\n", + "data: hello4", + "data: hello4", + "id:4", + "event:message", + }, "\n"), + want: []Event{ + {Name: "message", Data: []byte("hello0")}, + {Name: "message", ID: "1", Data: []byte("hello1\nhello1")}, + {Name: "message", ID: "3", Data: []byte("hello3\nhello3")}, + {Name: "message", ID: "4", Data: []byte("hello4\nhello4")}, + }, + }, + { + name: "non-continuous data items in the event", + input: "event: foo\ndata: 123\nretry: 5\ndata: 456", + want: []Event{ + {Name: "foo", Data: []byte("123\n456"), Retry: "5"}, + }, + }, + { + name: "no-data events", + input: "event: foo\n\nevent: bar", + want: []Event{ + {Name: "foo"}, + {Name: "bar"}, + }, + }, + { + name: "empty data event", + input: "event: foo\ndata:\n\nevent: bar", + want: []Event{ + {Name: "foo"}, + {Name: "bar"}, + }, + }, + { + + name: "malformed data event", + input: "someline", + wantErr: "malformed event", + }, } for _, tt := range tests { diff --git a/mcp/streamable.go b/mcp/streamable.go index 37e9ff2..893a706 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1915,6 +1915,14 @@ func (c *streamableClientConn) processStream(ctx context.Context, requestSummary if ctx.Err() != nil { return "", 0, true // don't reconnect: client cancelled } + + // Malformed events are hard errors that indicate corrupted data or protocol + // violations. These should fail the connection permanently. + if errors.Is(err, errMalformedEvent) { + c.fail(fmt.Errorf("%s: %v", requestSummary, err)) + return "", 0, true + } + break } From d0922abcd3400dbe465699f06099f68cab2f2b29 Mon Sep 17 00:00:00 2001 From: Maciek Kisiel Date: Thu, 5 Feb 2026 13:17:44 +0000 Subject: [PATCH 184/418] mcp: allow SSE messages with empty data (SEP-1699) --- conformance/baseline.yml | 1 - mcp/streamable.go | 9 ++++++++ mcp/streamable_test.go | 50 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/conformance/baseline.yml b/conformance/baseline.yml index d731cd1..f0e1513 100644 --- a/conformance/baseline.yml +++ b/conformance/baseline.yml @@ -19,4 +19,3 @@ client: - auth/client-credentials-jwt - auth/client-credentials-basic - auth/pre-registration -- sse-retry diff --git a/mcp/streamable.go b/mcp/streamable.go index 893a706..c6b96bb 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1935,6 +1935,15 @@ func (c *streamableClientConn) processStream(ctx context.Context, requestSummary reconnectDelay = time.Duration(n) * time.Millisecond } } + + // According to SSE specification + // (https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation) + // events with an empty data buffer are allowed. + // In MCP these can be priming events (SEP-1699) that carry only a Last-Event-ID for stream resumption. + if len(evt.Data) == 0 { + continue + } + // According to SSE spec, events with no name default to "message" if evt.Name != "" && evt.Name != "message" { continue diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 05af216..f1f6200 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -2218,6 +2218,56 @@ collectLoop: } } +// TestProcessStreamPrimingEvent verifies that the streamable client correctly ignores +// SSE events with empty data buffers, which are used as priming events (e.g. SEP-1699). +func TestProcessStreamPrimingEvent(t *testing.T) { + // We create a mock response with a priming event (empty data, with an ID), + // followed by a normal event. + sseData := `id: 123 + +id: 124 +data: {"jsonrpc":"2.0","id":1,"result":{}} + +` + + ctx := t.Context() + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(sseData)), + } + + incoming := make(chan jsonrpc.Message, 10) + done := make(chan struct{}) + + conn := &streamableClientConn{ + ctx: ctx, + done: done, + incoming: incoming, + failed: make(chan struct{}), + logger: ensureLogger(nil), + } + + lastID, _, clientClosed := conn.processStream(ctx, "test", resp, nil) + + if clientClosed { + t.Fatalf("processStream was unexpectedly closed by client") + } + + if lastID != "124" { + t.Errorf("lastEventID = %q, want %q", lastID, "124") + } + + select { + case msg := <-incoming: + if res, ok := msg.(*jsonrpc.Response); !(ok && res.ID == jsonrpc2.Int64ID(1)) { + t.Errorf("got unexpected message: %v", msg) + } + default: + t.Errorf("expected a JSON-RPC message to be produced") + } +} + // TestScanEventsPingFiltering is a unit test for the low-level event scanning // with ping events to verify scanEvents properly parses all event types. func TestScanEventsPingFiltering(t *testing.T) { From 6a19c95fea005442ebacbc3a93fb93cbf4612bbd Mon Sep 17 00:00:00 2001 From: Maciek Kisiel Date: Thu, 5 Feb 2026 13:59:52 +0000 Subject: [PATCH 185/418] mcp: finalize cleanup of Go 1.23, make Go version support explicit --- .github/workflows/test.yml | 2 +- README.md | 3 +++ internal/readme/README.src.md | 3 +++ mcp/resource.go | 17 +++++++++++++++++ mcp/resource_go124.go | 29 ----------------------------- mcp/resource_pre_go124.go | 25 ------------------------- 6 files changed, 24 insertions(+), 55 deletions(-) delete mode 100644 mcp/resource_go124.go delete mode 100644 mcp/resource_pre_go124.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 68a3c42..d3f32e0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,7 +40,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - go: ["1.23", "1.24", "1.25"] + go: ["1.24", "1.25"] steps: - name: Check out code uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 diff --git a/README.md b/README.md index 5ab6569..4745282 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,9 @@ The following table shows which versions of the Go SDK support which versions of | v1.2.0+ | 2025-06-18 | 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05 | | v1.0.0 - v1.1.0 | 2025-06-18 | 2025-06-18, 2025-03-26, 2024-11-05 | +New releases of the SDK target only supported versions of Go. See +https://go.dev/doc/devel/release#policy for more information. + ## Getting started To get started creating an MCP server, create an `mcp.Server` instance, add diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md index 6a83231..57d9da3 100644 --- a/internal/readme/README.src.md +++ b/internal/readme/README.src.md @@ -37,6 +37,9 @@ The following table shows which versions of the Go SDK support which versions of | v1.2.0+ | 2025-06-18 | 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05 | | v1.0.0 - v1.1.0 | 2025-06-18 | 2025-06-18, 2025-03-26, 2024-11-05 | +New releases of the SDK target only supported versions of Go. See +https://go.dev/doc/devel/release#policy for more information. + ## Getting started To get started creating an MCP server, create an `mcp.Server` instance, add diff --git a/mcp/resource.go b/mcp/resource.go index dc657f5..bc4b3cb 100644 --- a/mcp/resource.go +++ b/mcp/resource.go @@ -113,6 +113,23 @@ func computeURIFilepath(rawURI, dirFilepath string, rootFilepaths []string) (str return uriFilepathRel, nil } +// withFile calls f on the file at join(dir, rel), +// protecting against path traversal attacks. +func withFile(dir, rel string, f func(*os.File) error) (err error) { + r, err := os.OpenRoot(dir) + if err != nil { + return err + } + defer r.Close() + file, err := r.Open(rel) + if err != nil { + return err + } + // Record error, in case f writes. + defer func() { err = errors.Join(err, file.Close()) }() + return f(file) +} + // fileRoots transforms the Roots obtained from the client into absolute paths on // the local filesystem. // TODO(jba): expose this functionality to user ResourceHandlers, diff --git a/mcp/resource_go124.go b/mcp/resource_go124.go deleted file mode 100644 index 4a35603..0000000 --- a/mcp/resource_go124.go +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright 2025 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -//go:build go1.24 - -package mcp - -import ( - "errors" - "os" -) - -// withFile calls f on the file at join(dir, rel), -// protecting against path traversal attacks. -func withFile(dir, rel string, f func(*os.File) error) (err error) { - r, err := os.OpenRoot(dir) - if err != nil { - return err - } - defer r.Close() - file, err := r.Open(rel) - if err != nil { - return err - } - // Record error, in case f writes. - defer func() { err = errors.Join(err, file.Close()) }() - return f(file) -} diff --git a/mcp/resource_pre_go124.go b/mcp/resource_pre_go124.go deleted file mode 100644 index d1f72ee..0000000 --- a/mcp/resource_pre_go124.go +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright 2025 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -//go:build !go1.24 - -package mcp - -import ( - "errors" - "os" - "path/filepath" -) - -// withFile calls f on the file at join(dir, rel). -// It does not protect against path traversal attacks. -func withFile(dir, rel string, f func(*os.File) error) (err error) { - file, err := os.Open(filepath.Join(dir, rel)) - if err != nil { - return err - } - // Record error, in case f writes. - defer func() { err = errors.Join(err, file.Close()) }() - return f(file) -} From 41b54dced52d24ec41e321a03c15a61950436172 Mon Sep 17 00:00:00 2001 From: Suraj Bobade Date: Mon, 9 Feb 2026 15:11:27 +0530 Subject: [PATCH 186/418] mcp: use omitzero json tag for byte array field in ResourceContents, instead of omitempty /mcp/content.go: use omitzero json tag for byte array, instead of omitempty earlier. This change removes requirement of custom MarshalJSON Fixes #781 --- mcp/content.go | 32 +------------------------------- 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/mcp/content.go b/mcp/content.go index fb1a0d1..cc30d2a 100644 --- a/mcp/content.go +++ b/mcp/content.go @@ -9,7 +9,6 @@ package mcp import ( "encoding/json" - "errors" "fmt" ) @@ -189,39 +188,10 @@ type ResourceContents struct { URI string `json:"uri"` MIMEType string `json:"mimeType,omitempty"` Text string `json:"text,omitempty"` - Blob []byte `json:"blob,omitempty"` + Blob []byte `json:"blob,omitzero"` Meta Meta `json:"_meta,omitempty"` } -func (r *ResourceContents) MarshalJSON() ([]byte, error) { - // If we could assume Go 1.24, we could use omitzero for Blob and avoid this method. - if r.URI == "" { - return nil, errors.New("ResourceContents missing URI") - } - if r.Blob == nil { - // Text. Marshal normally. - type wireResourceContents ResourceContents // (lacks MarshalJSON method) - return json.Marshal((wireResourceContents)(*r)) - } - // Blob. - if r.Text != "" { - return nil, errors.New("ResourceContents has non-zero Text and Blob fields") - } - // r.Blob may be the empty slice, so marshal with an alternative definition. - br := struct { - URI string `json:"uri,omitempty"` - MIMEType string `json:"mimeType,omitempty"` - Blob []byte `json:"blob"` - Meta Meta `json:"_meta,omitempty"` - }{ - URI: r.URI, - MIMEType: r.MIMEType, - Blob: r.Blob, - Meta: r.Meta, - } - return json.Marshal(br) -} - // wireContent is the wire format for content. // It represents the protocol types TextContent, ImageContent, AudioContent, // ResourceLink, and EmbeddedResource. From 86f84151225970a4cd71549dd07c44cc64454818 Mon Sep 17 00:00:00 2001 From: original-owl <92678081+La002@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:10:11 +0530 Subject: [PATCH 187/418] mcp: use synctest for timing-dependent tests (#756) Converted timing-based tests to use Go 1.25's synctest for instant, deterministic execution with simulated time. Moved converted tests to *_go125_test.go files. Tests using real I/O (exec.Command, httptest) remain timing-based as they require actual system operations. Fixes #680 --------- Co-authored-by: i586147 --- examples/server/auth-middleware/go.mod | 2 +- examples/server/auth-middleware/go.sum | 1 + examples/server/rate-limiting/go.mod | 2 +- examples/server/rate-limiting/go.sum | 1 + mcp/cmd_go125_test.go | 56 ++ mcp/cmd_test.go | 3 +- mcp/elicitation_go125_test.go | 69 +++ mcp/elicitation_test.go | 1 + mcp/mcp_go125_test.go | 756 +++++++++++++++++++++++++ mcp/mcp_test.go | 7 + 10 files changed, 895 insertions(+), 3 deletions(-) create mode 100644 mcp/cmd_go125_test.go create mode 100644 mcp/elicitation_go125_test.go create mode 100644 mcp/mcp_go125_test.go diff --git a/examples/server/auth-middleware/go.mod b/examples/server/auth-middleware/go.mod index 9975879..8690256 100644 --- a/examples/server/auth-middleware/go.mod +++ b/examples/server/auth-middleware/go.mod @@ -8,7 +8,7 @@ require ( ) require ( - github.com/google/jsonschema-go v0.3.0 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/oauth2 v0.30.0 // indirect ) diff --git a/examples/server/auth-middleware/go.sum b/examples/server/auth-middleware/go.sum index 32ceedf..d257e10 100644 --- a/examples/server/auth-middleware/go.sum +++ b/examples/server/auth-middleware/go.sum @@ -4,6 +4,7 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= diff --git a/examples/server/rate-limiting/go.mod b/examples/server/rate-limiting/go.mod index adf535b..61c8788 100644 --- a/examples/server/rate-limiting/go.mod +++ b/examples/server/rate-limiting/go.mod @@ -8,7 +8,7 @@ require ( ) require ( - github.com/google/jsonschema-go v0.3.0 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect ) diff --git a/examples/server/rate-limiting/go.sum b/examples/server/rate-limiting/go.sum index 92c2739..cb67bcb 100644 --- a/examples/server/rate-limiting/go.sum +++ b/examples/server/rate-limiting/go.sum @@ -2,6 +2,7 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= diff --git a/mcp/cmd_go125_test.go b/mcp/cmd_go125_test.go new file mode 100644 index 0000000..ea36e2b --- /dev/null +++ b/mcp/cmd_go125_test.go @@ -0,0 +1,56 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +//go:build go1.25 + +package mcp_test + +import ( + "context" + "errors" + "testing" + "testing/synctest" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestServerRunContextCancel_Synctest(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + server := mcp.NewServer(&mcp.Implementation{Name: "greeter", Version: "v0.0.1"}, nil) + mcp.AddTool(server, &mcp.Tool{Name: "greet", Description: "say hi"}, SayHi) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + serverTransport, clientTransport := mcp.NewInMemoryTransports() + + // run the server and capture the exit error + onServerExit := make(chan error) + go func() { + onServerExit <- server.Run(ctx, serverTransport) + }() + + // send a ping to the server to ensure it's running + client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + session, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { session.Close() }) + + if err := session.Ping(context.Background(), nil); err != nil { + t.Fatal(err) + } + + // cancel the context to stop the server + cancel() + + // wait for the server to exit + + err = <-onServerExit + if !errors.Is(err, context.Canceled) { + t.Fatalf("server did not exit after context cancellation, got error: %v", err) + } + }) +} diff --git a/mcp/cmd_test.go b/mcp/cmd_test.go index ebf6b59..094814f 100644 --- a/mcp/cmd_test.go +++ b/mcp/cmd_test.go @@ -76,6 +76,7 @@ func runCancelContextServer() { } } +// TODO: remove this test when Go 1.24 support is dropped (use go1.25 synctest version). func TestServerRunContextCancel(t *testing.T) { server := mcp.NewServer(&mcp.Implementation{Name: "greeter", Version: "v0.0.1"}, nil) mcp.AddTool(server, &mcp.Tool{Name: "greet", Description: "say hi"}, SayHi) @@ -107,7 +108,7 @@ func TestServerRunContextCancel(t *testing.T) { cancel() // wait for the server to exit - // TODO: use synctest when availble + // TODO: use synctest when available select { case <-time.After(5 * time.Second): t.Fatal("server did not exit after context cancellation") diff --git a/mcp/elicitation_go125_test.go b/mcp/elicitation_go125_test.go new file mode 100644 index 0000000..e1a3007 --- /dev/null +++ b/mcp/elicitation_go125_test.go @@ -0,0 +1,69 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +//go:build go1.25 + +package mcp + +import ( + "context" + "testing" + "testing/synctest" +) + +func TestElicitationCompleteNotification_Synctest(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := context.Background() + + var elicitationCompleteCh = make(chan *ElicitationCompleteParams, 1) + + c := NewClient(testImpl, &ClientOptions{ + Capabilities: &ClientCapabilities{ + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, + Elicitation: &ElicitationCapabilities{ + URL: &URLElicitationCapabilities{}, + }, + }, + ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + ElicitationCompleteHandler: func(_ context.Context, req *ElicitationCompleteNotificationRequest) { + elicitationCompleteCh <- req.Params + }, + }) + + _, ss, cleanup := basicClientServerConnection(t, c, nil, nil) + defer cleanup() + + // 1. Server initiates a URL elicitation + elicitID := "testElicitationID-123" + resp, err := ss.Elicit(ctx, &ElicitParams{ + Mode: "url", + Message: "Please complete this form: ", + URL: "https://example.com/form?id=" + elicitID, + ElicitationID: elicitID, + }) + if err != nil { + t.Fatalf("Elicit failed: %v", err) + } + if resp.Action != "accept" { + t.Fatalf("Elicit action is %q, want %q", resp.Action, "accept") + } + + // 2. Server sends elicitation complete notification (simulating out-of-band completion) + err = handleNotify(ctx, notificationElicitationComplete, newServerRequest(ss, &ElicitationCompleteParams{ + ElicitationID: elicitID, + })) + if err != nil { + t.Fatalf("failed to send elicitation complete notification: %v", err) + } + + // 3. Client should receive the notification + gotParams := <-elicitationCompleteCh + if gotParams.ElicitationID != elicitID { + t.Errorf("elicitationComplete notification ID mismatch: got %q, want %q", gotParams.ElicitationID, elicitID) + } + }) +} diff --git a/mcp/elicitation_test.go b/mcp/elicitation_test.go index bfa71e7..bb560b6 100644 --- a/mcp/elicitation_test.go +++ b/mcp/elicitation_test.go @@ -143,6 +143,7 @@ func TestElicitationURLMode(t *testing.T) { } } +// TODO: remove this test when Go 1.24 support is dropped (use go1.25 synctest version). func TestElicitationCompleteNotification(t *testing.T) { ctx := context.Background() diff --git a/mcp/mcp_go125_test.go b/mcp/mcp_go125_test.go new file mode 100644 index 0000000..5c52db1 --- /dev/null +++ b/mcp/mcp_go125_test.go @@ -0,0 +1,756 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +//go:build go1.25 + +package mcp + +import ( + "context" + "errors" + "fmt" + "log/slog" + "path/filepath" + "runtime" + "slices" + "strings" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/jsonschema-go/jsonschema" +) + +// TODO(maciej-kisiel): split this test into multiple test cases that target specific functionality. +func TestEndToEnd_Synctest(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := context.Background() + var ct, st Transport = NewInMemoryTransports() + + // Channels to check if notification callbacks happened. + // These test asynchronous sending of notifications after a small delay (see + // Server.sendNotification). + notificationChans := map[string]chan int{} + for _, name := range []string{"initialized", "roots", "tools", "prompts", "resources", "progress_server", "progress_client", "resource_updated", "subscribe", "unsubscribe", "elicitation_complete"} { + notificationChans[name] = make(chan int, 1) + } + + waitForNotification := func(t *testing.T, name string) { + t.Helper() + time.Sleep(notificationDelay * 2) + <-notificationChans[name] + } + + sopts := &ServerOptions{ + InitializedHandler: func(context.Context, *InitializedRequest) { + notificationChans["initialized"] <- 0 + }, + RootsListChangedHandler: func(context.Context, *RootsListChangedRequest) { + notificationChans["roots"] <- 0 + }, + ProgressNotificationHandler: func(context.Context, *ProgressNotificationServerRequest) { + notificationChans["progress_server"] <- 0 + }, + SubscribeHandler: func(context.Context, *SubscribeRequest) error { + notificationChans["subscribe"] <- 0 + return nil + }, + UnsubscribeHandler: func(context.Context, *UnsubscribeRequest) error { + notificationChans["unsubscribe"] <- 0 + return nil + }, + } + s := NewServer(testImpl, sopts) + AddTool(s, &Tool{ + Name: "greet", + Description: "say hi", + }, sayHi) + AddTool(s, &Tool{Name: "fail", InputSchema: &jsonschema.Schema{Type: "object"}}, + func(context.Context, *CallToolRequest, map[string]any) (*CallToolResult, any, error) { + return nil, nil, errTestFailure + }) + s.AddPrompt(codeReviewPrompt, codReviewPromptHandler) + s.AddPrompt(&Prompt{Name: "fail"}, func(_ context.Context, _ *GetPromptRequest) (*GetPromptResult, error) { + return nil, errTestFailure + }) + s.AddResource(resource1, readHandler) + s.AddResource(resource2, readHandler) + + // Connect the server. + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + if got := slices.Collect(s.Sessions()); len(got) != 1 { + t.Errorf("after connection, Clients() has length %d, want 1", len(got)) + } + + loggingMessages := make(chan *LoggingMessageParams, 100) // big enough for all logging + opts := &ClientOptions{ + CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) { + return &CreateMessageResult{Model: "aModel", Content: &TextContent{}}, nil + }, + ElicitationHandler: func(ctx context.Context, req *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{ + Action: "accept", + Content: map[string]any{ + "name": "Test User", + "email": "test@example.com", + }, + }, nil + }, + ToolListChangedHandler: func(context.Context, *ToolListChangedRequest) { + notificationChans["tools"] <- 0 + }, + PromptListChangedHandler: func(context.Context, *PromptListChangedRequest) { + notificationChans["prompts"] <- 0 + }, + ResourceListChangedHandler: func(context.Context, *ResourceListChangedRequest) { + notificationChans["resources"] <- 0 + }, + LoggingMessageHandler: func(_ context.Context, req *LoggingMessageRequest) { + loggingMessages <- req.Params + }, + ProgressNotificationHandler: func(context.Context, *ProgressNotificationClientRequest) { + notificationChans["progress_client"] <- 0 + }, + ResourceUpdatedHandler: func(context.Context, *ResourceUpdatedNotificationRequest) { + notificationChans["resource_updated"] <- 0 + }, + ElicitationCompleteHandler: func(_ context.Context, req *ElicitationCompleteNotificationRequest) { + notificationChans["elicitation_complete"] <- 0 + }, + } + c := NewClient(testImpl, opts) + rootAbs, err := filepath.Abs(filepath.FromSlash("testdata/files")) + if err != nil { + t.Fatal(err) + } + c.AddRoots(&Root{URI: "file://" + rootAbs}) + + // Connect the client. + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + + waitForNotification(t, "initialized") + if err := cs.Ping(ctx, nil); err != nil { + t.Fatalf("ping failed: %v", err) + } + + // ===== prompts ===== + t.Log("Testing prompts") + { + res, err := cs.ListPrompts(ctx, nil) + if err != nil { + t.Fatalf("prompts/list failed: %v", err) + } + wantPrompts := []*Prompt{ + { + Name: "code_review", + Description: "do a code review", + Arguments: []*PromptArgument{{Name: "Code", Required: true}}, + }, + {Name: "fail"}, + } + if diff := cmp.Diff(wantPrompts, res.Prompts); diff != "" { + t.Fatalf("prompts/list mismatch (-want +got):\n%s", diff) + } + + gotReview, err := cs.GetPrompt(ctx, &GetPromptParams{Name: "code_review", Arguments: map[string]string{"Code": "1+1"}}) + if err != nil { + t.Fatal(err) + } + wantReview := &GetPromptResult{ + Description: "Code review prompt", + Messages: []*PromptMessage{{ + Content: &TextContent{Text: "Please review the following code: 1+1"}, + Role: "user", + }}, + } + if diff := cmp.Diff(wantReview, gotReview); diff != "" { + t.Errorf("prompts/get 'code_review' mismatch (-want +got):\n%s", diff) + } + + if _, err := cs.GetPrompt(ctx, &GetPromptParams{Name: "fail"}); err == nil || !strings.Contains(err.Error(), errTestFailure.Error()) { + t.Errorf("fail returned unexpected error: got %v, want containing %v", err, errTestFailure) + } + + s.AddPrompt(&Prompt{Name: "T"}, nil) + waitForNotification(t, "prompts") + s.RemovePrompts("T") + waitForNotification(t, "prompts") + } + + // ===== tools ===== + t.Log("Testing tools") + { + // ListTools is tested in client_list_test.go. + gotHi, err := cs.CallTool(ctx, &CallToolParams{ + Name: "greet", + Arguments: map[string]any{"Name": "user"}, + }) + if err != nil { + t.Fatal(err) + } + wantHi := &CallToolResult{ + Content: []Content{ + &TextContent{Text: "hi user"}, + }, + } + if diff := cmp.Diff(wantHi, gotHi, ctrCmpOpts...); diff != "" { + t.Errorf("tools/call 'greet' mismatch (-want +got):\n%s", diff) + } + + gotFail, err := cs.CallTool(ctx, &CallToolParams{ + Name: "fail", + Arguments: map[string]any{}, + }) + // Counter-intuitively, when a tool fails, we don't expect an RPC error for + // call tool: instead, the failure is embedded in the result. + if err != nil { + t.Fatal(err) + } + wantFail := &CallToolResult{ + IsError: true, + Content: []Content{ + &TextContent{Text: errTestFailure.Error()}, + }, + } + if diff := cmp.Diff(wantFail, gotFail, ctrCmpOpts...); diff != "" { + t.Errorf("tools/call 'fail' mismatch (-want +got):\n%s", diff) + } + + // Check output schema validation. + badout := &Tool{ + Name: "badout", + OutputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "x": {Type: "string"}, + }, + }, + } + AddTool(s, badout, func(_ context.Context, _ *CallToolRequest, arg map[string]any) (*CallToolResult, map[string]any, error) { + return nil, map[string]any{"x": 1}, nil + }) + _, err = cs.CallTool(ctx, &CallToolParams{Name: "badout"}) + wantMsg := `has type "integer", want "string"` + if err == nil || !strings.Contains(err.Error(), wantMsg) { + t.Errorf("\ngot %q\nwant error message containing %q", err, wantMsg) + } + + // Check tools-changed notifications. + s.AddTool(&Tool{Name: "T", InputSchema: &jsonschema.Schema{Type: "object"}}, nopHandler) + waitForNotification(t, "tools") + s.RemoveTools("T") + waitForNotification(t, "tools") + } + + // ===== resources ===== + t.Log("Testing resources") + //TODO: fix for Windows + if runtime.GOOS != "windows" { + wantResources := []*Resource{resource2, resource1} + lrres, err := cs.ListResources(ctx, nil) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(wantResources, lrres.Resources); diff != "" { + t.Errorf("resources/list mismatch (-want, +got):\n%s", diff) + } + + template := &ResourceTemplate{ + Name: "rt", + MIMEType: "text/template", + URITemplate: "file:///{+filename}", // the '+' means that filename can contain '/' + } + s.AddResourceTemplate(template, readHandler) + tres, err := cs.ListResourceTemplates(ctx, nil) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff([]*ResourceTemplate{template}, tres.ResourceTemplates); diff != "" { + t.Errorf("resources/list mismatch (-want, +got):\n%s", diff) + } + + for _, tt := range []struct { + uri string + mimeType string // "": not found; "text/plain": resource; "text/template": template + fail bool // non-nil error returned + }{ + {"file:///info.txt", "text/plain", false}, + {"file:///fail.txt", "", false}, + {"file:///template.txt", "text/template", false}, + {"file:///../private.txt", "", true}, // not found: escaping disallowed + } { + rres, err := cs.ReadResource(ctx, &ReadResourceParams{URI: tt.uri}) + if err != nil { + if code := errorCode(err); code == CodeResourceNotFound { + if tt.mimeType != "" { + t.Errorf("%s: not found but expected it to be", tt.uri) + } + } else if !tt.fail { + t.Errorf("%s: unexpected error %v", tt.uri, err) + } + } else { + if tt.fail { + t.Errorf("%s: unexpected success", tt.uri) + } else if g, w := len(rres.Contents), 1; g != w { + t.Errorf("got %d contents, wanted %d", g, w) + } else { + c := rres.Contents[0] + if got := c.URI; got != tt.uri { + t.Errorf("got uri %q, want %q", got, tt.uri) + } + if got := c.MIMEType; got != tt.mimeType { + t.Errorf("%s: got MIME type %q, want %q", tt.uri, got, tt.mimeType) + } + } + } + } + + s.AddResource(&Resource{URI: "http://U"}, nil) + waitForNotification(t, "resources") + s.RemoveResources("http://U") + waitForNotification(t, "resources") + } + + // ===== roots ===== + t.Log("Testing roots") + { + rootRes, err := ss.ListRoots(ctx, &ListRootsParams{}) + if err != nil { + t.Fatal(err) + } + gotRoots := rootRes.Roots + wantRoots := slices.Collect(c.roots.all()) + if diff := cmp.Diff(wantRoots, gotRoots); diff != "" { + t.Errorf("roots/list mismatch (-want +got):\n%s", diff) + } + + c.AddRoots(&Root{URI: "U"}) + waitForNotification(t, "roots") + c.RemoveRoots("U") + waitForNotification(t, "roots") + } + + // ===== sampling ===== + t.Log("Testing sampling") + { + // TODO: test that a client that doesn't have the handler returns CodeUnsupportedMethod. + res, err := ss.CreateMessage(ctx, &CreateMessageParams{}) + if err != nil { + t.Fatal(err) + } + if g, w := res.Model, "aModel"; g != w { + t.Errorf("got %q, want %q", g, w) + } + } + + // ===== logging ===== + t.Log("Testing logging") + { + want := []*LoggingMessageParams{ + { + Logger: "test", + Level: "warning", + Data: map[string]any{ + "msg": "first", + "name": "Pat", + "logtest": true, + }, + }, + { + Logger: "test", + Level: "alert", + Data: map[string]any{ + "msg": "second", + "count": 2.0, + "logtest": true, + }, + }, + } + + check := func(t *testing.T) { + t.Helper() + var got []*LoggingMessageParams + // Read messages from this test until we've seen all we expect. + for len(got) < len(want) { + p := <-loggingMessages + // Ignore logging from other tests. + if m, ok := p.Data.(map[string]any); ok && m["logtest"] != nil { + delete(m, "time") + got = append(got, p) + } + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("mismatch (-want, +got):\n%s", diff) + } + } + + // Use the LoggingMessage method directly. + t.Log("Testing logging (direct)") + { + mustLog := func(level LoggingLevel, data any) { + t.Helper() + if err := ss.Log(ctx, &LoggingMessageParams{ + Logger: "test", + Level: level, + Data: data, + }); err != nil { + t.Fatal(err) + } + } + + // Nothing should be logged until the client sets a level. + mustLog("info", "before") + if err := cs.SetLoggingLevel(ctx, &SetLoggingLevelParams{Level: "warning"}); err != nil { + t.Fatal(err) + } + mustLog("warning", want[0].Data) + mustLog("debug", "nope") // below the level + mustLog("info", "negative") // below the level + mustLog("alert", want[1].Data) + check(t) + } + + // Use the slog handler. + t.Log("Testing logging (handler)") + { + // We can't check the "before SetLevel" behavior because it's already been set. + // Not a big deal: that check is in LoggingMessage anyway. + logger := slog.New(NewLoggingHandler(ss, &LoggingHandlerOptions{LoggerName: "test"})) + logger.Warn("first", "name", "Pat", "logtest", true) + logger.Debug("nope") // below the level + logger.Info("negative") // below the level + logger.Log(ctx, LevelAlert, "second", "count", 2, "logtest", true) + check(t) + } + } + + // ===== progress ===== + t.Log("Testing progress") + { + ss.NotifyProgress(ctx, &ProgressNotificationParams{ + ProgressToken: "token-xyz", + Message: "progress update", + Progress: 0.5, + Total: 2, + }) + waitForNotification(t, "progress_client") + + cs.NotifyProgress(ctx, &ProgressNotificationParams{ + ProgressToken: "token-abc", + Message: "progress update", + Progress: 1, + Total: 2, + }) + waitForNotification(t, "progress_server") + } + + // ===== resource_subscriptions ===== + t.Log("Testing resource_subscriptions") + { + err := cs.Subscribe(ctx, &SubscribeParams{ + URI: "test", + }) + if err != nil { + t.Fatal(err) + } + synctest.Wait() + <-notificationChans["subscribe"] + + s.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{ + URI: "test", + }) + waitForNotification(t, "resource_updated") + + err = cs.Unsubscribe(ctx, &UnsubscribeParams{ + URI: "test", + }) + if err != nil { + t.Fatal(err) + } + waitForNotification(t, "unsubscribe") + + // Verify the client does not receive the update after unsubscribing. + s.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{ + URI: "test", + }) + synctest.Wait() + select { + case <-notificationChans["resource_updated"]: + t.Fatalf("resource updated after unsubscription") + default: + // Expected: no notification received + } + } + + // ===== elicitation ===== + t.Log("Testing elicitation") + { + result, err := ss.Elicit(ctx, &ElicitParams{ + Message: "Please provide information", + }) + if err != nil { + t.Fatal(err) + } + if result.Action != "accept" { + t.Errorf("got action %q, want %q", result.Action, "accept") + } + } + + // Disconnect. + cs.Close() + if err := ss.Wait(); err != nil { + t.Errorf("server failed: %v", err) + } + + // After disconnecting, neither client nor server should have any + // connections. + for range s.Sessions() { + t.Errorf("unexpected client after disconnection") + } + }) +} + +func TestKeepAlive_Synctest(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := context.Background() + + ct, st := NewInMemoryTransports() + + serverOpts := &ServerOptions{ + KeepAlive: 100 * time.Millisecond, + } + s := NewServer(testImpl, serverOpts) + AddTool(s, greetTool(), sayHi) + + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + clientOpts := &ClientOptions{ + KeepAlive: 100 * time.Millisecond, + } + c := NewClient(testImpl, clientOpts) + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + // Wait for a few keepalive cycles to ensure pings are working. + // With synctest, this advances simulated time instantly. + time.Sleep(300 * time.Millisecond) + + // Test that the connection is still alive by making a call + result, err := cs.CallTool(ctx, &CallToolParams{ + Name: "greet", + Arguments: map[string]any{"Name": "user"}, + }) + if err != nil { + t.Fatalf("call failed after keepalive: %v", err) + } + if len(result.Content) == 0 { + t.Fatal("expected content in result") + } + if textContent, ok := result.Content[0].(*TextContent); !ok || textContent.Text != "hi user" { + t.Fatalf("unexpected result: %v", result.Content[0]) + } + }) +} + +func TestCancellation_Synctest(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + var ( + start = make(chan struct{}) + cancelled = make(chan struct{}, 1) // don't block the request + ) + slowTool := func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { + start <- struct{}{} + select { + case <-ctx.Done(): + cancelled <- struct{}{} + case <-time.After(5 * time.Second): + return nil, nil, nil + } + return nil, nil, nil + } + cs, _, cleanup := basicConnection(t, func(s *Server) { + AddTool(s, &Tool{Name: "slow", InputSchema: &jsonschema.Schema{Type: "object"}}, slowTool) + }) + defer cleanup() + + ctx, cancel := context.WithCancel(context.Background()) + go cs.CallTool(ctx, &CallToolParams{Name: "slow"}) + <-start + cancel() + + <-cancelled + }) +} + +func TestKeepAliveFailure_Synctest(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := context.Background() + + // This test doesn't pinpoint keepalive detected failures well due to the fact that in-memory transports + // propagate `io.EOF` synchronously, causing the transport level connection to be closed immediately. + // TODO: propose better transports that would allow testing this scenario precisely. + ct, st := NewInMemoryTransports() + + // Server without keepalive (to test one-sided keepalive) + s := NewServer(testImpl, nil) + AddTool(s, greetTool(), sayHi) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + + // Client with short keepalive + clientOpts := &ClientOptions{ + KeepAlive: 50 * time.Millisecond, + } + c := NewClient(testImpl, clientOpts) + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + // Let the connection establish properly first + synctest.Wait() + + // simulate ping failure + ss.Close() + + time.Sleep(100 * time.Millisecond) + synctest.Wait() + + _, err = cs.CallTool(ctx, &CallToolParams{ + Name: "greet", + Arguments: map[string]any{"Name": "user"}, + }) + if err != nil && (errors.Is(err, ErrConnectionClosed) || strings.Contains(err.Error(), "connection closed")) { + return // Test passed + } + + t.Errorf("expected connection to be closed by keepalive, but it wasn't. Last error: %v", err) + }) +} + +func TestSynchronousNotifications_Synctest(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + var toolsChanged atomic.Int32 + clientOpts := &ClientOptions{ + ToolListChangedHandler: func(ctx context.Context, req *ToolListChangedRequest) { + toolsChanged.Add(1) + }, + CreateMessageHandler: func(ctx context.Context, req *CreateMessageRequest) (*CreateMessageResult, error) { + // See the comment after "from server" below. + if n := toolsChanged.Load(); n != 1 { + return nil, fmt.Errorf("got %d tools-changed notification, wanted 1", n) + } + // TODO(rfindley): investigate the error returned from this test if + // CreateMessageResult is new(CreateMessageResult): it's a mysterious + // unmarshalling error that we should improve. + return &CreateMessageResult{Content: &TextContent{}}, nil + }, + } + client := NewClient(testImpl, clientOpts) + + var rootsChanged atomic.Bool + serverOpts := &ServerOptions{ + RootsListChangedHandler: func(_ context.Context, req *RootsListChangedRequest) { + rootsChanged.Store(true) + }, + } + server := NewServer(testImpl, serverOpts) + addTool := func(s *Server) { + AddTool(s, &Tool{Name: "tool"}, func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { + if !rootsChanged.Load() { + return nil, nil, fmt.Errorf("didn't get root change notification") + } + return new(CallToolResult), nil, nil + }) + } + cs, ss, cleanup := basicClientServerConnection(t, client, server, addTool) + defer cleanup() + + t.Log("from client") + { + client.AddRoots(&Root{Name: "myroot", URI: "file://foo"}) + res, err := cs.CallTool(context.Background(), &CallToolParams{Name: "tool"}) + if err != nil { + t.Fatalf("CallTool failed: %v", err) + } + if res.IsError { + t.Errorf("tool error: %v", res.Content[0].(*TextContent).Text) + } + } + + t.Log("from server") + { + // Despite all this tool-changed activity, we expect only one notification. + for range 10 { + server.RemoveTools("tool") + addTool(server) + } + + time.Sleep(notificationDelay * 2) // Wait for delayed notification. + if _, err := ss.CreateMessage(context.Background(), new(CreateMessageParams)); err != nil { + t.Errorf("CreateMessage failed: %v", err) + } + } + + }) +} + +func TestNoDistributedDeadlock_Synctest(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + // This test verifies that calls are asynchronous, and so it's not possible + // to have a distributed deadlock. + // + // The setup creates potential deadlock for both the client and server: the + // client sends a call to tool1, which itself calls createMessage, which in + // turn calls tool2, which calls ping. + // + // If the server were not asynchronous, the call to tool2 would hang. If the + // client were not asynchronous, the call to ping would hang. + // + // Such a scenario is unlikely in practice, but is still theoretically + // possible, and in any case making tool calls asynchronous by default + // delegates synchronization to the user. + clientOpts := &ClientOptions{ + CreateMessageHandler: func(ctx context.Context, req *CreateMessageRequest) (*CreateMessageResult, error) { + req.Session.CallTool(ctx, &CallToolParams{Name: "tool2"}) + return &CreateMessageResult{Content: &TextContent{}}, nil + }, + } + client := NewClient(testImpl, clientOpts) + cs, _, cleanup := basicClientServerConnection(t, client, nil, func(s *Server) { + AddTool(s, &Tool{Name: "tool1"}, func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { + req.Session.CreateMessage(ctx, new(CreateMessageParams)) + return new(CallToolResult), nil, nil + }) + AddTool(s, &Tool{Name: "tool2"}, func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { + req.Session.Ping(ctx, nil) + return new(CallToolResult), nil, nil + }) + }) + defer cleanup() + + if _, err := cs.CallTool(context.Background(), &CallToolParams{Name: "tool1"}); err != nil { + // should not deadlock + t.Fatalf("CallTool failed: %v", err) + } + }) +} diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index ce352ae..71a1dcc 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -56,6 +56,7 @@ func codReviewPromptHandler(_ context.Context, req *GetPromptRequest) (*GetPromp }, nil } +// TODO: remove this test when Go 1.24 support is dropped (use go1.25 synctest version). func TestEndToEnd(t *testing.T) { ctx := context.Background() var ct, st Transport = NewInMemoryTransports() @@ -672,6 +673,7 @@ func TestServerClosing(t *testing.T) { } } +// TODO: remove this test when Go 1.24 support is dropped (use go1.25 synctest version). func TestBatching(t *testing.T) { ctx := context.Background() ct, st := NewInMemoryTransports() @@ -710,6 +712,7 @@ func TestBatching(t *testing.T) { } } +// TODO: remove this test when Go 1.24 support is dropped (use go1.25 synctest version). func TestCancellation(t *testing.T) { var ( start = make(chan struct{}) @@ -903,6 +906,7 @@ func nopHandler(context.Context, *CallToolRequest) (*CallToolResult, error) { return nil, nil } +// TODO: remove this test when Go 1.24 support is dropped (use go1.25 synctest version). func TestKeepAlive(t *testing.T) { // TODO: try to use the new synctest package for this test once we upgrade to Go 1.24+. // synctest would allow us to control time and avoid the time.Sleep calls, making the test @@ -1715,6 +1719,7 @@ func TestElicitationDefaultValues(t *testing.T) { } } +// TODO: remove this test when Go 1.24 support is dropped (use go1.25 synctest version). func TestKeepAliveFailure(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -1798,6 +1803,7 @@ func TestAddTool_DuplicateNoPanicAndNoDuplicate(t *testing.T) { } } +// TODO: remove this test when Go 1.24 support is dropped (use go1.25 synctest version). func TestSynchronousNotifications(t *testing.T) { var toolsChanged atomic.Int32 clientOpts := &ClientOptions{ @@ -1860,6 +1866,7 @@ func TestSynchronousNotifications(t *testing.T) { }) } +// TODO: remove this test when Go 1.24 support is dropped (use go1.25 synctest version). func TestNoDistributedDeadlock(t *testing.T) { // This test verifies that calls are asynchronous, and so it's not possible // to have a distributed deadlock. From 6a81e5da2a3581e14bf2709dbdf88342d582f8d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Theodor=20Nesfeldt=20Eng=C3=B8y?= <140903820+TheodorNEngoy@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:02:17 +0100 Subject: [PATCH 188/418] examples: bind auth-middleware server to localhost by default (#784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auth-middleware example currently defaults to `-http :8080`, which binds to all interfaces (LAN-exposed) by default. This changes the default to `localhost:8080` and adds a short comment noting `-http 0.0.0.0:8080` if you intentionally want to expose it. Rationale: this example serves token-generation endpoints and authenticated MCP routes; binding loopback by default avoids accidental exposure while keeping the opt-in remote behavior. --------- Co-authored-by: Theodor N. Engøy Co-authored-by: Theodor N. Engøy --- examples/server/auth-middleware/main.go | 4 +++- scripts/server-conformance.sh | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/server/auth-middleware/main.go b/examples/server/auth-middleware/main.go index ca339df..f9ac34f 100644 --- a/examples/server/auth-middleware/main.go +++ b/examples/server/auth-middleware/main.go @@ -26,7 +26,9 @@ import ( // This example demonstrates how to integrate auth.RequireBearerToken middleware // with an MCP server to provide authenticated access to MCP tools and resources. -var httpAddr = flag.String("http", ":8080", "HTTP address to listen on") +// Default to loopback to avoid exposing powerful demo endpoints to the local network. +// Set -http 0.0.0.0:8080 to intentionally expose it. +var httpAddr = flag.String("http", "localhost:8080", "HTTP address to listen on") // JWTClaims represents the claims in our JWT tokens. // In a real application, you would include additional claims like issuer, audience, etc. diff --git a/scripts/server-conformance.sh b/scripts/server-conformance.sh index 0826086..f31d8df 100755 --- a/scripts/server-conformance.sh +++ b/scripts/server-conformance.sh @@ -70,8 +70,8 @@ fi go build -o "$WORKDIR/conformance-server" ./conformance/everything-server # Start the server in the background -echo "Starting conformance server on port $PORT..." -"$WORKDIR/conformance-server" -http=":$PORT" & +echo "Starting conformance server on localhost:$PORT..." +"$WORKDIR/conformance-server" -http="localhost:$PORT" & SERVER_PID=$! echo "Server pid is $SERVER_PID" From c952ab0a1c6cdb23f06d4eca23d37cb446a977c7 Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Fri, 13 Feb 2026 10:39:53 +0100 Subject: [PATCH 189/418] chore: add ROADMAP.md (#788) Roadmap is required for Tier 1 SDK classification. --- ROADMAP.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 ROADMAP.md diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..589bf55 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,32 @@ +# MCP Go SDK Roadmap + +## Current focus + +The following items are planned for the next release (v1.4.0): + +- **SEP-1730: Tier 1 SDK support** (https://github.com/modelcontextprotocol/go-sdk/issues/675) + - Description: We aim to be rated as Tier 1 SDK. +- **SEP-1577: support Sampling With Tools** (https://github.com/modelcontextprotocol/go-sdk/issues/629) + - Description: Sampling with Tools is the last SEP to be implemented + to implement the full scope of 2025-11-25 MCP specification. +- **OAuth Support** (https://github.com/modelcontextprotocol/go-sdk/issues/19) + - Description: Client-side OAuth support is the last feature + needed to provide the Authorization solution as per specification. + +## Future work + +Once the tiering system is in place, we will draft a new roadmap to describe +our long-term strategy. It may include experimental features or extensions +to the MCP specification listed in the next section. + +## Experimental features + +- **SEP-1686 (experimental): Implement Tasks** (https://github.com/modelcontextprotocol/go-sdk/issues/626) + +## Extensions + +### Authorization extensions (https://github.com/modelcontextprotocol/ext-auth) + +- **ext-auth: Enterprise Managed Authorization** (https://github.com/modelcontextprotocol/go-sdk/issues/628) + - Status: In progress - https://github.com/modelcontextprotocol/go-sdk/pull/770 +- **ext-auth: OAuth Client Credentials** (https://github.com/modelcontextprotocol/go-sdk/issues/627) \ No newline at end of file From 67bd3f2e2b53ce11a16db8d976cdb8ff1e986b6d Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 13 Feb 2026 15:23:32 +0000 Subject: [PATCH 190/418] mcp: add automatic DNS rebinding protection for localhost servers (#760) ## Summary Add DNS rebinding protection that is automatically enabled when requests arrive via localhost (127.0.0.1, [::1]). This protects against malicious websites using DNS rebinding to interact with local MCP servers. (note: this was a claude assisted PR, mostly wanted to see how difficult passing this test would be for this SDK) ## Design Goal: Secure by Default The primary goal is to make it difficult to run a localhost server without these protections by mistake. There are other approaches that could provide secure defaults (e.g., a helper for `ListenAndServe` that does the localhost check), but using `http.LocalAddrContextKey` for runtime detection seemed like the most backwards compatible approach and least likely to be disabled by accident. With this implementation: - **No code changes required** - existing servers get protection automatically - **No opt-in needed** - protection activates based on the connection's local address - **Explicit opt-out** - users must deliberately set `DisableLocalhostProtection: true` ## Changes - Add `DisableLocalhostProtection` option to `StreamableHTTPOptions` - Add `isLocalhostAddr` and `isLocalhostHost` helper functions - Validate Host header at start of `ServeHTTP`, rejecting non-localhost Host headers with 403 Forbidden ## How it works The protection uses `http.LocalAddrContextKey` to detect the connection's local address at runtime. When a request arrives via localhost (127.0.0.1 or [::1]), the handler validates that the Host header also matches a localhost value. If not, the request is rejected with 403 Forbidden. This approach means: - Protection is enabled for requests arriving via localhost, regardless of whether the server listens on `127.0.0.1` or `0.0.0.0` - Requests arriving via non-localhost IPs (e.g., external network requests) are **not** affected ### Edge case: Reverse proxies If a reverse proxy (e.g., Envoy, nginx) runs on the same host and forwards requests to the MCP server via localhost while preserving the original Host header, those requests would be rejected. In this case, users should either: 1. Set `DisableLocalhostProtection: true` 2. Configure the proxy to rewrite the Host header to localhost ## Testing - Added unit tests for `isLocalhostAddr` and `isLocalhostHost` helper functions - Added integration tests for the full protection flow - Verified against the MCP conformance test suite: - `localhost-host-rebinding-rejected`: PASS - `localhost-host-valid-accepted`: PASS ## Related - Spec: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise - TypeScript SDK implementation: `localhostHostValidation()` middleware - Conformance test: `dns-rebinding-protection` scenario --------- Co-authored-by: Maciek Kisiel --- conformance/baseline.yml | 3 +- internal/util/net.go | 26 +++++++++ internal/util/net_test.go | 35 ++++++++++++ mcp/streamable.go | 30 +++++++++++ mcp/streamable_test.go | 111 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 internal/util/net.go create mode 100644 internal/util/net_test.go diff --git a/conformance/baseline.yml b/conformance/baseline.yml index f0e1513..ae1f9c6 100644 --- a/conformance/baseline.yml +++ b/conformance/baseline.yml @@ -1,5 +1,4 @@ -server: -- dns-rebinding-protection +server: [] # All tests pass! client: - auth/basic-cimd - auth/metadata-default diff --git a/internal/util/net.go b/internal/util/net.go new file mode 100644 index 0000000..6858614 --- /dev/null +++ b/internal/util/net.go @@ -0,0 +1,26 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. +package util + +import ( + "net" + "net/netip" + "strings" +) + +func IsLoopback(addr string) bool { + host, _, err := net.SplitHostPort(addr) + if err != nil { + // If SplitHostPort fails, it might be just a host without a port. + host = strings.Trim(addr, "[]") + } + if host == "localhost" { + return true + } + ip, err := netip.ParseAddr(host) + if err != nil { + return false + } + return ip.IsLoopback() +} diff --git a/internal/util/net_test.go b/internal/util/net_test.go new file mode 100644 index 0000000..fd3187b --- /dev/null +++ b/internal/util/net_test.go @@ -0,0 +1,35 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. +package util + +import "testing" + +// TestIsLoopback tests the IsLoopback helper function. +func TestIsLoopback(t *testing.T) { + tests := []struct { + addr string + want bool + }{ + {"localhost", true}, + {"localhost:3000", true}, + {"127.0.0.1", true}, + {"127.0.0.1:3000", true}, + {"[::1]", true}, + {"[::1]:3000", true}, + {"::1", true}, + {"", false}, + {"evil.com", false}, + {"evil.com:80", false}, + {"localhost.evil.com", false}, + {"127.0.0.1.evil.com", false}, + } + + for _, tt := range tests { + t.Run(tt.addr, func(t *testing.T) { + if got := IsLoopback(tt.addr); got != tt.want { + t.Errorf("IsLoopback(%q) = %v, want %v", tt.addr, got, tt.want) + } + }) + } +} diff --git a/mcp/streamable.go b/mcp/streamable.go index c6b96bb..c06877f 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -20,6 +20,7 @@ import ( "maps" "math" "math/rand/v2" + "net" "net/http" "slices" "strconv" @@ -30,6 +31,8 @@ import ( "github.com/modelcontextprotocol/go-sdk/auth" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + "github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug" + "github.com/modelcontextprotocol/go-sdk/internal/util" "github.com/modelcontextprotocol/go-sdk/internal/xcontext" "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) @@ -161,6 +164,16 @@ type StreamableHTTPOptions struct { // // If SessionTimeout is the zero value, idle sessions are never closed. SessionTimeout time.Duration + + // DisableLocalhostProtection disables automatic DNS rebinding protection. + // By default, requests arriving via a localhost address (127.0.0.1, [::1]) + // that have a non-localhost Host header are rejected with 403 Forbidden. + // This protects against DNS rebinding attacks regardless of whether the + // server is listening on localhost specifically or on 0.0.0.0. + // + // Only disable this if you understand the security implications. + // See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise + DisableLocalhostProtection bool } // NewStreamableHTTPHandler returns a new [StreamableHTTPHandler]. @@ -207,7 +220,24 @@ func (h *StreamableHTTPHandler) closeAll() { } } +// disablelocalhostprotection is a compatibility parameter that allows to disable +// DNS rebinding protection, which was added in the 1.4.0 version of the SDK. +// See the documentation for the mcpgodebug package for instructions how to enable it. +// The option will be removed in the 1.6.0 version of the SDK. +var disablelocalhostprotection = mcpgodebug.Value("disablelocalhostprotection") + func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + // DNS rebinding protection: auto-enabled for localhost servers. + // See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise + if !h.opts.DisableLocalhostProtection && disablelocalhostprotection != "1" { + if localAddr, ok := req.Context().Value(http.LocalAddrContextKey).(net.Addr); ok && localAddr != nil { + if util.IsLoopback(localAddr.String()) && !util.IsLoopback(req.Host) { + http.Error(w, fmt.Sprintf("Forbidden: invalid Host header %q", req.Host), http.StatusForbidden) + return + } + } + } + // Allow multiple 'Accept' headers. // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept#syntax accept := strings.Split(strings.Join(req.Header.Values("Accept"), ","), ",") diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index f1f6200..2cbe400 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -2374,3 +2374,114 @@ func Test_ExportErrSessionMissing(t *testing.T) { t.Errorf("expected error to wrap ErrSessionMissing, got: %v", err) } } + +// TestStreamableLocalhostProtection verifies that DNS rebinding protection +// is automatically enabled for localhost servers. +func TestStreamableLocalhostProtection(t *testing.T) { + server := NewServer(testImpl, nil) + + tests := []struct { + name string + listenAddr string // Address to listen on + hostHeader string // Host header in request + disableProtection bool // DisableLocalhostProtection setting + wantStatus int + }{ + // Auto-enabled for localhost listeners (127.0.0.1). + { + name: "127.0.0.1 accepts 127.0.0.1", + listenAddr: "127.0.0.1:0", + hostHeader: "127.0.0.1:1234", + disableProtection: false, + wantStatus: http.StatusOK, + }, + { + name: "127.0.0.1 accepts localhost", + listenAddr: "127.0.0.1:0", + hostHeader: "localhost:1234", + disableProtection: false, + wantStatus: http.StatusOK, + }, + { + name: "127.0.0.1 rejects evil.com", + listenAddr: "127.0.0.1:0", + hostHeader: "evil.com", + disableProtection: false, + wantStatus: http.StatusForbidden, + }, + { + name: "127.0.0.1 rejects evil.com:80", + listenAddr: "127.0.0.1:0", + hostHeader: "evil.com:80", + disableProtection: false, + wantStatus: http.StatusForbidden, + }, + { + name: "127.0.0.1 rejects localhost.evil.com", + listenAddr: "127.0.0.1:0", + hostHeader: "localhost.evil.com", + disableProtection: false, + wantStatus: http.StatusForbidden, + }, + + // When listening on 0.0.0.0, requests arriving via localhost are still protected + // because LocalAddrContextKey returns the actual connection's local address. + // This is actually more secure - DNS rebinding attacks target localhost regardless + // of the listener configuration. + { + name: "0.0.0.0 via localhost rejects evil.com", + listenAddr: "0.0.0.0:0", + hostHeader: "evil.com", + disableProtection: false, + wantStatus: http.StatusForbidden, + }, + + // Explicit disable + { + name: "disabled accepts evil.com", + listenAddr: "127.0.0.1:0", + hostHeader: "evil.com", + disableProtection: true, + wantStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &StreamableHTTPOptions{ + Stateless: true, // Simpler for testing + DisableLocalhostProtection: tt.disableProtection, + } + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, opts) + + listener, err := net.Listen("tcp", tt.listenAddr) + if err != nil { + t.Fatalf("Failed to listen on %s: %v", tt.listenAddr, err) + } + defer listener.Close() + + srv := &http.Server{Handler: handler} + go srv.Serve(listener) + defer srv.Close() + + reqReader := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`) + req, err := http.NewRequest("POST", fmt.Sprintf("http://%s", listener.Addr().String()), reqReader) + if err != nil { + t.Fatal(err) + } + req.Host = tt.hostHeader + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if got := resp.StatusCode; got != tt.wantStatus { + t.Errorf("Status code: got %d, want %d", got, tt.wantStatus) + } + }) + } +} From 23ab1bfd26c8c64ef5c24c50261b6b33fcb3b036 Mon Sep 17 00:00:00 2001 From: Nithin P Date: Fri, 13 Feb 2026 22:46:57 +0530 Subject: [PATCH 191/418] jsonrpc2: fix Content-Length header parsing to be case-insensitive --- internal/jsonrpc2/frame.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/jsonrpc2/frame.go b/internal/jsonrpc2/frame.go index 46fcc9d..72527cb 100644 --- a/internal/jsonrpc2/frame.go +++ b/internal/jsonrpc2/frame.go @@ -163,8 +163,8 @@ func (r *headerReader) Read(ctx context.Context) (Message, error) { return nil, fmt.Errorf("invalid header line %q", line) } name, value := line[:colon], strings.TrimSpace(line[colon+1:]) - switch name { - case "Content-Length": + switch { + case strings.EqualFold(name, "Content-Length"): if contentLength, err = strconv.ParseInt(value, 10, 32); err != nil { return nil, fmt.Errorf("failed parsing Content-Length: %v", value) } From be7e6f49ab532c8ac757bdf1f2dc804af3155e60 Mon Sep 17 00:00:00 2001 From: Maciek Kisiel Date: Tue, 17 Feb 2026 08:52:03 +0000 Subject: [PATCH 192/418] chore: bump node.js version for conformance test runs. --- .github/workflows/nightly.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index e2b5a52..2bceda7 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -31,6 +31,6 @@ jobs: - name: Set up Node.js uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 with: - node-version: "20" + node-version: "22" - name: Run server conformance tests run: ./scripts/server-conformance.sh From 14869a46eea36d9ba9cc6cbe2fa8d30dde3647de Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Tue, 17 Feb 2026 11:49:32 +0100 Subject: [PATCH 193/418] chore: update issue templates (#797) Testing issue templates. --- .github/ISSUE_TEMPLATE/bug_report.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..ce398c1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,25 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' +type: Bug + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Logs** +If applicable, add logs to help explain your problem. + +**Additional context** +Add any other context about the problem here. From 3cf9f9908d3f44cf9fd75d134c378687e6fb7996 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Tue, 17 Feb 2026 06:07:08 -0500 Subject: [PATCH 194/418] mcp: implement sampling with tools (#699) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary Add support for tool use within sampling requests, as described in the MCP 2025-11-25 spec's sampling.tools capability. New types Content types: - ToolUseContent — represents a tool invocation request from the assistant - ToolResultContent — represents the result of a tool invocation, with nested []Content for the result blocks Capability types: - SamplingCapabilities gains Tools and Context sub-fields - SamplingToolsCapabilities, SamplingContextCapabilities Tool-enabled sampling (parallel tool calls): - CreateMessageWithToolsParams with SamplingMessageV2 (supports array content) - CreateMessageWithToolsResult (supports array content) - ToolChoice — controls tool invocation mode (auto, required, none) - ServerSession.CreateMessageWithTools - ClientOptions.CreateMessageWithToolsHandler Design Following the TypeScript SDK's pattern, tool-enabled sampling uses separate types from basic sampling to avoid breaking the existing API. The basic CreateMessage/CreateMessageResult path is unchanged. Both paths share the same wire method (sampling/createMessage) and go through the method info table. The table uses the broader CreateMessageWithToolsResult type internally; CreateMessage downconverts (erroring if multiple content blocks are returned). This is documented as a rough edge to unify in v2. Setting CreateMessageWithToolsHandler automatically infers the sampling.tools capability. It is a panic to set both CreateMessageHandler and CreateMessageWithToolsHandler. References - https://modelcontextprotocol.io/specification/2025-11-25/client/sampling - TypeScript SDK: CreateMessageResultWithToolsSchema / CreateMessageRequestParamsWithTools --------- Co-authored-by: Claude Opus 4.6 --- docs/rough_edges.md | 14 + internal/docs/rough_edges.src.md | 14 + mcp/client.go | 62 +++- mcp/content.go | 185 +++++++++- mcp/protocol.go | 232 +++++++++++- mcp/protocol_test.go | 582 +++++++++++++++++++++++++++++++ mcp/requests.go | 1 + mcp/sampling_test.go | 546 +++++++++++++++++++++++++++++ mcp/server.go | 43 ++- 9 files changed, 1641 insertions(+), 38 deletions(-) create mode 100644 mcp/sampling_test.go diff --git a/docs/rough_edges.md b/docs/rough_edges.md index 98758cd..e0e6d73 100644 --- a/docs/rough_edges.md +++ b/docs/rough_edges.md @@ -45,3 +45,17 @@ v2. **Workaround**: to advertise no capabilities, set `ServerOptions.Capabilities` or `ClientOptions.Capabilities` to an empty `&ServerCapabilities{}` or `&ClientCapabilities{}` respectively. + +- `CreateMessageResult.Content` is singular `Content`, but the 2025-11-25 spec + allows `content` to be a single block or an array (for parallel tool calls). + We added `CreateMessageResultWithTools` (with `Content []Content`) as a + workaround, matching the TypeScript SDK's approach. In v2, + `CreateMessageResult` should use `[]Content` directly. Similarly, + `SamplingMessage.Content` should become `[]Content` to support sending + multiple tool_result blocks in a single user message. + +- We didn't actually need CallToolParams and CallToolParamsRaw, since even when + we're unmarshalling into a custom Go type (for the mcp.AddTool convenience + wrapper) we need to first unmarshal into a `map[string]any` in order to do + server-side validation of required fields. CallToolParams could have just had + a map[string]any. diff --git a/internal/docs/rough_edges.src.md b/internal/docs/rough_edges.src.md index 4dc3219..42e79f7 100644 --- a/internal/docs/rough_edges.src.md +++ b/internal/docs/rough_edges.src.md @@ -44,3 +44,17 @@ v2. **Workaround**: to advertise no capabilities, set `ServerOptions.Capabilities` or `ClientOptions.Capabilities` to an empty `&ServerCapabilities{}` or `&ClientCapabilities{}` respectively. + +- `CreateMessageResult.Content` is singular `Content`, but the 2025-11-25 spec + allows `content` to be a single block or an array (for parallel tool calls). + We added `CreateMessageResultWithTools` (with `Content []Content`) as a + workaround, matching the TypeScript SDK's approach. In v2, + `CreateMessageResult` should use `[]Content` directly. Similarly, + `SamplingMessage.Content` should become `[]Content` to support sending + multiple tool_result blocks in a single user message. + +- We didn't actually need CallToolParams and CallToolParamsRaw, since even when + we're unmarshalling into a custom Go type (for the mcp.AddTool convenience + wrapper) we need to first unmarshal into a `map[string]any` in order to do + server-side validation of required fields. CallToolParams could have just had + a map[string]any. diff --git a/mcp/client.go b/mcp/client.go index a167047..96f1f82 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -52,6 +52,9 @@ func NewClient(impl *Implementation, options *ClientOptions) *Client { } options = nil // prevent reuse + if opts.CreateMessageHandler != nil && opts.CreateMessageWithToolsHandler != nil { + panic("cannot set both CreateMessageHandler and CreateMessageWithToolsHandler; use CreateMessageWithToolsHandler for tool support, or CreateMessageHandler for basic sampling") + } if opts.Logger == nil { // ensure we have a logger opts.Logger = ensureLogger(nil) } @@ -77,6 +80,19 @@ type ClientOptions struct { // non nil value for [ClientCapabilities.Sampling], that value overrides the // inferred capability. CreateMessageHandler func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) + // CreateMessageWithToolsHandler handles incoming sampling/createMessage + // requests that may involve tool use. It returns + // [CreateMessageWithToolsResult], which supports array content for parallel + // tool calls. + // + // Setting this handler causes the client to advertise the sampling + // capability with tools support (sampling.tools). As with + // [CreateMessageHandler], [ClientOptions.Capabilities].Sampling overrides + // the inferred capability. + // + // It is a panic to set both CreateMessageHandler and + // CreateMessageWithToolsHandler. + CreateMessageWithToolsHandler func(context.Context, *CreateMessageWithToolsRequest) (*CreateMessageWithToolsResult, error) // ElicitationHandler handles incoming requests for elicitation/create. // // Setting ElicitationHandler to a non-nil value automatically causes the @@ -109,7 +125,16 @@ type ClientOptions struct { // are set in the Capabilities field, their values override the inferred // value. // - // For example, to to configure elicitation modes: + // For example, to advertise sampling with tools and context support: + // + // Capabilities: &ClientCapabilities{ + // Sampling: &SamplingCapabilities{ + // Tools: &SamplingToolsCapabilities{}, + // Context: &SamplingContextCapabilities{}, + // }, + // } + // + // Or to configure elicitation modes: // // Capabilities: &ClientCapabilities{ // Elicitation: &ElicitationCapabilities{ @@ -119,8 +144,7 @@ type ClientOptions struct { // } // // Conversely, if Capabilities does not set a field (for example, if the - // Elicitation field is nil), the inferred elicitation capability will be - // used. + // Elicitation field is nil), the inferred capability will be used. Capabilities *ClientCapabilities // ElicitationCompleteHandler handles incoming notifications for notifications/elicitation/complete. ElicitationCompleteHandler func(context.Context, *ElicitationCompleteNotificationRequest) @@ -198,10 +222,13 @@ func (c *Client) capabilities(protocolVersion string) *ClientCapabilities { caps.Roots = *caps.RootsV2 } - // Augment with sampling capability if handler is set. - if c.opts.CreateMessageHandler != nil { + // Augment with sampling capability if a handler is set. + if c.opts.CreateMessageHandler != nil || c.opts.CreateMessageWithToolsHandler != nil { if caps.Sampling == nil { caps.Sampling = &SamplingCapabilities{} + if c.opts.CreateMessageWithToolsHandler != nil { + caps.Sampling.Tools = &SamplingToolsCapabilities{} + } } } @@ -453,12 +480,27 @@ func (c *Client) listRoots(_ context.Context, req *ListRootsRequest) (*ListRoots }, nil } -func (c *Client) createMessage(ctx context.Context, req *CreateMessageRequest) (*CreateMessageResult, error) { - if c.opts.CreateMessageHandler == nil { - // TODO: wrap or annotate this error? Pick a standard code? - return nil, &jsonrpc.Error{Code: codeUnsupportedMethod, Message: "client does not support CreateMessage"} +func (c *Client) createMessage(ctx context.Context, req *CreateMessageWithToolsRequest) (*CreateMessageWithToolsResult, error) { + if c.opts.CreateMessageWithToolsHandler != nil { + return c.opts.CreateMessageWithToolsHandler(ctx, req) } - return c.opts.CreateMessageHandler(ctx, req) + if c.opts.CreateMessageHandler != nil { + // Downconvert the request for the basic handler. + baseParams, err := req.Params.toBase() + if err != nil { + return nil, err + } + baseReq := &CreateMessageRequest{ + Session: req.Session, + Params: baseParams, + } + res, err := c.opts.CreateMessageHandler(ctx, baseReq) + if err != nil { + return nil, err + } + return res.toWithTools(), nil + } + return nil, &jsonrpc.Error{Code: codeUnsupportedMethod, Message: "client does not support CreateMessage"} } // urlElicitationMiddleware returns middleware that automatically handles URL elicitation diff --git a/mcp/content.go b/mcp/content.go index cc30d2a..b94076c 100644 --- a/mcp/content.go +++ b/mcp/content.go @@ -13,7 +13,10 @@ import ( ) // A Content is a [TextContent], [ImageContent], [AudioContent], -// [ResourceLink], or [EmbeddedResource]. +// [ResourceLink], [EmbeddedResource], [ToolUseContent], or [ToolResultContent]. +// +// Note: [ToolUseContent] and [ToolResultContent] are only valid in sampling +// message contexts (CreateMessageParams/CreateMessageResult). type Content interface { MarshalJSON() ([]byte, error) fromWire(*wireContent) @@ -182,6 +185,104 @@ func (c *EmbeddedResource) fromWire(wire *wireContent) { c.Annotations = wire.Annotations } +// ToolUseContent represents a request from the assistant to invoke a tool. +// This content type is only valid in sampling messages. +type ToolUseContent struct { + // ID is a unique identifier for this tool use, used to match with ToolResultContent. + ID string + // Name is the name of the tool to invoke. + Name string + // Input contains the tool arguments as a JSON object. + Input map[string]any + Meta Meta +} + +func (c *ToolUseContent) MarshalJSON() ([]byte, error) { + input := c.Input + if input == nil { + input = map[string]any{} + } + wire := struct { + Type string `json:"type"` + ID string `json:"id"` + Name string `json:"name"` + Input map[string]any `json:"input"` + Meta Meta `json:"_meta,omitempty"` + }{ + Type: "tool_use", + ID: c.ID, + Name: c.Name, + Input: input, + Meta: c.Meta, + } + return json.Marshal(wire) +} + +func (c *ToolUseContent) fromWire(wire *wireContent) { + c.ID = wire.ID + c.Name = wire.Name + c.Input = wire.Input + c.Meta = wire.Meta +} + +// ToolResultContent represents the result of a tool invocation. +// This content type is only valid in sampling messages with role "user". +type ToolResultContent struct { + // ToolUseID references the ID from the corresponding ToolUseContent. + ToolUseID string + // Content holds the unstructured result of the tool call. + Content []Content + // StructuredContent holds an optional structured result as a JSON object. + StructuredContent any + // IsError indicates whether the tool call ended in an error. + IsError bool + Meta Meta +} + +func (c *ToolResultContent) MarshalJSON() ([]byte, error) { + // Marshal nested content + var contentWire []*wireContent + for _, content := range c.Content { + data, err := content.MarshalJSON() + if err != nil { + return nil, err + } + var w wireContent + if err := json.Unmarshal(data, &w); err != nil { + return nil, err + } + contentWire = append(contentWire, &w) + } + if contentWire == nil { + contentWire = []*wireContent{} // avoid JSON null + } + + wire := struct { + Type string `json:"type"` + ToolUseID string `json:"toolUseId"` + Content []*wireContent `json:"content"` + StructuredContent any `json:"structuredContent,omitempty"` + IsError bool `json:"isError,omitempty"` + Meta Meta `json:"_meta,omitempty"` + }{ + Type: "tool_result", + ToolUseID: c.ToolUseID, + Content: contentWire, + StructuredContent: c.StructuredContent, + IsError: c.IsError, + Meta: c.Meta, + } + return json.Marshal(wire) +} + +func (c *ToolResultContent) fromWire(wire *wireContent) { + c.ToolUseID = wire.ToolUseID + c.StructuredContent = wire.StructuredContent + c.IsError = wire.IsError + c.Meta = wire.Meta + // Content is handled separately in contentFromWire due to nested content +} + // ResourceContents contains the contents of a specific resource or // sub-resource. type ResourceContents struct { @@ -194,28 +295,55 @@ type ResourceContents struct { // wireContent is the wire format for content. // It represents the protocol types TextContent, ImageContent, AudioContent, -// ResourceLink, and EmbeddedResource. +// ResourceLink, EmbeddedResource, ToolUseContent, and ToolResultContent. // The Type field distinguishes them. In the protocol, each type has a constant // value for the field. -// At most one of Text, Data, Resource, and URI is non-zero. type wireContent struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - MIMEType string `json:"mimeType,omitempty"` - Data []byte `json:"data,omitempty"` - Resource *ResourceContents `json:"resource,omitempty"` - URI string `json:"uri,omitempty"` - Name string `json:"name,omitempty"` - Title string `json:"title,omitempty"` - Description string `json:"description,omitempty"` - Size *int64 `json:"size,omitempty"` - Meta Meta `json:"_meta,omitempty"` - Annotations *Annotations `json:"annotations,omitempty"` - Icons []Icon `json:"icons,omitempty"` + Type string `json:"type"` + Text string `json:"text,omitempty"` // TextContent + MIMEType string `json:"mimeType,omitempty"` // ImageContent, AudioContent, ResourceLink + Data []byte `json:"data,omitempty"` // ImageContent, AudioContent + Resource *ResourceContents `json:"resource,omitempty"` // EmbeddedResource + URI string `json:"uri,omitempty"` // ResourceLink + Name string `json:"name,omitempty"` // ResourceLink, ToolUseContent + Title string `json:"title,omitempty"` // ResourceLink + Description string `json:"description,omitempty"` // ResourceLink + Size *int64 `json:"size,omitempty"` // ResourceLink + Meta Meta `json:"_meta,omitempty"` // all types + Annotations *Annotations `json:"annotations,omitempty"` // all types except ToolUseContent, ToolResultContent + Icons []Icon `json:"icons,omitempty"` // ResourceLink + ID string `json:"id,omitempty"` // ToolUseContent + Input map[string]any `json:"input,omitempty"` // ToolUseContent + ToolUseID string `json:"toolUseId,omitempty"` // ToolResultContent + NestedContent []*wireContent `json:"content,omitempty"` // ToolResultContent + StructuredContent any `json:"structuredContent,omitempty"` // ToolResultContent + IsError bool `json:"isError,omitempty"` // ToolResultContent +} + +// unmarshalContent unmarshals JSON that is either a single content object or +// an array of content objects. A single object is wrapped in a one-element slice. +func unmarshalContent(raw json.RawMessage, allow map[string]bool) ([]Content, error) { + if len(raw) == 0 || string(raw) == "null" { + return nil, fmt.Errorf("nil content") + } + // Try array first, then fall back to single object. + var wires []*wireContent + if err := json.Unmarshal(raw, &wires); err == nil { + return contentsFromWire(wires, allow) + } + var wire wireContent + if err := json.Unmarshal(raw, &wire); err != nil { + return nil, err + } + c, err := contentFromWire(&wire, allow) + if err != nil { + return nil, err + } + return []Content{c}, nil } func contentsFromWire(wires []*wireContent, allow map[string]bool) ([]Content, error) { - var blocks []Content + blocks := make([]Content, 0, len(wires)) for _, wire := range wires { block, err := contentFromWire(wire, allow) if err != nil { @@ -254,6 +382,27 @@ func contentFromWire(wire *wireContent, allow map[string]bool) (Content, error) v := new(EmbeddedResource) v.fromWire(wire) return v, nil + case "tool_use": + v := new(ToolUseContent) + v.fromWire(wire) + return v, nil + case "tool_result": + v := new(ToolResultContent) + v.fromWire(wire) + // Handle nested content - tool_result content can contain text, image, audio, + // resource_link, and resource (same as CallToolResult.content) + if wire.NestedContent != nil { + toolResultContentAllow := map[string]bool{ + "text": true, "image": true, "audio": true, + "resource_link": true, "resource": true, + } + nestedContent, err := contentsFromWire(wire.NestedContent, toolResultContentAllow) + if err != nil { + return nil, fmt.Errorf("tool_result nested content: %w", err) + } + v.Content = nestedContent + } + return v, nil } - return nil, fmt.Errorf("internal error: unrecognized content type %s", wire.Type) + return nil, fmt.Errorf("unrecognized content type %q", wire.Type) } diff --git a/mcp/protocol.go b/mcp/protocol.go index 2ac84de..6e4a190 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -188,7 +188,6 @@ type RootCapabilities struct { // this schema, but this is not a closed set: any client can define its own, // additional capabilities. type ClientCapabilities struct { - // NOTE: any addition to ClientCapabilities must also be reflected in // [ClientCapabilities.clone]. @@ -216,7 +215,12 @@ type ClientCapabilities struct { func (c *ClientCapabilities) clone() *ClientCapabilities { cp := *c cp.RootsV2 = shallowClone(c.RootsV2) - cp.Sampling = shallowClone(c.Sampling) + if c.Sampling != nil { + x := *c.Sampling + x.Tools = shallowClone(c.Sampling.Tools) + x.Context = shallowClone(c.Sampling.Context) + cp.Sampling = &x + } if c.Elicitation != nil { x := *c.Elicitation x.Form = shallowClone(c.Elicitation.Form) @@ -357,6 +361,11 @@ type CreateMessageParams struct { Meta `json:"_meta,omitempty"` // A request to include context from one or more MCP servers (including the // caller), to be attached to the prompt. The client may ignore this request. + // + // The default is "none". Values "thisServer" and + // "allServers" are soft-deprecated. Servers SHOULD only use these values if + // the client declares ClientCapabilities.sampling.context. These values may + // be removed in future spec releases. IncludeContext string `json:"includeContext,omitempty"` // The maximum number of tokens to sample, as requested by the server. The // client may choose to sample fewer tokens than requested. @@ -379,6 +388,106 @@ func (x *CreateMessageParams) isParams() {} func (x *CreateMessageParams) GetProgressToken() any { return getProgressToken(x) } func (x *CreateMessageParams) SetProgressToken(t any) { setProgressToken(x, t) } +// CreateMessageWithToolsParams is a sampling request that includes tools. +// It extends the basic [CreateMessageParams] fields with tools, tool choice, +// and messages that support array content (for parallel tool calls). +// +// Use with [ServerSession.CreateMessageWithTools]. +type CreateMessageWithToolsParams struct { + Meta `json:"_meta,omitempty"` + IncludeContext string `json:"includeContext,omitempty"` + MaxTokens int64 `json:"maxTokens"` + // Messages supports array content for tool_use and tool_result blocks. + Messages []*SamplingMessageV2 `json:"messages"` + Metadata any `json:"metadata,omitempty"` + ModelPreferences *ModelPreferences `json:"modelPreferences,omitempty"` + StopSequences []string `json:"stopSequences,omitempty"` + SystemPrompt string `json:"systemPrompt,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + // Tools is the list of tools available for the model to use. + Tools []*Tool `json:"tools,omitempty"` + // ToolChoice controls how the model should use tools. + ToolChoice *ToolChoice `json:"toolChoice,omitempty"` +} + +func (x *CreateMessageWithToolsParams) isParams() {} +func (x *CreateMessageWithToolsParams) GetProgressToken() any { return getProgressToken(x) } +func (x *CreateMessageWithToolsParams) SetProgressToken(t any) { setProgressToken(x, t) } + +// toBase converts to CreateMessageParams by taking the content block from each +// message. Tools and ToolChoice are dropped. Returns an error if any message +// has multiple content blocks, since SamplingMessage only supports one. +func (p *CreateMessageWithToolsParams) toBase() (*CreateMessageParams, error) { + var msgs []*SamplingMessage + for _, m := range p.Messages { + if len(m.Content) > 1 { + return nil, fmt.Errorf("message has %d content blocks; use CreateMessageWithToolsHandler to support multiple content", len(m.Content)) + } + var content Content + if len(m.Content) > 0 { + content = m.Content[0] + } + msgs = append(msgs, &SamplingMessage{Content: content, Role: m.Role}) + } + return &CreateMessageParams{ + Meta: p.Meta, + IncludeContext: p.IncludeContext, + MaxTokens: p.MaxTokens, + Messages: msgs, + Metadata: p.Metadata, + ModelPreferences: p.ModelPreferences, + StopSequences: p.StopSequences, + SystemPrompt: p.SystemPrompt, + Temperature: p.Temperature, + }, nil +} + +// SamplingMessageV2 describes a message issued to or received from an +// LLM API, supporting array content for parallel tool calls. The "V2" refers +// to the 2025-11-25 spec, which changed content from a single block to +// single-or-array. In v2 of the SDK, this will replace [SamplingMessage]. +// +// When marshaling, a single-element Content slice is marshaled as a single +// object for compatibility with pre-2025-11-25 implementations. When +// unmarshaling, a single JSON content object is accepted and wrapped in a +// one-element slice. +type SamplingMessageV2 struct { + Content []Content `json:"content"` + Role Role `json:"role"` +} + +var samplingWithToolsAllow = map[string]bool{ + "text": true, "image": true, "audio": true, + "tool_use": true, "tool_result": true, +} + +// MarshalJSON marshals the message. A single-element Content slice is marshaled +// as a single object for backward compatibility. +func (m *SamplingMessageV2) MarshalJSON() ([]byte, error) { + if len(m.Content) == 1 { + return json.Marshal(&SamplingMessage{Content: m.Content[0], Role: m.Role}) + } + type msg SamplingMessageV2 // avoid recursion + return json.Marshal((*msg)(m)) +} + +func (m *SamplingMessageV2) UnmarshalJSON(data []byte) error { + type msg SamplingMessageV2 // avoid recursion + var wire struct { + msg + Content json.RawMessage `json:"content"` + } + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + var err error + if wire.msg.Content, err = unmarshalContent(wire.Content, samplingWithToolsAllow); err != nil { + return err + } + *m = SamplingMessageV2(wire.msg) + return nil +} + // The client's response to a sampling/create_message request from the server. // The client should inform the user before returning the sampled message, to // allow them to inspect the response (human in the loop) and decide whether to @@ -392,6 +501,12 @@ type CreateMessageResult struct { Model string `json:"model"` Role Role `json:"role"` // The reason why sampling stopped, if known. + // + // Standard values: + // - "endTurn": natural end of the assistant's turn + // - "stopSequence": a stop sequence was encountered + // - "maxTokens": reached the maximum token limit + // - "toolUse": the model wants to use one or more tools StopReason string `json:"stopReason,omitempty"` } @@ -413,6 +528,84 @@ func (r *CreateMessageResult) UnmarshalJSON(data []byte) error { return nil } +// CreateMessageWithToolsResult is the client's response to a +// sampling/create_message request that included tools. Content is a slice to +// support parallel tool calls (multiple tool_use blocks in one response). +// +// Use [ServerSession.CreateMessageWithTools] to send a sampling request with +// tools and receive this result type. +// +// When unmarshaling, a single JSON content object is accepted and wrapped in a +// one-element slice, for compatibility with clients that return a single block. +type CreateMessageWithToolsResult struct { + Meta `json:"_meta,omitempty"` + Content []Content `json:"content"` + Model string `json:"model"` + Role Role `json:"role"` + // The reason why sampling stopped. + // + // Standard values: "endTurn", "stopSequence", "maxTokens", "toolUse". + StopReason string `json:"stopReason,omitempty"` +} + +// createMessageWithToolsResultAllow lists content types valid in assistant responses. +// tool_result is excluded: it only appears in user messages. +var createMessageWithToolsResultAllow = map[string]bool{ + "text": true, "image": true, "audio": true, + "tool_use": true, +} + +func (*CreateMessageWithToolsResult) isResult() {} + +// MarshalJSON marshals the result. When Content has a single element, it is +// marshaled as a single object for compatibility with pre-2025-11-25 +// implementations that expect a single content block. +func (r *CreateMessageWithToolsResult) MarshalJSON() ([]byte, error) { + if len(r.Content) == 1 { + return json.Marshal(&CreateMessageResult{ + Meta: r.Meta, + Content: r.Content[0], + Model: r.Model, + Role: r.Role, + StopReason: r.StopReason, + }) + } + type result CreateMessageWithToolsResult // avoid recursion + return json.Marshal((*result)(r)) +} + +func (r *CreateMessageWithToolsResult) UnmarshalJSON(data []byte) error { + type result CreateMessageWithToolsResult // avoid recursion + var wire struct { + result + Content json.RawMessage `json:"content"` + } + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + var err error + if wire.result.Content, err = unmarshalContent(wire.Content, createMessageWithToolsResultAllow); err != nil { + return err + } + *r = CreateMessageWithToolsResult(wire.result) + return nil +} + +// toWithTools converts a CreateMessageResult to CreateMessageWithToolsResult. +func (r *CreateMessageResult) toWithTools() *CreateMessageWithToolsResult { + var content []Content + if r.Content != nil { + content = []Content{r.Content} + } + return &CreateMessageWithToolsResult{ + Meta: r.Meta, + Content: content, + Model: r.Model, + Role: r.Role, + StopReason: r.StopReason, + } +} + type GetPromptParams struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. @@ -982,7 +1175,27 @@ func (x *RootsListChangedParams) SetProgressToken(t any) { setProgressToken(x, t // below directly above ClientCapabilities. // SamplingCapabilities describes the client's support for sampling. -type SamplingCapabilities struct{} +type SamplingCapabilities struct { + // Context indicates the client supports includeContext values other than "none". + Context *SamplingContextCapabilities `json:"context,omitempty"` + // Tools indicates the client supports tools and toolChoice in sampling requests. + Tools *SamplingToolsCapabilities `json:"tools,omitempty"` +} + +// SamplingContextCapabilities indicates the client supports context inclusion. +type SamplingContextCapabilities struct{} + +// SamplingToolsCapabilities indicates the client supports tool use in sampling. +type SamplingToolsCapabilities struct{} + +// ToolChoice controls how the model uses tools during sampling. +type ToolChoice struct { + // Mode controls tool invocation behavior: + // - "auto": Model decides whether to use tools (default) + // - "required": Model must use at least one tool + // - "none": Model must not use any tools + Mode string `json:"mode,omitempty"` +} // ElicitationCapabilities describes the capabilities for elicitation. // @@ -993,14 +1206,15 @@ type ElicitationCapabilities struct { } // FormElicitationCapabilities describes capabilities for form elicitation. -type FormElicitationCapabilities struct { -} +type FormElicitationCapabilities struct{} // URLElicitationCapabilities describes capabilities for url elicitation. -type URLElicitationCapabilities struct { -} +type URLElicitationCapabilities struct{} // Describes a message issued to or received from an LLM API. +// +// For assistant messages, Content may be text, image, audio, or tool_use. +// For user messages, Content may be text, image, audio, or tool_result. type SamplingMessage struct { Content Content `json:"content"` Role Role `json:"role"` @@ -1017,8 +1231,9 @@ func (m *SamplingMessage) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(data, &wire); err != nil { return err } + // Allow text, image, audio, tool_use, and tool_result in sampling messages var err error - if wire.msg.Content, err = contentFromWire(wire.Content, map[string]bool{"text": true, "image": true, "audio": true}); err != nil { + if wire.msg.Content, err = contentFromWire(wire.Content, map[string]bool{"text": true, "image": true, "audio": true, "tool_use": true, "tool_result": true}); err != nil { return err } *m = SamplingMessage(wire.msg) @@ -1297,7 +1512,6 @@ type ToolCapabilities struct { // ServerCapabilities describes capabilities that a server supports. type ServerCapabilities struct { - // NOTE: any addition to ServerCapabilities must also be reflected in // [ServerCapabilities.clone]. diff --git a/mcp/protocol_test.go b/mcp/protocol_test.go index 67d021d..b33e5f5 100644 --- a/mcp/protocol_test.go +++ b/mcp/protocol_test.go @@ -7,6 +7,7 @@ package mcp import ( "encoding/json" "maps" + "reflect" "testing" "github.com/google/go-cmp/cmp" @@ -488,6 +489,587 @@ func TestCompleteResult(t *testing.T) { } } +func TestToolUseContent_MarshalJSON(t *testing.T) { + tests := []struct { + name string + content *ToolUseContent + want *ToolUseContent + }{ + { + name: "basic tool use", + content: &ToolUseContent{ + ID: "tool_123", + Name: "calculator", + Input: map[string]any{ + "operation": "add", + "x": 1.0, + "y": 2.0, + }, + }, + want: &ToolUseContent{ + ID: "tool_123", + Name: "calculator", + Input: map[string]any{ + "operation": "add", + "x": 1.0, + "y": 2.0, + }, + }, + }, + { + name: "nil input marshals as empty object", + content: &ToolUseContent{ + ID: "tool_456", + Name: "no_args_tool", + Input: nil, + }, + want: &ToolUseContent{ + ID: "tool_456", + Name: "no_args_tool", + Input: map[string]any{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := tt.content.MarshalJSON() + if err != nil { + t.Fatalf("MarshalJSON() error = %v", err) + } + wire := &wireContent{} + if err := json.Unmarshal(data, wire); err != nil { + t.Fatalf("Unmarshal wire error = %v", err) + } + got, err := contentFromWire(wire, map[string]bool{"tool_use": true}) + if err != nil { + t.Fatalf("contentFromWire() error = %v", err) + } + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestToolUseContent_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + json string + want *ToolUseContent + }{ + { + name: "basic tool use", + json: `{"type":"tool_use","id":"tool_123","name":"calculator","input":{"x":1,"y":2}}`, + want: &ToolUseContent{ + ID: "tool_123", + Name: "calculator", + Input: map[string]any{ + "x": 1.0, + "y": 2.0, + }, + }, + }, + { + name: "with meta", + json: `{"type":"tool_use","id":"t1","name":"calc","input":{"x":1},"_meta":{"requestId":"req-123"}}`, + want: &ToolUseContent{ + ID: "t1", + Name: "calc", + Input: map[string]any{"x": 1.0}, + Meta: Meta{"requestId": "req-123"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wire := &wireContent{} + if err := json.Unmarshal([]byte(tt.json), wire); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + got, err := contentFromWire(wire, map[string]bool{"tool_use": true}) + if err != nil { + t.Fatalf("contentFromWire() error = %v", err) + } + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestToolResultContent_MarshalJSON(t *testing.T) { + tests := []struct { + name string + content *ToolResultContent + want string + }{ + { + name: "basic tool result", + content: &ToolResultContent{ + ToolUseID: "tool_123", + Content: []Content{&TextContent{Text: "42"}}, + }, + want: `{"type":"tool_result","toolUseId":"tool_123","content":[{"type":"text","text":"42"}]}`, + }, + { + name: "tool result with error", + content: &ToolResultContent{ + ToolUseID: "tool_456", + Content: []Content{&TextContent{Text: "division by zero"}}, + IsError: true, + }, + want: `{"type":"tool_result","toolUseId":"tool_456","content":[{"type":"text","text":"division by zero"}],"isError":true}`, + }, + { + name: "tool result with structured content", + content: &ToolResultContent{ + ToolUseID: "tool_789", + Content: []Content{&TextContent{Text: `{"result": 42}`}}, + StructuredContent: map[string]any{"result": 42.0}, + }, + want: `{"type":"tool_result","toolUseId":"tool_789","content":[{"type":"text","text":"{\"result\": 42}"}],"structuredContent":{"result":42}}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := tt.content.MarshalJSON() + if err != nil { + t.Fatalf("MarshalJSON() error = %v", err) + } + if diff := cmp.Diff(tt.want, string(data)); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestToolResultContent_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + json string + wantID string + wantErr bool + checkFn func(t *testing.T, got *ToolResultContent) + }{ + { + name: "basic tool result", + json: `{"type":"tool_result","toolUseId":"tool_123","content":[{"type":"text","text":"42"}],"isError":false}`, + wantID: "tool_123", + checkFn: func(t *testing.T, got *ToolResultContent) { + if len(got.Content) != 1 { + t.Fatalf("len(Content) = %d, want 1", len(got.Content)) + } + tc, ok := got.Content[0].(*TextContent) + if !ok { + t.Fatalf("Content[0] type = %T, want *TextContent", got.Content[0]) + } + if tc.Text != "42" { + t.Errorf("Text = %v, want 42", tc.Text) + } + }, + }, + { + name: "image nested content", + json: `{"type":"tool_result","toolUseId":"t1","content":[{"type":"image","mimeType":"image/png","data":"YWJj"}]}`, + wantID: "t1", + checkFn: func(t *testing.T, got *ToolResultContent) { + if len(got.Content) != 1 { + t.Fatalf("len(Content) = %d, want 1", len(got.Content)) + } + img, ok := got.Content[0].(*ImageContent) + if !ok { + t.Fatalf("Content[0] type = %T, want *ImageContent", got.Content[0]) + } + if img.MIMEType != "image/png" { + t.Errorf("MIMEType = %v, want image/png", img.MIMEType) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wire := &wireContent{} + if err := json.Unmarshal([]byte(tt.json), wire); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + got, err := contentFromWire(wire, map[string]bool{"tool_result": true}) + if (err != nil) != tt.wantErr { + t.Fatalf("contentFromWire() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + result, ok := got.(*ToolResultContent) + if !ok { + t.Fatalf("type = %T, want *ToolResultContent", got) + } + if result.ToolUseID != tt.wantID { + t.Errorf("ToolUseID = %v, want %v", result.ToolUseID, tt.wantID) + } + if tt.checkFn != nil { + tt.checkFn(t, result) + } + }) + } +} + +func TestSamplingMessage_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + json string + wantRole Role + wantType string // expected Content type name + }{ + { + name: "tool_use content", + json: `{"content":{"type":"tool_use","id":"tool_1","name":"calc","input":{}},"role":"assistant"}`, + wantRole: "assistant", + wantType: "*mcp.ToolUseContent", + }, + { + name: "tool_result content", + json: `{"content":{"type":"tool_result","toolUseId":"tool_1","content":[{"type":"text","text":"42"}]},"role":"user"}`, + wantRole: "user", + wantType: "*mcp.ToolResultContent", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var msg SamplingMessage + if err := json.Unmarshal([]byte(tt.json), &msg); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if msg.Role != tt.wantRole { + t.Errorf("Role = %v, want %v", msg.Role, tt.wantRole) + } + gotType := reflect.TypeOf(msg.Content).String() + if gotType != tt.wantType { + t.Errorf("Content type = %v, want %v", gotType, tt.wantType) + } + }) + } +} + +func TestSamplingCapabilities_MarshalJSON(t *testing.T) { + caps := &SamplingCapabilities{ + Tools: &SamplingToolsCapabilities{}, + Context: &SamplingContextCapabilities{}, + } + data, err := json.Marshal(caps) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + var got SamplingCapabilities + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if got.Tools == nil { + t.Error("Tools capability should not be nil") + } + if got.Context == nil { + t.Error("Context capability should not be nil") + } +} + +func TestSamplingCapabilities_UnmarshalJSON(t *testing.T) { + // Empty struct should marshal/unmarshal correctly (backward compatibility). + caps := &SamplingCapabilities{} + data, err := json.Marshal(caps) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + var got SamplingCapabilities + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if got.Tools != nil { + t.Error("Tools capability should be nil for empty capabilities") + } + if got.Context != nil { + t.Error("Context capability should be nil for empty capabilities") + } +} + +func TestCreateMessageWithToolsParams_MarshalUnmarshalJSON(t *testing.T) { + params := &CreateMessageWithToolsParams{ + MaxTokens: 1000, + Messages: []*SamplingMessageV2{ + { + Role: "user", + Content: []Content{&TextContent{Text: "Calculate 1+1"}}, + }, + }, + Tools: []*Tool{ + { + Name: "calculator", + Description: "A calculator tool", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "x": map[string]any{"type": "number"}, + "y": map[string]any{"type": "number"}, + }, + }, + }, + }, + ToolChoice: &ToolChoice{Mode: "auto"}, + } + + data, err := json.Marshal(params) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + var got CreateMessageWithToolsParams + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if len(got.Tools) != 1 { + t.Fatalf("len(Tools) = %v, want 1", len(got.Tools)) + } + if got.Tools[0].Name != "calculator" { + t.Errorf("Tools[0].Name = %v, want calculator", got.Tools[0].Name) + } + if got.ToolChoice == nil || got.ToolChoice.Mode != "auto" { + t.Errorf("ToolChoice = %v, want {Mode: auto}", got.ToolChoice) + } +} + +func TestToolChoice_MarshalUnmarshalJSON(t *testing.T) { + choices := []*ToolChoice{ + {Mode: "auto"}, + {Mode: "required"}, + {Mode: "none"}, + } + + for _, tc := range choices { + data, err := json.Marshal(tc) + if err != nil { + t.Fatalf("Marshal(%v) error = %v", tc.Mode, err) + } + var got ToolChoice + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if diff := cmp.Diff(*tc, got); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + } +} + +func TestCreateMessageWithToolsResult_MarshalJSON(t *testing.T) { + // Single-element Content marshals as object (not array) for backward compat. + result := &CreateMessageWithToolsResult{ + Model: "test", + Role: "assistant", + Content: []Content{&TextContent{Text: "hello"}}, + } + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("Unmarshal raw error = %v", err) + } + + content := raw["content"] + for _, b := range content { + if b == ' ' || b == '\t' || b == '\n' || b == '\r' { + continue + } + if b == '[' { + t.Errorf("single-element Content marshaled as array, want object") + } + break + } +} + +func TestCreateMessageWithToolsResult_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + json string + wantLen int + wantErr bool + }{ + { + name: "single tool_use content", + json: `{"content":{"type":"tool_use","id":"tool_1","name":"calculator","input":{"x":1}},"model":"test-model","role":"assistant","stopReason":"toolUse"}`, + wantLen: 1, + }, + { + name: "array of tool_use content", + json: `{"content":[{"type":"tool_use","id":"t1","name":"calc","input":{"x":1}},{"type":"tool_use","id":"t2","name":"search","input":{"q":"hi"}}],"model":"test","role":"assistant","stopReason":"toolUse"}`, + wantLen: 2, + }, + { + name: "empty array", + json: `{"content":[],"model":"m","role":"assistant"}`, + wantLen: 0, + }, + { + name: "null content", + json: `{"content":null,"model":"m","role":"assistant"}`, + wantErr: true, + }, + { + name: "rejects tool_result", + json: `{"content":{"type":"tool_result","toolUseId":"t1","content":[]},"model":"m","role":"assistant"}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got CreateMessageWithToolsResult + err := json.Unmarshal([]byte(tt.json), &got) + if (err != nil) != tt.wantErr { + t.Fatalf("Unmarshal() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if len(got.Content) != tt.wantLen { + t.Errorf("len(Content) = %d, want %d", len(got.Content), tt.wantLen) + } + }) + } +} + +func TestSamplingMessageV2_MarshalJSON(t *testing.T) { + msg := &SamplingMessageV2{ + Role: "user", + Content: []Content{}, + } + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + var got SamplingMessageV2 + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(got.Content) != 0 { + t.Errorf("len(Content) = %d, want 0", len(got.Content)) + } +} + +func TestSamplingMessageV2_UnmarshalJSON(t *testing.T) { + // Text + tool_use in the same message (valid per spec for assistant). + msg := &SamplingMessageV2{ + Role: "assistant", + Content: []Content{ + &TextContent{Text: "Let me check the weather."}, + &ToolUseContent{ID: "c1", Name: "weather", Input: map[string]any{"city": "SF"}}, + }, + } + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + var got SamplingMessageV2 + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(got.Content) != 2 { + t.Fatalf("len(Content) = %d, want 2", len(got.Content)) + } + if _, ok := got.Content[0].(*TextContent); !ok { + t.Errorf("Content[0] type = %T, want *TextContent", got.Content[0]) + } + if _, ok := got.Content[1].(*ToolUseContent); !ok { + t.Errorf("Content[1] type = %T, want *ToolUseContent", got.Content[1]) + } +} + +func TestToBase_Conversion(t *testing.T) { + // Single-content messages convert successfully. + params := &CreateMessageWithToolsParams{ + MaxTokens: 1000, + Messages: []*SamplingMessageV2{ + {Role: "user", Content: []Content{&TextContent{Text: "hello"}}}, + }, + Tools: []*Tool{{Name: "calc"}}, + ToolChoice: &ToolChoice{Mode: "auto"}, + } + base, err := params.toBase() + if err != nil { + t.Fatalf("toBase() error = %v", err) + } + if base.MaxTokens != 1000 { + t.Errorf("MaxTokens = %d, want 1000", base.MaxTokens) + } + if tc, ok := base.Messages[0].Content.(*TextContent); !ok || tc.Text != "hello" { + t.Errorf("Messages[0].Content = %v, want TextContent{hello}", base.Messages[0].Content) + } + + // Multi-content messages return an error. + params2 := &CreateMessageWithToolsParams{ + MaxTokens: 1000, + Messages: []*SamplingMessageV2{ + {Role: "assistant", Content: []Content{ + &ToolUseContent{ID: "c1", Name: "calc", Input: map[string]any{}}, + &ToolUseContent{ID: "c2", Name: "search", Input: map[string]any{}}, + }}, + }, + } + if _, err := params2.toBase(); err == nil { + t.Error("toBase() should return error for multi-content message") + } +} + +func TestToWithTools_Conversion(t *testing.T) { + tests := []struct { + name string + result *CreateMessageResult + wantLen int + }{ + { + name: "with content", + result: &CreateMessageResult{ + Model: "test", + Role: "assistant", + Content: &TextContent{Text: "hello"}, + StopReason: "endTurn", + }, + wantLen: 1, + }, + { + name: "nil content", + result: &CreateMessageResult{ + Model: "test", + Role: "assistant", + }, + wantLen: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wt := tt.result.toWithTools() + if wt.Model != tt.result.Model { + t.Errorf("Model = %v, want %v", wt.Model, tt.result.Model) + } + if len(wt.Content) != tt.wantLen { + t.Fatalf("len(Content) = %d, want %d", len(wt.Content), tt.wantLen) + } + if tt.wantLen > 0 { + if tc, ok := wt.Content[0].(*TextContent); !ok || tc.Text != "hello" { + t.Errorf("Content[0] = %v, want TextContent{hello}", wt.Content[0]) + } + } + }) + } +} + func TestContentUnmarshal(t *testing.T) { // Verify that types with a Content field round-trip properly. roundtrip := func(in, out any) { diff --git a/mcp/requests.go b/mcp/requests.go index f64d6fb..4280941 100644 --- a/mcp/requests.go +++ b/mcp/requests.go @@ -24,6 +24,7 @@ type ( type ( CreateMessageRequest = ClientRequest[*CreateMessageParams] + CreateMessageWithToolsRequest = ClientRequest[*CreateMessageWithToolsParams] ElicitRequest = ClientRequest[*ElicitParams] initializedClientRequest = ClientRequest[*InitializedParams] InitializeRequest = ClientRequest[*InitializeParams] diff --git a/mcp/sampling_test.go b/mcp/sampling_test.go new file mode 100644 index 0000000..61afce4 --- /dev/null +++ b/mcp/sampling_test.go @@ -0,0 +1,546 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +// TODO: move other sampling-related tests to this file. + +import ( + "context" + "strings" + "testing" +) + +func TestSamplingWithTools_Integration(t *testing.T) { + ctx := context.Background() + ct, st := NewInMemoryTransports() + + // Track what the client received + var receivedParams *CreateMessageWithToolsParams + + // Client with tools capability, using CreateMessageWithToolsHandler + client := NewClient(testImpl, &ClientOptions{ + CreateMessageWithToolsHandler: func(_ context.Context, req *CreateMessageWithToolsRequest) (*CreateMessageWithToolsResult, error) { + receivedParams = req.Params + // Return a tool use response + return &CreateMessageWithToolsResult{ + Model: "test-model", + Role: "assistant", + Content: []Content{&ToolUseContent{ + ID: "tool_call_1", + Name: "calculator", + Input: map[string]any{"x": 1.0, "y": 2.0}, + }}, + StopReason: "toolUse", + }, nil + }, + Capabilities: &ClientCapabilities{ + Sampling: &SamplingCapabilities{Tools: &SamplingToolsCapabilities{}}, + }, + }) + + server := NewServer(testImpl, nil) + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + cs, err := client.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + // Server sends CreateMessageWithTools + result, err := ss.CreateMessageWithTools(ctx, &CreateMessageWithToolsParams{ + MaxTokens: 1000, + Messages: []*SamplingMessageV2{ + {Role: "user", Content: []Content{&TextContent{Text: "Calculate 1+2"}}}, + }, + Tools: []*Tool{ + { + Name: "calculator", + Description: "A calculator", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "x": map[string]any{"type": "number"}, + "y": map[string]any{"type": "number"}, + }, + }, + }, + }, + ToolChoice: &ToolChoice{Mode: "auto"}, + }) + if err != nil { + t.Fatalf("CreateMessageWithTools() error = %v", err) + } + + // Verify client received the tools + if receivedParams == nil { + t.Fatal("client did not receive params") + } + if len(receivedParams.Tools) != 1 { + t.Errorf("client received %d tools, want 1", len(receivedParams.Tools)) + } + if receivedParams.Tools[0].Name != "calculator" { + t.Errorf("tool name = %v, want calculator", receivedParams.Tools[0].Name) + } + if receivedParams.ToolChoice == nil || receivedParams.ToolChoice.Mode != "auto" { + t.Errorf("tool choice mode = %v, want auto", receivedParams.ToolChoice) + } + + // Verify server received the tool use response + if result.StopReason != "toolUse" { + t.Errorf("StopReason = %v, want toolUse", result.StopReason) + } + if len(result.Content) != 1 { + t.Fatalf("len(Content) = %d, want 1", len(result.Content)) + } + toolUse, ok := result.Content[0].(*ToolUseContent) + if !ok { + t.Fatalf("Content[0] type = %T, want *ToolUseContent", result.Content[0]) + } + if toolUse.ID != "tool_call_1" { + t.Errorf("ToolUse.ID = %v, want tool_call_1", toolUse.ID) + } + if toolUse.Name != "calculator" { + t.Errorf("ToolUse.Name = %v, want calculator", toolUse.Name) + } +} + +func TestSamplingWithToolResult_Integration(t *testing.T) { + ctx := context.Background() + ct, st := NewInMemoryTransports() + + // Track messages received by client + var receivedMessages []*SamplingMessage + + client := NewClient(testImpl, &ClientOptions{ + CreateMessageHandler: func(_ context.Context, req *CreateMessageRequest) (*CreateMessageResult, error) { + receivedMessages = req.Params.Messages + return &CreateMessageResult{ + Model: "test-model", + Role: "assistant", + Content: &TextContent{Text: "The result is 3"}, + }, nil + }, + Capabilities: &ClientCapabilities{ + Sampling: &SamplingCapabilities{Tools: &SamplingToolsCapabilities{}}, + }, + }) + + server := NewServer(testImpl, nil) + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + cs, err := client.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + // Server sends CreateMessage with tool result in messages + _, err = ss.CreateMessage(ctx, &CreateMessageParams{ + MaxTokens: 1000, + Messages: []*SamplingMessage{ + {Role: "user", Content: &TextContent{Text: "Calculate 1+2"}}, + {Role: "assistant", Content: &ToolUseContent{ + ID: "tool_1", + Name: "calculator", + Input: map[string]any{"x": 1.0, "y": 2.0}, + }}, + {Role: "user", Content: &ToolResultContent{ + ToolUseID: "tool_1", + Content: []Content{&TextContent{Text: "3"}}, + }}, + }, + }) + if err != nil { + t.Fatalf("CreateMessage() error = %v", err) + } + + // Verify client received all messages including tool content + if len(receivedMessages) != 3 { + t.Fatalf("received %d messages, want 3", len(receivedMessages)) + } + + // Check first message is text + if _, ok := receivedMessages[0].Content.(*TextContent); !ok { + t.Errorf("message[0] content type = %T, want *TextContent", receivedMessages[0].Content) + } + + // Check second message is tool use + toolUse, ok := receivedMessages[1].Content.(*ToolUseContent) + if !ok { + t.Fatalf("message[1] content type = %T, want *ToolUseContent", receivedMessages[1].Content) + } + if toolUse.ID != "tool_1" { + t.Errorf("toolUse.ID = %v, want tool_1", toolUse.ID) + } + + // Check third message is tool result + toolResult, ok := receivedMessages[2].Content.(*ToolResultContent) + if !ok { + t.Fatalf("message[2] content type = %T, want *ToolResultContent", receivedMessages[2].Content) + } + if toolResult.ToolUseID != "tool_1" { + t.Errorf("toolResult.ToolUseID = %v, want tool_1", toolResult.ToolUseID) + } + if len(toolResult.Content) != 1 { + t.Fatalf("toolResult.Content len = %d, want 1", len(toolResult.Content)) + } + if tc, ok := toolResult.Content[0].(*TextContent); !ok || tc.Text != "3" { + t.Errorf("toolResult.Content[0] = %v, want TextContent with '3'", toolResult.Content[0]) + } +} + +func TestSamplingToolsCapabilities(t *testing.T) { + ctx := context.Background() + + t.Run("client with explicit tools capability", func(t *testing.T) { + ct, st := NewInMemoryTransports() + + client := NewClient(testImpl, &ClientOptions{ + CreateMessageHandler: func(_ context.Context, _ *CreateMessageRequest) (*CreateMessageResult, error) { + return &CreateMessageResult{Model: "m", Content: &TextContent{}}, nil + }, + Capabilities: &ClientCapabilities{ + Sampling: &SamplingCapabilities{ + Tools: &SamplingToolsCapabilities{}, + Context: &SamplingContextCapabilities{}, + }, + }, + }) + + server := NewServer(testImpl, nil) + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + cs, err := client.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + // Check server sees client capabilities + caps := ss.InitializeParams().Capabilities + if caps.Sampling == nil { + t.Fatal("client should advertise sampling capability") + } + if caps.Sampling.Tools == nil { + t.Error("client should advertise sampling.tools capability") + } + if caps.Sampling.Context == nil { + t.Error("client should advertise sampling.context capability") + } + }) + + t.Run("client without tools capability", func(t *testing.T) { + ct, st := NewInMemoryTransports() + + client := NewClient(testImpl, &ClientOptions{ + CreateMessageHandler: func(_ context.Context, _ *CreateMessageRequest) (*CreateMessageResult, error) { + return &CreateMessageResult{Model: "m", Content: &TextContent{}}, nil + }, + // No Capabilities.Sampling.Tools set + }) + + server := NewServer(testImpl, nil) + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + cs, err := client.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + // Check server sees client capabilities + caps := ss.InitializeParams().Capabilities + if caps.Sampling == nil { + t.Fatal("client should advertise sampling capability") + } + if caps.Sampling.Tools != nil { + t.Error("client should NOT advertise sampling.tools capability") + } + if caps.Sampling.Context != nil { + t.Error("client should NOT advertise sampling.context capability") + } + }) + + t.Run("client with inferred tools capability", func(t *testing.T) { + ct, st := NewInMemoryTransports() + + client := NewClient(testImpl, &ClientOptions{ + CreateMessageWithToolsHandler: func(_ context.Context, _ *CreateMessageWithToolsRequest) (*CreateMessageWithToolsResult, error) { + return &CreateMessageWithToolsResult{Model: "m", Content: []Content{&TextContent{}}}, nil + }, + // No explicit Capabilities set — tools should be inferred. + }) + + server := NewServer(testImpl, nil) + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + cs, err := client.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + caps := ss.InitializeParams().Capabilities + if caps.Sampling == nil { + t.Fatal("client should advertise sampling capability") + } + if caps.Sampling.Tools == nil { + t.Error("client should infer sampling.tools capability from CreateMessageWithToolsHandler") + } + }) +} + +func TestSamplingToolResultWithError_Integration(t *testing.T) { + ctx := context.Background() + ct, st := NewInMemoryTransports() + + var receivedMessages []*SamplingMessage + + client := NewClient(testImpl, &ClientOptions{ + CreateMessageHandler: func(_ context.Context, req *CreateMessageRequest) (*CreateMessageResult, error) { + receivedMessages = req.Params.Messages + return &CreateMessageResult{ + Model: "test-model", + Role: "assistant", + Content: &TextContent{Text: "I see the tool failed"}, + }, nil + }, + Capabilities: &ClientCapabilities{ + Sampling: &SamplingCapabilities{Tools: &SamplingToolsCapabilities{}}, + }, + }) + + server := NewServer(testImpl, nil) + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + cs, err := client.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + // Server sends CreateMessage with error tool result, preceded by + // the original request and tool use for a more realistic scenario. + _, err = ss.CreateMessage(ctx, &CreateMessageParams{ + MaxTokens: 1000, + Messages: []*SamplingMessage{ + {Role: "user", Content: &TextContent{Text: "Divide 1 by 0"}}, + {Role: "assistant", Content: &ToolUseContent{ + ID: "tool_1", + Name: "calculator", + Input: map[string]any{"op": "div", "x": 1.0, "y": 0.0}, + }}, + {Role: "user", Content: &ToolResultContent{ + ToolUseID: "tool_1", + Content: []Content{&TextContent{Text: "division by zero"}}, + IsError: true, + }}, + }, + }) + if err != nil { + t.Fatalf("CreateMessage() error = %v", err) + } + + if len(receivedMessages) != 3 { + t.Fatalf("received %d messages, want 3", len(receivedMessages)) + } + + toolResult, ok := receivedMessages[2].Content.(*ToolResultContent) + if !ok { + t.Fatalf("content type = %T, want *ToolResultContent", receivedMessages[2].Content) + } + if !toolResult.IsError { + t.Error("IsError should be true") + } + if toolResult.ToolUseID != "tool_1" { + t.Errorf("ToolUseID = %v, want tool_1", toolResult.ToolUseID) + } +} + +func TestParallelToolCalls_Integration(t *testing.T) { + ctx := context.Background() + ct, st := NewInMemoryTransports() + + // Client returns parallel tool use results + client := NewClient(testImpl, &ClientOptions{ + CreateMessageWithToolsHandler: func(_ context.Context, req *CreateMessageWithToolsRequest) (*CreateMessageWithToolsResult, error) { + return &CreateMessageWithToolsResult{ + Model: "test-model", + Role: "assistant", + Content: []Content{ + &ToolUseContent{ID: "call_1", Name: "weather", Input: map[string]any{"city": "SF"}}, + &ToolUseContent{ID: "call_2", Name: "weather", Input: map[string]any{"city": "NY"}}, + }, + StopReason: "toolUse", + }, nil + }, + Capabilities: &ClientCapabilities{ + Sampling: &SamplingCapabilities{Tools: &SamplingToolsCapabilities{}}, + }, + }) + + server := NewServer(testImpl, nil) + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + cs, err := client.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + result, err := ss.CreateMessageWithTools(ctx, &CreateMessageWithToolsParams{ + MaxTokens: 1000, + Messages: []*SamplingMessageV2{ + {Role: "user", Content: []Content{&TextContent{Text: "Weather in SF and NY"}}}, + }, + Tools: []*Tool{ + {Name: "weather", InputSchema: map[string]any{"type": "object"}}, + }, + }) + if err != nil { + t.Fatalf("CreateMessageWithTools() error = %v", err) + } + + if len(result.Content) != 2 { + t.Fatalf("len(Content) = %d, want 2", len(result.Content)) + } + for i, c := range result.Content { + tu, ok := c.(*ToolUseContent) + if !ok { + t.Fatalf("Content[%d] type = %T, want *ToolUseContent", i, c) + } + if tu.Name != "weather" { + t.Errorf("Content[%d].Name = %v, want weather", i, tu.Name) + } + } + if result.Content[0].(*ToolUseContent).ID != "call_1" { + t.Errorf("Content[0].ID = %v, want call_1", result.Content[0].(*ToolUseContent).ID) + } + if result.Content[1].(*ToolUseContent).ID != "call_2" { + t.Errorf("Content[1].ID = %v, want call_2", result.Content[1].(*ToolUseContent).ID) + } +} + +func TestNewClient_BothHandlersPanics(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic when both handlers set") + } + msg, ok := r.(string) + if !ok || !strings.Contains(msg, "CreateMessageHandler") { + t.Errorf("unexpected panic: %v", r) + } + }() + NewClient(testImpl, &ClientOptions{ + CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) { + return nil, nil + }, + CreateMessageWithToolsHandler: func(context.Context, *CreateMessageWithToolsRequest) (*CreateMessageWithToolsResult, error) { + return nil, nil + }, + }) +} + +func TestCreateMessage_MultipleContentError(t *testing.T) { + ctx := context.Background() + ct, st := NewInMemoryTransports() + + // Client returns multiple content blocks via CreateMessageWithToolsHandler + client := NewClient(testImpl, &ClientOptions{ + CreateMessageWithToolsHandler: func(_ context.Context, _ *CreateMessageWithToolsRequest) (*CreateMessageWithToolsResult, error) { + return &CreateMessageWithToolsResult{ + Model: "test", + Role: "assistant", + Content: []Content{ + &TextContent{Text: "a"}, + &TextContent{Text: "b"}, + }, + }, nil + }, + }) + + server := NewServer(testImpl, nil) + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + cs, err := client.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + // Server calls CreateMessage (singular), should get error + _, err = ss.CreateMessage(ctx, &CreateMessageParams{ + MaxTokens: 100, + Messages: []*SamplingMessage{{Role: "user", Content: &TextContent{Text: "hi"}}}, + }) + if err == nil { + t.Fatal("expected error for multiple content blocks") + } + if !strings.Contains(err.Error(), "CreateMessageWithTools") { + t.Errorf("error should mention CreateMessageWithTools, got: %v", err) + } +} + +func TestClientCapabilities_CloneSampling(t *testing.T) { + caps := &ClientCapabilities{ + Sampling: &SamplingCapabilities{ + Tools: &SamplingToolsCapabilities{}, + Context: &SamplingContextCapabilities{}, + }, + } + cloned := caps.clone() + + // Verify deep copy — Sampling pointer should differ. + // (Tools and Context are empty structs, so Go may reuse the same address; + // we just check they're non-nil and that mutating Sampling doesn't alias.) + if cloned.Sampling == caps.Sampling { + t.Error("Sampling pointer should differ after clone") + } + if cloned.Sampling.Tools == nil { + t.Error("cloned Sampling.Tools should not be nil") + } + if cloned.Sampling.Context == nil { + t.Error("cloned Sampling.Context should not be nil") + } + // Verify mutation doesn't affect original. + cloned.Sampling.Tools = nil + if caps.Sampling.Tools == nil { + t.Error("modifying cloned Sampling.Tools should not affect original") + } +} diff --git a/mcp/server.go b/mcp/server.go index 930410e..f4e6a94 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1163,6 +1163,10 @@ func (ss *ServerSession) ListRoots(ctx context.Context, params *ListRootsParams) } // CreateMessage sends a sampling request to the client. +// +// If the client returns multiple content blocks (e.g. parallel tool calls), +// CreateMessage returns an error. Use [ServerSession.CreateMessageWithTools] +// for tool-enabled sampling. func (ss *ServerSession) CreateMessage(ctx context.Context, params *CreateMessageParams) (*CreateMessageResult, error) { if err := ss.checkInitialized(methodCreateMessage); err != nil { return nil, err @@ -1175,7 +1179,44 @@ func (ss *ServerSession) CreateMessage(ctx context.Context, params *CreateMessag p2.Messages = []*SamplingMessage{} // avoid JSON "null" params = &p2 } - return handleSend[*CreateMessageResult](ctx, methodCreateMessage, newServerRequest(ss, orZero[Params](params))) + res, err := handleSend[*CreateMessageWithToolsResult](ctx, methodCreateMessage, newServerRequest(ss, orZero[Params](params))) + if err != nil { + return nil, err + } + // Downconvert to singular content. + if len(res.Content) > 1 { + return nil, fmt.Errorf("CreateMessage result has %d content blocks; use CreateMessageWithTools for multiple content", len(res.Content)) + } + var content Content + if len(res.Content) > 0 { + content = res.Content[0] + } + return &CreateMessageResult{ + Meta: res.Meta, + Content: content, + Model: res.Model, + Role: res.Role, + StopReason: res.StopReason, + }, nil +} + +// CreateMessageWithTools sends a sampling request with tools to the client, +// returning a [CreateMessageWithToolsResult] that supports array content +// (for parallel tool calls). Use this instead of [ServerSession.CreateMessage] +// when the request includes tools. +func (ss *ServerSession) CreateMessageWithTools(ctx context.Context, params *CreateMessageWithToolsParams) (*CreateMessageWithToolsResult, error) { + if err := ss.checkInitialized(methodCreateMessage); err != nil { + return nil, err + } + if params == nil { + params = &CreateMessageWithToolsParams{Messages: []*SamplingMessageV2{}} + } + if params.Messages == nil { + p2 := *params + p2.Messages = []*SamplingMessageV2{} // avoid JSON "null" + params = &p2 + } + return handleSend[*CreateMessageWithToolsResult](ctx, methodCreateMessage, newServerRequest(ss, orZero[Params](params))) } // Elicit sends an elicitation request to the client asking for user input. From d3aeecfdbc9ceaddef101fa9c0e2046c236b9dcd Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Tue, 17 Feb 2026 11:57:23 +0100 Subject: [PATCH 195/418] chore: add an issue template for enhancements --- .github/ISSUE_TEMPLATE/enhancement.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/enhancement.md diff --git a/.github/ISSUE_TEMPLATE/enhancement.md b/.github/ISSUE_TEMPLATE/enhancement.md new file mode 100644 index 0000000..d2ecf10 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/enhancement.md @@ -0,0 +1,21 @@ +--- +name: Enhancement +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' +type: Enhancement + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. From 8b526cdb2ebf177c44b4b89ead50d98554ab2347 Mon Sep 17 00:00:00 2001 From: Maciek Kisiel Date: Tue, 17 Feb 2026 13:22:51 +0000 Subject: [PATCH 196/418] chore: setup dependabot to update github actions. --- .github/dependabot.yml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..262a757 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: +# Check for updates to GitHub Actions every month. +- package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" From 40475813dd71004002cff8801edb1e22a71b4625 Mon Sep 17 00:00:00 2001 From: Maciek Kisiel Date: Mon, 16 Feb 2026 10:55:54 +0000 Subject: [PATCH 197/418] mcp: fix multi-select enum elicitation --- mcp/client.go | 62 ++++++++++++++++++++- mcp/mcp_test.go | 142 +++++++++++++++++++++++++++++++++++------------- 2 files changed, 165 insertions(+), 39 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index 96f1f82..bc81c1e 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -710,8 +710,10 @@ func validateElicitProperty(propName string, propSchema *jsonschema.Schema) erro return validateElicitNumberProperty(propName, propSchema) case "boolean": return validateElicitBooleanProperty(propName, propSchema) + case "array": + return validateElicitArrayProperty(propName, propSchema) default: - return fmt.Errorf("elicit schema property %q has unsupported type %q, only string, number, integer, and boolean are allowed", propName, propSchema.Type) + return fmt.Errorf("elicit schema property %q has unsupported type %q, only string, number, integer, boolean, and array are allowed", propName, propSchema.Type) } } @@ -724,7 +726,7 @@ func validateElicitStringProperty(propName string, propSchema *jsonschema.Schema return fmt.Errorf("elicit schema property %q has enum values but type is %q, enums are only supported for string type", propName, propSchema.Type) } // Enum values themselves are validated by the JSON schema library - // Validate enumNames if present - must match enum length + // Validate legacy enumNames if present - must match enum length. if propSchema.Extra != nil { if enumNamesRaw, exists := propSchema.Extra["enumNames"]; exists { // Type check enumNames - should be a slice @@ -739,6 +741,15 @@ func validateElicitStringProperty(propName string, propSchema *jsonschema.Schema } return nil } + // Handle new style of titled enums. + if propSchema.OneOf != nil { + for _, entry := range propSchema.OneOf { + if err := validateTitledEnumEntry(entry); err != nil { + return fmt.Errorf("elicit schema property %q oneOf has invalid entry: %v", propName, err) + } + } + return nil + } // Validate format if specified - only specific formats are allowed if propSchema.Format != "" { @@ -791,6 +802,53 @@ func validateElicitNumberProperty(propName string, propSchema *jsonschema.Schema return nil } +// validateElicitArrayProperty validates multi-select enum properties. +func validateElicitArrayProperty(propName string, propSchema *jsonschema.Schema) error { + if propSchema.Items == nil { + return fmt.Errorf("elicit schema property %q is array but missing 'items' definition", propName) + } + + items := propSchema.Items + switch items.Type { + case "string": + // Untitled enums. + if items.Enum == nil { + return fmt.Errorf("elicit schema property %q items must specify enum for untitled enums", propName) + } + return nil + case "": + // Titled enums. + if len(items.AnyOf) == 0 { + return fmt.Errorf("elicit schema property %q items must specify anyOf for titled enums", propName) + } + for _, entry := range items.AnyOf { + if err := validateTitledEnumEntry(entry); err != nil { + return fmt.Errorf("elicit schema property %q items has invalid entry: %v", propName, err) + } + } + return nil + default: + return fmt.Errorf("elicit schema property %q items have unsupported type %q", propName, items.Type) + } +} + +func validateTitledEnumEntry(entry *jsonschema.Schema) error { + if entry.Const == nil { + return fmt.Errorf("const is required for titled enum entries") + } + constVal, ok := (*entry.Const).(string) + if !ok { + return fmt.Errorf("const must be a string for titled enum entries") + } + if constVal == "" { + return fmt.Errorf("const cannot be empty for titled enum entries") + } + if entry.Title == "" { + return fmt.Errorf("title is required for titled enum entries") + } + return nil +} + // validateElicitBooleanProperty validates boolean-type properties. func validateElicitBooleanProperty(propName string, propSchema *jsonschema.Schema) error { return validateDefaultProperty[bool](propName, propSchema) diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 71a1dcc..86c0550 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -1095,7 +1095,7 @@ func TestElicitationSchemaValidation(t *testing.T) { }, }, { - name: "string enum", + name: "single select untitled enum", schema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ @@ -1111,7 +1111,7 @@ func TestElicitationSchemaValidation(t *testing.T) { }, }, { - name: "enum with matching enumNames", + name: "legacy titled enum", schema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ @@ -1130,7 +1130,7 @@ func TestElicitationSchemaValidation(t *testing.T) { }, }, { - name: "enum with enum schema", + name: "single select titled enum", schema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ @@ -1138,22 +1138,62 @@ func TestElicitationSchemaValidation(t *testing.T) { Type: "string", OneOf: []*jsonschema.Schema{ { - Const: anyPtr(map[string]string{ - "const": "high", - "title": "High Priority", - }), + Const: anyPtr("high"), + Title: "High Priority", }, { - Const: anyPtr(map[string]string{ - "const": "medium", - "title": "Medium Priority", - }), + Const: anyPtr("medium"), + Title: "Medium Priority", }, { - Const: anyPtr(map[string]string{ - "const": "low", - "title": "Low Priority", - }), + Const: anyPtr("low"), + Title: "Low Priority", + }, + }, + }, + }, + }, + }, + { + name: "multi select untitled enum", + schema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "status": { + Type: "array", + Items: &jsonschema.Schema{ + Type: "string", + Enum: []any{ + "active", + "inactive", + "pending", + }, + }, + }, + }, + }, + }, + { + name: "multi select titled enum", + schema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "priority": { + Type: "array", + Items: &jsonschema.Schema{ + AnyOf: []*jsonschema.Schema{ + { + Const: anyPtr("high"), + Title: "High Priority", + }, + { + Const: anyPtr("medium"), + Title: "Medium Priority", + }, + { + Const: anyPtr("low"), + Title: "Low Priority", + }, }, }, }, @@ -1210,17 +1250,7 @@ func TestElicitationSchemaValidation(t *testing.T) { "config": {Type: "object"}, }, }, - expectedError: "elicit schema property \"config\" has unsupported type \"object\", only string, number, integer, and boolean are allowed", - }, - { - name: "array property", - schema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "tags": {Type: "array", Items: &jsonschema.Schema{Type: "string"}}, - }, - }, - expectedError: "elicit schema property \"tags\" has unsupported type \"array\", only string, number, integer, and boolean are allowed", + expectedError: "elicit schema property \"config\" has unsupported type \"object\", only string, number, integer, boolean, and array are allowed", }, { name: "array without items", @@ -1230,7 +1260,27 @@ func TestElicitationSchemaValidation(t *testing.T) { "items": {Type: "array"}, }, }, - expectedError: "elicit schema property \"items\" has unsupported type \"array\", only string, number, integer, and boolean are allowed", + expectedError: "elicit schema property \"items\" is array but missing 'items' definition", + }, + { + name: "array with integer items", + schema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "items": {Type: "array", Items: &jsonschema.Schema{Type: "integer"}}, + }, + }, + expectedError: "elicit schema property \"items\" items have unsupported type \"integer\"", + }, + { + name: "array of generic strings", + schema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "items": {Type: "array", Items: &jsonschema.Schema{Type: "string"}}, + }, + }, + expectedError: "elicit schema property \"items\" items must specify enum for untitled enums", }, { name: "unsupported string format", @@ -1385,7 +1435,29 @@ func TestElicitationSchemaValidation(t *testing.T) { }, }, }, - expectedError: "elicit schema property \"priority\" has unsupported type \"\", only string, number, integer, and boolean are allowed", + expectedError: "elicit schema property \"priority\" has unsupported type \"\", only string, number, integer, boolean, and array are allowed", + }, + { + name: "titled enum without const", + schema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "priority": { + Type: "string", + OneOf: []*jsonschema.Schema{ + { + Const: anyPtr("high"), + Title: "High Priority", + }, + { + Type: "string", + Title: "Other Priority", + }, + }, + }, + }, + }, + expectedError: "elicit schema property \"priority\" oneOf has invalid entry: const is required for titled enum entries", }, { name: "untyped property", @@ -1395,7 +1467,7 @@ func TestElicitationSchemaValidation(t *testing.T) { "data": {}, }, }, - expectedError: "elicit schema property \"data\" has unsupported type \"\", only string, number, integer, and boolean are allowed", + expectedError: "elicit schema property \"data\" has unsupported type \"\", only string, number, integer, boolean, and array are allowed", }, } @@ -1458,10 +1530,8 @@ func TestElicitContentValidation(t *testing.T) { Type: "string", OneOf: []*jsonschema.Schema{ { - Const: anyPtr(map[string]string{ - "const": "high", - "title": "High Priority", - }), + Const: anyPtr("high"), + Title: "High Priority", }, }, }, @@ -1478,10 +1548,8 @@ func TestElicitContentValidation(t *testing.T) { Type: "string", OneOf: []*jsonschema.Schema{ { - Const: anyPtr(map[string]string{ - "const": "potato", - "title": "Potato Priority", - }), + Const: anyPtr("potato"), + Title: "Potato Priority", }, }, }, From 1580ac9494340d8eaf4458693dc1182171664183 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 13:40:36 +0000 Subject: [PATCH 198/418] build(deps): bump actions/setup-node from 4.1.0 to 6.2.0 Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4.1.0 to 6.2.0. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/39370e3970a6d050c480ffad4ff0ed4d3fdee5af...6044e13b5dc448c55e2357c09f80417699197238) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: 6.2.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/nightly.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 2bceda7..45aa699 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -29,7 +29,7 @@ jobs: with: go-version: "^1.25" - name: Set up Node.js - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: "22" - name: Run server conformance tests From 3dd262606ec4139948b03dd5135a9735f0e2f955 Mon Sep 17 00:00:00 2001 From: "Yamamoto, Hirotaka" Date: Wed, 18 Feb 2026 00:10:07 +0900 Subject: [PATCH 199/418] mcp: add Extensions field to capabilities per SEP-2133 (#794) This implements [SEP-2133](https://modelcontextprotocol.io/community/seps/2133-extensions) and resolves #777. ## Summary of changes - Add `Extensions` field to `ServerCapabilities` and `ClientCapabilities` structs per [SEP-2133](https://modelcontextprotocol.io/community/seps/2133-extensions) - Add `AddExtension` helper methods on both types that normalize nil settings to empty maps. - [The spec requires an object, not null](https://modelcontextprotocol.io/community/seps/2133-extensions#negotiation:~:text=an%20empty%20object%20indicates%20no%20settings.) - Include shallow-copy support via `maps.Clone` in the existing `clone()` methods - also include shallow-copy support for `Experimental` field. - Add comprehensive tests: unit tests for nil-safety, capability preservation, and over-the-wire round-trip ## What's tested - [x] `go test ./mcp/...` passes - [x] Server conformance tests pass (30 scenarios, 40 assertions, 0 failures) - [x] Client conformance tests match existing baseline (auth scenarios are known failures) --- mcp/client_test.go | 37 ++++++++++++++++++++++++++++++++ mcp/protocol.go | 53 ++++++++++++++++++++++++++++++++++++++++++++-- mcp/server_test.go | 36 +++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 2 deletions(-) diff --git a/mcp/client_test.go b/mcp/client_test.go index 2f7abc0..fc37c3e 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -402,6 +402,26 @@ func TestClientCapabilities(t *testing.T) { RootsV2: &RootCapabilities{ListChanged: true}, }, }, + { + name: "extensions preserved", + configureClient: func(s *Client) {}, + clientOpts: func() ClientOptions { + caps := &ClientCapabilities{ + RootsV2: &RootCapabilities{ListChanged: true}, + } + caps.AddExtension("io.example/ext1", map[string]any{"key": "value"}) + caps.AddExtension("io.example/ext2", nil) + return ClientOptions{Capabilities: caps} + }(), + wantCapabilities: &ClientCapabilities{ + Extensions: map[string]any{ + "io.example/ext1": map[string]any{"key": "value"}, + "io.example/ext2": map[string]any{}, + }, + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, + }, + }, } for _, tc := range testCases { @@ -469,6 +489,23 @@ func TestClientCapabilitiesOverWire(t *testing.T) { }, wantCapabilities: &ClientCapabilities{}, }, + { + name: "Extensions over wire", + clientOpts: func() *ClientOptions { + caps := &ClientCapabilities{ + RootsV2: &RootCapabilities{ListChanged: true}, + } + caps.AddExtension("io.example/ext", map[string]any{"key": "value"}) + return &ClientOptions{Capabilities: caps} + }(), + wantCapabilities: &ClientCapabilities{ + Extensions: map[string]any{ + "io.example/ext": map[string]any{"key": "value"}, + }, + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, + }, + }, } for _, tc := range testCases { diff --git a/mcp/protocol.go b/mcp/protocol.go index 6e4a190..9bbaa0b 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -13,6 +13,7 @@ package mcp import ( "encoding/json" "fmt" + "maps" ) // Optional annotations for the client. The client can use annotations to inform @@ -192,7 +193,14 @@ type ClientCapabilities struct { // [ClientCapabilities.clone]. // Experimental reports non-standard capabilities that the client supports. + // The caller should not modify the map after assigning it. Experimental map[string]any `json:"experimental,omitempty"` + // Extensions reports extensions that the client supports. + // Keys are extension identifiers in "{vendor-prefix}/{extension-name}" format. + // Values are per-extension settings objects; use [ClientCapabilities.AddExtension] + // to ensure nil settings are normalized to empty objects. + // The caller should not modify the map or its values after assigning it. + Extensions map[string]any `json:"extensions,omitempty"` // Roots describes the client's support for roots. // // Deprecated: use RootsV2. As described in #607, Roots should have been a @@ -211,9 +219,26 @@ type ClientCapabilities struct { Elicitation *ElicitationCapabilities `json:"elicitation,omitempty"` } -// clone returns a deep copy of the ClientCapabilities. +// AddExtension adds an extension with the given name and settings. +// If settings is nil, an empty map is used to ensure valid JSON serialization +// (the spec requires an object, not null). +// The settings map should not be modified after the call. +func (c *ClientCapabilities) AddExtension(name string, settings map[string]any) { + if c.Extensions == nil { + c.Extensions = make(map[string]any) + } + if settings == nil { + settings = map[string]any{} + } + c.Extensions[name] = settings +} + +// clone returns a copy of the ClientCapabilities. +// Values in the Extensions and Experimental maps are shallow-copied. func (c *ClientCapabilities) clone() *ClientCapabilities { cp := *c + cp.Experimental = maps.Clone(c.Experimental) + cp.Extensions = maps.Clone(c.Extensions) cp.RootsV2 = shallowClone(c.RootsV2) if c.Sampling != nil { x := *c.Sampling @@ -1516,7 +1541,14 @@ type ServerCapabilities struct { // [ServerCapabilities.clone]. // Experimental reports non-standard capabilities that the server supports. + // The caller should not modify the map after assigning it. Experimental map[string]any `json:"experimental,omitempty"` + // Extensions reports extensions that the server supports. + // Keys are extension identifiers in "{vendor-prefix}/{extension-name}" format. + // Values are per-extension settings objects; use [ServerCapabilities.AddExtension] + // to ensure nil settings are normalized to empty objects. + // The caller should not modify the map or its values after assigning it. + Extensions map[string]any `json:"extensions,omitempty"` // Completions is present if the server supports argument autocompletion // suggestions. Completions *CompletionCapabilities `json:"completions,omitempty"` @@ -1530,9 +1562,26 @@ type ServerCapabilities struct { Tools *ToolCapabilities `json:"tools,omitempty"` } -// clone returns a deep copy of the ServerCapabilities. +// AddExtension adds an extension with the given name and settings. +// If settings is nil, an empty map is used to ensure valid JSON serialization +// (the spec requires an object, not null). +// The settings map should not be modified after the call. +func (c *ServerCapabilities) AddExtension(name string, settings map[string]any) { + if c.Extensions == nil { + c.Extensions = make(map[string]any) + } + if settings == nil { + settings = map[string]any{} + } + c.Extensions[name] = settings +} + +// clone returns a copy of the ServerCapabilities. +// Values in the Extensions and Experimental maps are shallow-copied. func (c *ServerCapabilities) clone() *ServerCapabilities { cp := *c + cp.Experimental = maps.Clone(c.Experimental) + cp.Extensions = maps.Clone(c.Extensions) cp.Completions = shallowClone(c.Completions) cp.Logging = shallowClone(c.Logging) cp.Prompts = shallowClone(c.Prompts) diff --git a/mcp/server_test.go b/mcp/server_test.go index 0f86f76..227e7be 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -441,6 +441,25 @@ func TestServerCapabilities(t *testing.T) { Logging: &LoggingCapabilities{}, }, }, + { + name: "extensions preserved", + configureServer: func(s *Server) {}, + serverOpts: func() ServerOptions { + caps := &ServerCapabilities{ + Logging: &LoggingCapabilities{}, + } + caps.AddExtension("io.example/ext1", map[string]any{"key": "value"}) + caps.AddExtension("io.example/ext2", nil) + return ServerOptions{Capabilities: caps} + }(), + wantCapabilities: &ServerCapabilities{ + Extensions: map[string]any{ + "io.example/ext1": map[string]any{"key": "value"}, + "io.example/ext2": map[string]any{}, + }, + Logging: &LoggingCapabilities{}, + }, + }, } for _, tc := range testCases { @@ -927,6 +946,23 @@ func TestServerCapabilitiesOverWire(t *testing.T) { Tools: &ToolCapabilities{ListChanged: true}, }, }, + { + name: "Extensions over wire", + serverOpts: func() *ServerOptions { + caps := &ServerCapabilities{ + Logging: &LoggingCapabilities{}, + } + caps.AddExtension("io.example/ext", map[string]any{"key": "value"}) + return &ServerOptions{Capabilities: caps} + }(), + configureServer: func(s *Server) {}, + wantCapabilities: &ServerCapabilities{ + Extensions: map[string]any{ + "io.example/ext": map[string]any{"key": "value"}, + }, + Logging: &LoggingCapabilities{}, + }, + }, } for _, tc := range testCases { From ec98351ea4e741a7c2440d54da173e5c01e47fda Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:44:02 +0100 Subject: [PATCH 200/418] build(deps): bump actions/setup-go from 5.5.0 to 6.2.0 (#804) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5.5.0 to 6.2.0.
Release notes

Sourced from actions/setup-go's releases.

v6.2.0

What's Changed

Enhancements

Dependency updates

New Contributors

Full Changelog: https://github.com/actions/setup-go/compare/v6...v6.2.0

v6.1.0

What's Changed

Enhancements

Dependency updates

New Contributors

Full Changelog: https://github.com/actions/setup-go/compare/v6...v6.1.0

v6.0.0

What's Changed

Breaking Changes

Make sure your runner is on version v2.327.1 or later to ensure compatibility with this release. See Release Notes

Dependency Upgrades

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/setup-go&package-manager=github_actions&previous-version=5.5.0&new-version=6.2.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Maciek Kisiel --- .github/workflows/conformance.yml | 4 ++-- .github/workflows/docs-check.yml | 2 +- .github/workflows/nightly.yml | 2 +- .github/workflows/test.yml | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 332975c..f3aa7fb 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -20,7 +20,7 @@ jobs: - name: Check out code uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 - name: Set up Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: go-version: "^1.25" - name: Start everything-server @@ -42,7 +42,7 @@ jobs: - name: Check out code uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 - name: Set up Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: go-version: "^1.25" - name: "Run conformance tests" diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index 984e878..0f51f55 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Set up Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 - name: Check out code uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 - name: Check docs are up-to-date diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 45aa699..a674902 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -25,7 +25,7 @@ jobs: - name: Check out code uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 - name: Set up Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: go-version: "^1.25" - name: Set up Node.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d3f32e0..17b9135 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: - name: Check out code uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 - name: Set up Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: go-version: "^1.25" - name: Check formatting @@ -45,7 +45,7 @@ jobs: - name: Check out code uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 - name: Set up Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: go-version: ${{ matrix.go }} - name: Test @@ -57,7 +57,7 @@ jobs: - name: Check out code uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 - name: Set up Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: go-version: "1.24" - name: Test with -race From 9600c3bd31a789eeab48aac52eb3a942b6ce0031 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:44:18 +0100 Subject: [PATCH 201/418] build(deps): bump actions/checkout from 4.3.0 to 6.0.2 (#803) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/checkout](https://github.com/actions/checkout) from 4.3.0 to 6.0.2.
Release notes

Sourced from actions/checkout's releases.

v6.0.2

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v6.0.1...v6.0.2

v6.0.1

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v6...v6.0.1

v6.0.0

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v5.0.0...v6.0.0

v6-beta

What's Changed

Updated persist-credentials to store the credentials under $RUNNER_TEMP instead of directly in the local git config.

This requires a minimum Actions Runner version of v2.329.0 to access the persisted credentials for Docker container action scenarios.

v5.0.1

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v5...v5.0.1

v5.0.0

What's Changed

⚠️ Minimum Compatible Runner Version

v2.327.1
Release Notes

... (truncated)

Changelog

Sourced from actions/checkout's changelog.

Changelog

v6.0.2

v6.0.1

v6.0.0

v5.0.1

v5.0.0

v4.3.1

v4.3.0

v4.2.2

v4.2.1

v4.2.0

v4.1.7

v4.1.6

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/checkout&package-manager=github_actions&previous-version=4.3.0&new-version=6.0.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Maciek Kisiel --- .github/workflows/conformance.yml | 4 ++-- .github/workflows/docs-check.yml | 2 +- .github/workflows/nightly.yml | 2 +- .github/workflows/publish-docs.yml | 3 +-- .github/workflows/test.yml | 6 +++--- 5 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index f3aa7fb..7a2e65f 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: @@ -40,7 +40,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index 0f51f55..4ed070b 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Go uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 - name: Check out code - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Check docs are up-to-date run: | go generate ./... diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index a674902..150a9f0 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index d599019..b844bd0 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -13,8 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 - + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 17b9135..b544f44 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: @@ -43,7 +43,7 @@ jobs: go: ["1.24", "1.25"] steps: - name: Check out code - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: @@ -55,7 +55,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: From 7b8d81c264074404abdf5aa16e2cf0c2d9c64cc0 Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Wed, 18 Feb 2026 16:38:36 +0100 Subject: [PATCH 202/418] all: use case-sensitive JSON unmarshaling (#807) As reported in https://github.com/modelcontextprotocol/go-sdk/issues/805, case insensitive unmarshaling can pose security issues. This change adjusts all non-test and non-example unmarshaling to use a case sensitive mode. This introduces a new external dependency `github.com/segmentio/encoding`. --- go.mod | 6 ++++ go.sum | 6 ++++ internal/json/json.go | 19 +++++++++++ internal/json/json_test.go | 63 +++++++++++++++++++++++++++++++++++ internal/jsonrpc2/conn.go | 3 +- internal/jsonrpc2/messages.go | 4 ++- mcp/client.go | 3 +- mcp/content.go | 8 +++-- mcp/protocol.go | 16 +++++---- mcp/server.go | 5 +-- mcp/shared.go | 3 +- mcp/streamable.go | 3 +- mcp/tool.go | 3 +- mcp/transport.go | 5 +-- mcp/util.go | 4 ++- oauthex/dcr.go | 8 +++-- 16 files changed, 134 insertions(+), 25 deletions(-) create mode 100644 internal/json/json.go create mode 100644 internal/json/json_test.go diff --git a/go.mod b/go.mod index 3b5b3b8..a9aea54 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,13 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/go-cmp v0.7.0 github.com/google/jsonschema-go v0.4.2 + github.com/segmentio/encoding v0.5.3 github.com/yosida95/uritemplate/v3 v3.0.2 golang.org/x/oauth2 v0.34.0 golang.org/x/tools v0.41.0 ) + +require ( + github.com/segmentio/asm v1.1.3 // indirect + golang.org/x/sys v0.40.0 // indirect +) diff --git a/go.sum b/go.sum index 3432579..53778f7 100644 --- a/go.sum +++ b/go.sum @@ -4,9 +4,15 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= diff --git a/internal/json/json.go b/internal/json/json.go new file mode 100644 index 0000000..1148770 --- /dev/null +++ b/internal/json/json.go @@ -0,0 +1,19 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +// Package json provides internal JSON utilities. + +package json + +import ( + "bytes" + + "github.com/segmentio/encoding/json" +) + +func Unmarshal(data []byte, v any) error { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DontMatchCaseInsensitiveStructFields() + return dec.Decode(v) +} diff --git a/internal/json/json_test.go b/internal/json/json_test.go new file mode 100644 index 0000000..feeba70 --- /dev/null +++ b/internal/json/json_test.go @@ -0,0 +1,63 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package json + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestUnmarshalCaseSensitivity(t *testing.T) { + type Nested struct { + Field string `json:"field"` + } + type Target struct { + Field string + TaggedField string `json:"custom_tag"` + Nested *Nested + } + + tests := []struct { + name string + json string + want Target + }{ + { + name: "exact match", + json: `{"Field": "value", "custom_tag": "tagged", "Nested": {"field": "nested"}}`, + want: Target{ + Field: "value", + TaggedField: "tagged", + Nested: &Nested{ + Field: "nested", + }, + }, + }, + { + name: "case mismatch", + json: `{"field": "value", "Custom_tag": "tagged", "Nested": {"Field": "nested"}}`, + want: Target{ + Field: "", + TaggedField: "", + Nested: &Nested{ + Field: "", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got Target + if err := Unmarshal([]byte(tt.json), &got); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("Unmarshal mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/jsonrpc2/conn.go b/internal/jsonrpc2/conn.go index 627ffe7..571df63 100644 --- a/internal/jsonrpc2/conn.go +++ b/internal/jsonrpc2/conn.go @@ -6,13 +6,14 @@ package jsonrpc2 import ( "context" - "encoding/json" "errors" "fmt" "io" "sync" "sync/atomic" "time" + + "github.com/modelcontextprotocol/go-sdk/internal/json" ) // Binder builds a connection configuration. diff --git a/internal/jsonrpc2/messages.go b/internal/jsonrpc2/messages.go index 6e58a6c..b424780 100644 --- a/internal/jsonrpc2/messages.go +++ b/internal/jsonrpc2/messages.go @@ -10,6 +10,8 @@ import ( "errors" "fmt" + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" + "github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug" ) @@ -173,7 +175,7 @@ func EncodeIndent(msg Message, prefix, indent string) ([]byte, error) { func DecodeMessage(data []byte) (Message, error) { msg := wireCombined{} - if err := json.Unmarshal(data, &msg); err != nil { + if err := internaljson.Unmarshal(data, &msg); err != nil { return nil, fmt.Errorf("unmarshaling jsonrpc message: %w", err) } if msg.VersionTag != wireVersion { diff --git a/mcp/client.go b/mcp/client.go index bc81c1e..74900b1 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -6,7 +6,6 @@ package mcp import ( "context" - "encoding/json" "errors" "fmt" "iter" @@ -18,7 +17,7 @@ import ( "time" "github.com/google/jsonschema-go/jsonschema" - + "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) diff --git a/mcp/content.go b/mcp/content.go index b94076c..95ea40d 100644 --- a/mcp/content.go +++ b/mcp/content.go @@ -10,6 +10,8 @@ package mcp import ( "encoding/json" "fmt" + + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" ) // A Content is a [TextContent], [ImageContent], [AudioContent], @@ -248,7 +250,7 @@ func (c *ToolResultContent) MarshalJSON() ([]byte, error) { return nil, err } var w wireContent - if err := json.Unmarshal(data, &w); err != nil { + if err := internaljson.Unmarshal(data, &w); err != nil { return nil, err } contentWire = append(contentWire, &w) @@ -328,11 +330,11 @@ func unmarshalContent(raw json.RawMessage, allow map[string]bool) ([]Content, er } // Try array first, then fall back to single object. var wires []*wireContent - if err := json.Unmarshal(raw, &wires); err == nil { + if err := internaljson.Unmarshal(raw, &wires); err == nil { return contentsFromWire(wires, allow) } var wire wireContent - if err := json.Unmarshal(raw, &wire); err != nil { + if err := internaljson.Unmarshal(raw, &wire); err != nil { return nil, err } c, err := contentFromWire(&wire, allow) diff --git a/mcp/protocol.go b/mcp/protocol.go index 9bbaa0b..837ce78 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -14,6 +14,8 @@ import ( "encoding/json" "fmt" "maps" + + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" ) // Optional annotations for the client. The client can use annotations to inform @@ -141,7 +143,7 @@ func (x *CallToolResult) UnmarshalJSON(data []byte) error { res Content []*wireContent `json:"content"` } - if err := json.Unmarshal(data, &wire); err != nil { + if err := internaljson.Unmarshal(data, &wire); err != nil { return err } var err error @@ -315,7 +317,7 @@ type CompleteReference struct { func (r *CompleteReference) UnmarshalJSON(data []byte) error { type wireCompleteReference CompleteReference // for naive unmarshaling var r2 wireCompleteReference - if err := json.Unmarshal(data, &r2); err != nil { + if err := internaljson.Unmarshal(data, &r2); err != nil { return err } switch r2.Type { @@ -502,7 +504,7 @@ func (m *SamplingMessageV2) UnmarshalJSON(data []byte) error { msg Content json.RawMessage `json:"content"` } - if err := json.Unmarshal(data, &wire); err != nil { + if err := internaljson.Unmarshal(data, &wire); err != nil { return err } var err error @@ -542,7 +544,7 @@ func (r *CreateMessageResult) UnmarshalJSON(data []byte) error { result Content *wireContent `json:"content"` } - if err := json.Unmarshal(data, &wire); err != nil { + if err := internaljson.Unmarshal(data, &wire); err != nil { return err } var err error @@ -605,7 +607,7 @@ func (r *CreateMessageWithToolsResult) UnmarshalJSON(data []byte) error { result Content json.RawMessage `json:"content"` } - if err := json.Unmarshal(data, &wire); err != nil { + if err := internaljson.Unmarshal(data, &wire); err != nil { return err } var err error @@ -1056,7 +1058,7 @@ func (m *PromptMessage) UnmarshalJSON(data []byte) error { msg Content *wireContent `json:"content"` } - if err := json.Unmarshal(data, &wire); err != nil { + if err := internaljson.Unmarshal(data, &wire); err != nil { return err } var err error @@ -1253,7 +1255,7 @@ func (m *SamplingMessage) UnmarshalJSON(data []byte) error { msg Content *wireContent `json:"content"` } - if err := json.Unmarshal(data, &wire); err != nil { + if err := internaljson.Unmarshal(data, &wire); err != nil { return err } // Allow text, image, audio, tool_use, and tool_result in sampling messages diff --git a/mcp/server.go b/mcp/server.go index f4e6a94..e3c03e2 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -25,6 +25,7 @@ import ( "time" "github.com/google/jsonschema-go/jsonschema" + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/internal/util" "github.com/modelcontextprotocol/go-sdk/jsonrpc" @@ -327,7 +328,7 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out], cache *SchemaCa // Unmarshal and validate args. var in In if input != nil { - if err := json.Unmarshal(input, &in); err != nil { + if err := internaljson.Unmarshal(input, &in); err != nil { return nil, fmt.Errorf("%w: %v", jsonrpc2.ErrInvalidParams, err) } } @@ -1371,7 +1372,7 @@ func initializeMethodInfo() methodInfo { info.unmarshalParams = func(m json.RawMessage) (Params, error) { var params *initializeParamsV2 if m != nil { - if err := json.Unmarshal(m, ¶ms); err != nil { + if err := internaljson.Unmarshal(m, ¶ms); err != nil { return nil, fmt.Errorf("unmarshaling %q into a %T: %w", m, params, err) } } diff --git a/mcp/shared.go b/mcp/shared.go index d83eae7..bda00c2 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -23,6 +23,7 @@ import ( "time" "github.com/modelcontextprotocol/go-sdk/auth" + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) @@ -283,7 +284,7 @@ func newMethodInfo[P paramsPtr[T], R Result, T any](flags methodFlags) methodInf unmarshalParams: func(m json.RawMessage) (Params, error) { var p P if m != nil { - if err := json.Unmarshal(m, &p); err != nil { + if err := internaljson.Unmarshal(m, &p); err != nil { return nil, fmt.Errorf("unmarshaling %q into a %T: %w", m, p, err) } } diff --git a/mcp/streamable.go b/mcp/streamable.go index c06877f..36ae5b1 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -30,6 +30,7 @@ import ( "time" "github.com/modelcontextprotocol/go-sdk/auth" + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug" "github.com/modelcontextprotocol/go-sdk/internal/util" @@ -1105,7 +1106,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques isInitialize = true // Extract the protocol version from InitializeParams. var params InitializeParams - if err := json.Unmarshal(jreq.Params, ¶ms); err == nil { + if err := internaljson.Unmarshal(jreq.Params, ¶ms); err == nil { initializeProtocolVersion = params.ProtocolVersion } } diff --git a/mcp/tool.go b/mcp/tool.go index 8aa7c3c..3ecb59d 100644 --- a/mcp/tool.go +++ b/mcp/tool.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/google/jsonschema-go/jsonschema" + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" ) // A ToolHandler handles a call to tools/call. @@ -83,7 +84,7 @@ func applySchema(data json.RawMessage, resolved *jsonschema.Resolved) (json.RawM if resolved != nil { v := make(map[string]any) if len(data) > 0 { - if err := json.Unmarshal(data, &v); err != nil { + if err := internaljson.Unmarshal(data, &v); err != nil { return nil, fmt.Errorf("unmarshaling arguments: %w", err) } } diff --git a/mcp/transport.go b/mcp/transport.go index fa18653..5f2a500 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -15,6 +15,7 @@ import ( "os" "sync" + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/internal/xcontext" "github.com/modelcontextprotocol/go-sdk/jsonrpc" @@ -193,7 +194,7 @@ type canceller struct { func (c *canceller) Preempt(ctx context.Context, req *jsonrpc.Request) (result any, err error) { if req.Method == notificationCancelled { var params CancelledParams - if err := json.Unmarshal(req.Params, ¶ms); err != nil { + if err := internaljson.Unmarshal(req.Params, ¶ms); err != nil { return nil, err } id, err := jsonrpc2.MakeID(params.RequestID) @@ -569,7 +570,7 @@ func (t *ioConn) Read(ctx context.Context) (jsonrpc.Message, error) { func readBatch(data []byte) (msgs []jsonrpc.Message, isBatch bool, _ error) { // Try to read an array of messages first. var rawBatch []json.RawMessage - if err := json.Unmarshal(data, &rawBatch); err == nil { + if err := internaljson.Unmarshal(data, &rawBatch); err == nil { if len(rawBatch) == 0 { return nil, true, fmt.Errorf("empty batch") } diff --git a/mcp/util.go b/mcp/util.go index f4a1f31..8ffaa74 100644 --- a/mcp/util.go +++ b/mcp/util.go @@ -6,6 +6,8 @@ package mcp import ( "encoding/json" + + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" ) func assert(cond bool, msg string) { @@ -21,7 +23,7 @@ func remarshal(from, to any) error { if err != nil { return err } - if err := json.Unmarshal(data, to); err != nil { + if err := internaljson.Unmarshal(data, to); err != nil { return err } return nil diff --git a/oauthex/dcr.go b/oauthex/dcr.go index c64cb8c..6db3025 100644 --- a/oauthex/dcr.go +++ b/oauthex/dcr.go @@ -17,6 +17,8 @@ import ( "io" "net/http" "time" + + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" ) // ClientRegistrationMetadata represents the client metadata fields for the DCR POST request (RFC 7591). @@ -144,7 +146,7 @@ func (r *ClientRegistrationResponse) UnmarshalJSON(data []byte) error { }{ alias: (*alias)(r), } - if err := json.Unmarshal(data, &aux); err != nil { + if err := internaljson.Unmarshal(data, &aux); err != nil { return err } if aux.ClientIDIssuedAt != 0 { @@ -206,7 +208,7 @@ func RegisterClient(ctx context.Context, registrationEndpoint string, clientMeta if resp.StatusCode == http.StatusCreated { var regResponse ClientRegistrationResponse - if err := json.Unmarshal(body, ®Response); err != nil { + if err := internaljson.Unmarshal(body, ®Response); err != nil { return nil, fmt.Errorf("failed to decode successful registration response: %w (%s)", err, string(body)) } if regResponse.ClientID == "" { @@ -221,7 +223,7 @@ func RegisterClient(ctx context.Context, registrationEndpoint string, clientMeta if resp.StatusCode == http.StatusBadRequest { var regError ClientRegistrationError - if err := json.Unmarshal(body, ®Error); err != nil { + if err := internaljson.Unmarshal(body, ®Error); err != nil { return nil, fmt.Errorf("failed to decode registration error response: %w (%s)", err, string(body)) } return nil, ®Error From ac65640a6841112cb7c08c48f903a903942b6351 Mon Sep 17 00:00:00 2001 From: Den Delimarsky <53200638+localden@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:24:53 -0800 Subject: [PATCH 203/418] chore: update SECURITY.md to use GitHub Security Advisories (#809) Replaces Anthropic HackerOne VDP references with standard GitHub Security Advisory process. --- SECURITY.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 6545156..5029242 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,15 +1,21 @@ # Security Policy -Thank you for helping us keep the SDKs and systems they interact with secure. +Thank you for helping keep the Model Context Protocol and its ecosystem secure. ## Reporting Security Issues -This SDK is maintained by [Anthropic](https://www.anthropic.com/) as part of the Model Context Protocol project. +If you discover a security vulnerability in this repository, please report it through +the [GitHub Security Advisory process](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) +for this repository. -The security of our systems and user data is Anthropic’s top priority. We appreciate the work of security researchers acting in good faith in identifying and reporting potential vulnerabilities. +Please **do not** report security vulnerabilities through public GitHub issues, discussions, +or pull requests. -Our security program is managed on HackerOne and we ask that any validated vulnerability in this functionality be reported through their [submission form](https://hackerone.com/anthropic-vdp/reports/new?type=team&report_type=vulnerability). +## What to Include -## Vulnerability Disclosure Program +To help us triage and respond quickly, please include: -Our Vulnerability Program Guidelines are defined on our [HackerOne program page](https://hackerone.com/anthropic-vdp). +- A description of the vulnerability +- Steps to reproduce the issue +- The potential impact +- Any suggested fixes (optional) From 51d256cadc585e4d6b693924016fd354dd7514e4 Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Thu, 19 Feb 2026 15:02:15 +0100 Subject: [PATCH 204/418] chore: Configure OSSF Scorecard action (#811) --- .github/workflows/scorecard.yml | 78 +++++++++++++++++++++++++++++++++ README.md | 1 + internal/readme/README.src.md | 1 + 3 files changed, 80 insertions(+) create mode 100644 .github/workflows/scorecard.yml diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..43bf223 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,78 @@ +# This workflow uses actions that are not certified by GitHub. They are provided +# by a third-party and are governed by separate terms of service, privacy +# policy, and support documentation. + +name: Scorecard supply-chain security +on: + # For Branch-Protection check. Only the default branch is supported. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection + branch_protection_rule: + # To guarantee Maintained check is occasionally updated. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained + schedule: + - cron: '41 23 * * 5' + push: + branches: [ "main" ] + +# Declare default permissions as read only. +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + # `publish_results: true` only works when run from the default branch. conditional can be removed if disabled. + if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request' + permissions: + # Needed to upload the results to code-scanning dashboard. + security-events: write + # Needed to publish results and get a badge (see publish_results below). + id-token: write + # Uncomment the permissions below if installing in a private repository. + # contents: read + # actions: read + + steps: + - name: "Checkout code" + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: "Run analysis" + uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1 + with: + results_file: results.sarif + results_format: sarif + # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: + # - you want to enable the Branch-Protection check on a *public* repository, or + # - you are installing Scorecard on a *private* repository + # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional. + # repo_token: ${{ secrets.SCORECARD_TOKEN }} + + # Public repositories: + # - Publish results to OpenSSF REST API for easy access by consumers + # - Allows the repository to include the Scorecard badge. + # - See https://github.com/ossf/scorecard-action#publishing-results. + # For private repositories: + # - `publish_results` will always be set to `false`, regardless + # of the value entered here. + publish_results: true + + # (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore + # file_mode: git + + # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF + # format to the repository Actions tab. + - name: "Upload artifact" + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + # Upload the results to GitHub's code scanning dashboard (optional). + # Commenting out will disable upload of results to your repo's Code Scanning dashboard + - name: "Upload to code-scanning" + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif diff --git a/README.md b/README.md index 4745282..2b015c6 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/modelcontextprotocol/go-sdk) [![PkgGoDev](https://pkg.go.dev/badge/github.com/modelcontextprotocol/go-sdk)](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) +[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/modelcontextprotocol/go-sdk/badge)](https://scorecard.dev/viewer/?uri=github.com/modelcontextprotocol/go-sdk) This repository contains an implementation of the official Go software development kit (SDK) for the Model Context Protocol (MCP). diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md index 57d9da3..fce0fa4 100644 --- a/internal/readme/README.src.md +++ b/internal/readme/README.src.md @@ -3,6 +3,7 @@ [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/modelcontextprotocol/go-sdk) [![PkgGoDev](https://pkg.go.dev/badge/github.com/modelcontextprotocol/go-sdk)](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) +[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/modelcontextprotocol/go-sdk/badge)](https://scorecard.dev/viewer/?uri=github.com/modelcontextprotocol/go-sdk) This repository contains an implementation of the official Go software development kit (SDK) for the Model Context Protocol (MCP). From 9f22cf142e37040c33a3e6a8561fda9d77a6cef6 Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Thu, 19 Feb 2026 15:22:12 +0100 Subject: [PATCH 205/418] =?UTF-8?q?chore:=20configure=20a=20simple=20AGENT?= =?UTF-8?q?S.md=20file=20and=20a=20skill=20for=20fixing=20GitHu=E2=80=A6?= =?UTF-8?q?=20(#810)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/fix-bug/SKILLS.md | 13 +++++++ AGENTS.md | 58 ++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 .agents/skills/fix-bug/SKILLS.md create mode 100644 AGENTS.md diff --git a/.agents/skills/fix-bug/SKILLS.md b/.agents/skills/fix-bug/SKILLS.md new file mode 100644 index 0000000..6956a54 --- /dev/null +++ b/.agents/skills/fix-bug/SKILLS.md @@ -0,0 +1,13 @@ +--- +name: fix-bug +description: Confirm, debug and fix bugs reported via GitHub issues. +--- + +# Step-by-step instructions + +1. Use the `gh` CLI to fetch information about the issue. +2. Create a reproduction test case. +3. Create an implementation plan that describes how you intend to fix the bug. Don't proceed without approval. +4. Implement the fix. +5. Verify the fix with the test case and run all tests to ensure no regressions. +6. Provide a summary of the changes. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bc65991 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,58 @@ +# AGENTS.md + +## Project Overview + +This repository contains the official Go SDK for the Model Context Protocol (MCP). +The SDK is designed to be idiomatic, future-proof, and extensible. + +### Key Packages + +- `mcp`: The core package defining the primary APIs for constructing and using MCP clients and servers. This is where most logic resides. +- `jsonrpc`: Provides the JSON-RPC 2.0 transport layer. Use this if implementing custom transports. +- `auth`: Primitives for supporting OAuth. +- `oauthex`: Extensions to the OAuth protocol, such as Protected Resource Metadata. +- `internal`: Internal implementation details not exposed to users. +- `examples`: Example clients and servers. Use these as references for usage patterns. + +## Development Setup + +The project uses the standard Go toolchain. + +- **Build**: `go build ./...` +- **Test**: `go test ./...` + +## Testing + +- **Unit Tests**: Run `go test ./...` to run all unit tests. +- **Conformance Tests**: Use the following scripts to run the official MCP conformance tests against the SDK. + - `./scripts/server-conformance.sh` for server tests. + - `./scripts/client-conformance.sh` for client tests. + - The scripts download the latest conformance suite from npm by default. + - To get possible options pass the `--help` flag to the script. + +## Development Guidelines + +### Code Style + +- Follow standard Go conventions (Effective Go). +- Use `gofmt` to format code. +- Add copyright headers to all new Go files: + ```go + // Copyright 2025 The Go MCP SDK Authors. All rights reserved. + // Use of this source code is governed by the license + // that can be found in the LICENSE file. + ``` +- Do not add comments to the code unless they are really necessary: + - Prefer self-documenting code. + - Focus on the "why" not the "what" in comments. + +### Documentation + +- **README.md**: Do NOT edit `README.md` directly. It is generated from `internal/readme/README.src.md`. + - Edit `internal/readme/README.src.md`. + - Run `go generate ./internal/readme` to regenerate. + - Commit both files. +- **docs/**: Do NOT edit `docs/` directory directly. It is generated from files in `internal/docs`. + - Edit `internal/docs/*.src.md`. + - Run `go generate ./internal/docs` to regenerate. + - Commit files from both directories. From 86d05a12f7e8542eca0478efd48ab1f694a664df Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Thu, 19 Feb 2026 16:08:40 +0100 Subject: [PATCH 206/418] chore: update publish-docs permissions to be more targeted. (#812) This is recommended by OSSF. --- .github/workflows/publish-docs.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index b844bd0..c45111f 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -5,11 +5,12 @@ on: types: [published] workflow_dispatch: -permissions: - contents: write +permissions: read-all jobs: deploy: + permissions: + contents: write runs-on: ubuntu-latest steps: - name: Check out code From b17143f7179833f7f6175161e81318dc3b3aa121 Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Fri, 20 Feb 2026 11:54:50 +0100 Subject: [PATCH 207/418] chore: increase timeout for conformance server start. (#813) We're experiencing some timeouts on server conformance test runs. This should fix it. --- .github/workflows/conformance.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 7a2e65f..dfe5b34 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -27,7 +27,7 @@ jobs: run: | go run ./conformance/everything-server/main.go -http=":3001" & # Wait for the server to be ready. - timeout 15 bash -c 'until curl -s http://localhost:3001/mcp; do sleep 0.5; done' + timeout 30 bash -c 'until curl -s http://localhost:3001/mcp; do sleep 0.5; done' - name: "Run conformance tests" uses: modelcontextprotocol/conformance@c2f3fdaf781dcd5a862cb0d2f6454c1c210bf0f0 # v0.1.11 with: From 19420360b7601460c6a1edf4860d701d796bb14a Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Fri, 20 Feb 2026 17:15:15 +0100 Subject: [PATCH 208/418] chore: update the version of the conformance suite. (#814) It contains an important fix for client auth tests. --- .github/workflows/conformance.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index dfe5b34..fdd71a7 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -29,12 +29,13 @@ jobs: # Wait for the server to be ready. timeout 30 bash -c 'until curl -s http://localhost:3001/mcp; do sleep 0.5; done' - name: "Run conformance tests" - uses: modelcontextprotocol/conformance@c2f3fdaf781dcd5a862cb0d2f6454c1c210bf0f0 # v0.1.11 + uses: modelcontextprotocol/conformance@a2855b03582a6c0b31065ad4d9af248316ce61a3 # v0.1.15 with: mode: server url: http://localhost:3001/mcp suite: active expected-failures: ./conformance/baseline.yml + node-version: 22 client-conformance: runs-on: ubuntu-latest @@ -46,9 +47,10 @@ jobs: with: go-version: "^1.25" - name: "Run conformance tests" - uses: modelcontextprotocol/conformance@c2f3fdaf781dcd5a862cb0d2f6454c1c210bf0f0 # v0.1.11 + uses: modelcontextprotocol/conformance@a2855b03582a6c0b31065ad4d9af248316ce61a3 # v0.1.15 with: mode: client command: go run ./conformance/everything-client/main.go suite: core expected-failures: ./conformance/baseline.yml + node-version: 22 From 0048a1894fe0ae4350bc2142effa09985e19c569 Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Fri, 27 Feb 2026 14:17:54 +0100 Subject: [PATCH 209/418] chore: Configure advanced CodeQL setup (#819) The basic setup doesn't allow scanning on PRs coming from external forks: https://github.com/github/codeql/issues/19698#issuecomment-3657789784. --- .github/workflows/codeql.yml | 45 ++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..30b4c57 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,45 @@ +name: "CodeQL Advanced" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '31 9 * * 4' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: go + build-mode: autobuild + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Initialize CodeQL + uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + with: + category: "/language:${{matrix.language}}" From 4e8b6ca17d1d00d4f5b230dbd5bf7012dc142fde Mon Sep 17 00:00:00 2001 From: Ronald Date: Fri, 27 Feb 2026 09:15:33 -0500 Subject: [PATCH 210/418] mcp: return 400 instead of 500 when body read fails in stateless mode (#817) ## Summary Changes `http.StatusInternalServerError` to `http.StatusBadRequest` when `io.ReadAll(req.Body)` fails during stateless mode initialization in the streamable HTTP handler. Fixes #816 ## Rationale Body read failures are client-side errors (disconnection, timeout, incomplete request), not server errors. Returning 500 is misleading and causes unnecessary alert fatigue for users monitoring their MCP servers. This makes the behavior consistent with `servePOST` which already returns 400 for the same error condition. ## Testing - Existing tests pass - This is a one-line change affecting only the HTTP status code returned Co-authored-by: Cursor --- mcp/streamable.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 36ae5b1..d78697d 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -405,7 +405,7 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque // stateless servers. body, err := io.ReadAll(req.Body) if err != nil { - http.Error(w, "failed to read body", http.StatusInternalServerError) + http.Error(w, "failed to read body", http.StatusBadRequest) return } req.Body.Close() From c9317fb5b75328ca2faeaf8ea0e23a53c37de49f Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Fri, 27 Feb 2026 15:30:13 +0100 Subject: [PATCH 211/418] all: client side OAuth support (#785) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR introduces client-side OAuth support. # Overall design philosophy The main new interface introduced to support client-side OAuth is the `OAuthHandler`. Contrary to its counter-parts in some other SDKs, it is intentionally small and generic so that it can support most of the OAuth authorization flow variants. The main `mcp` package purposefully delegates almost all auth handling to `OAuthHandler` implementations. This way, the `mcp` package continues to be largely OAuth agnostic. The `auth` package comes with a default `OAuthHandler` implementation – `AuthorizationCodeHandler`, which implements the authorization flow for the `authorization_code` grant type, as defined in the [MCP specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization). It is expected that the `OAuthHandler` implementation granularity will be roughly per grant type, as this parameter influences the authorization flow the most. # Detailed design ## `OAuthHandler` (auth/client.go) The main interface for the integration consists of two methods: ```go type OAuthHandler interface { // TokenSource returns a token source to be used for outgoing requests. // Returned token source might be nil. In that case, the transport will not // add any authorization headers to the request. TokenSource(context.Context) (oauth2.TokenSource, error) // Authorize is called when an HTTP request results in an error that may // be addressed by the authorization flow (currently 401 Unauthorized and 403 Forbidden). // It is responsible for performing the OAuth flow to obtain an access token. // The arguments are the request that failed and the response that was received for it. // The headers of the request are available, but the body will have already been consumed // when Authorize is called. // If the returned error is nil, TokenSource is expected to return a non-nil token source. // After a successful call to Authorize, the HTTP request will be retried by the transport. // The function is responsible for closing the response body. Authorize(context.Context, *http.Request, *http.Response) error } ``` `StreamableClientTransport` accepts this interface as a field and uses the `TokenSource` method to add an `Authorization: Bearer` header to outgoing requests to the MCP server. If the request fails with `401: Unauthorized` or `403: Forbidden`, the transport calls `Authorize` to perform the authorization flow. If `Authorize` returns a non-nil error, the request is retried once. `Authorize` errors do not result in termination of the client session (unless they happen during connection initialization), as some OAuth flows are multi-legged and it should be acceptable to retry for example when the authorization grant was received. *Note:* There was already an `OAuthHandler` type defined as part of a previous iteration of OAuth support. It was renamed to `OAuthHandlerLegacy`. This is the only backwards incompatible API change in the experimental client `auth` part (protected by the `mcp_go_client_oauth` Go build tag). The cost of replacing previous usages is deemed worthwhile to maintain clear naming, under the assumption that the experimental client `auth` part was not widely used. ## `AuthorizationCodeHandler` (auth/authorization_code.go) This is the `OAuthHandler` implementation that fulfills the [MCP specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization). In particular, in supports: - Protected Resource Metadata discovery and fetching - Authorization Server Metadata discovery and fetching - Client registration - OpenID Connect metadata document - Pre-registration - Dynamic Client Registration - PKCE - `client_secret_post`, `client_secret_basic` token endpoint auth methods - Automatic refresh of tokens ### Authorization Server redirect As part of the flow for the `authorization_code` grant type, the client needs to point the user to the authorization URL, so that they confirm the access request. This design proposes that it will be supported via a pluggable dependency that will initiate this process in an application-specific way: ```go type AuthorizationCodeHandler struct { ... // AuthorizationCodeFetcher is a required function called to initiate the authorization flow. // It is responsible for opening the URL in a browser for the user to start the authorization process. // It should return the authorization code and state once the Authorization Server // redirects back to the RedirectURL. AuthorizationCodeFetcher func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) ... } ``` The function is supposed perform any application specific logic to handle the redirect from the Authorization Server and return the resulting code and state. ## `oauthex` changes Some additional/adjusted building blocks that may be useful when creating `OAuthHandler` implementations are present in the `oauthex` package: - `auth_meta.go`: Authorization Server Metadata utilities: - `GetAuthServerMeta`: get Authorization Server Metadata from a URL - `dcr.go`: Dynamic Client Registration utilities (unchanged) - `resource_meta.go`: Protected Resource Metadata utilities: - `GetProtectedResourceMetadata`: get Protected Resource Metadata from a URL - `ParseWWWAuthenticate` – as per name (unchanged) - [deprecated] `GetProtectedResourceMetadataFromID` and `GetProtectedResourceMetadataFromHeader` in favor of more generic `GetProtectedResourceMetadata`. They will be kept under the Go build tag and removed one version of the SDK in the future. --- .github/workflows/conformance.yml | 2 +- README.md | 13 +- auth/auth.go | 6 +- auth/authorization_code.go | 548 +++++++++++++++ auth/authorization_code_test.go | 658 ++++++++++++++++++ auth/client.go | 137 +--- auth/client_private.go | 135 ++++ conformance/baseline.yml | 20 +- .../everything-client/client_private.go | 139 ++++ conformance/everything-client/main.go | 34 +- docs/protocol.md | 61 +- examples/auth/client/main.go | 130 ++++ examples/auth/server/main.go | 167 +++++ internal/docs/protocol.src.md | 61 +- .../oauthtest/fake_authorization_server.go | 304 ++++++++ internal/readme/README.src.md | 13 +- mcp/streamable.go | 88 ++- mcp/streamable_client_auth_test.go | 179 +++++ mcp/streamable_client_test.go | 1 - mcp/streamable_test.go | 49 -- oauthex/auth_meta.go | 87 ++- oauthex/auth_meta_test.go | 3 +- oauthex/oauth2.go | 23 +- oauthex/oauth2_test.go | 18 +- oauthex/oauthex.go | 86 --- oauthex/resource_meta.go | 97 ++- oauthex/resource_meta_public.go | 105 +++ oauthex/url_scheme_test.go | 4 +- scripts/client-conformance.sh | 13 +- 29 files changed, 2745 insertions(+), 436 deletions(-) create mode 100644 auth/authorization_code.go create mode 100644 auth/authorization_code_test.go create mode 100644 auth/client_private.go create mode 100644 conformance/everything-client/client_private.go create mode 100644 examples/auth/client/main.go create mode 100644 examples/auth/server/main.go create mode 100644 internal/oauthtest/fake_authorization_server.go create mode 100644 mcp/streamable_client_auth_test.go create mode 100644 oauthex/resource_meta_public.go diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index fdd71a7..184a31c 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -50,7 +50,7 @@ jobs: uses: modelcontextprotocol/conformance@a2855b03582a6c0b31065ad4d9af248316ce61a3 # v0.1.15 with: mode: client - command: go run ./conformance/everything-client/main.go + command: go run -tags mcp_go_client_oauth ./conformance/everything-client suite: core expected-failures: ./conformance/baseline.yml node-version: 22 diff --git a/README.md b/README.md index 2b015c6..ffaf33e 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,15 @@ contains feature documentation, mapping the MCP spec to the packages above. The following table shows which versions of the Go SDK support which versions of the MCP specification: -| SDK Version | Latest MCP Spec | All Supported MCP Specs | -|-----------------|-------------------|------------------------------------------------| -| v1.2.0+ | 2025-06-18 | 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05 | -| v1.0.0 - v1.1.0 | 2025-06-18 | 2025-06-18, 2025-03-26, 2024-11-05 | +| SDK Version | Latest MCP Spec | All Supported MCP Specs | +|-----------------|-------------------|----------------------------------------------------| +| v1.4.0+ | 2025-11-25\* | 2025-11-25\*, 2025-06-18, 2025-03-26, 2024-11-05 | +| v1.2.0 - v1.3.1 | 2025-11-25\*\* | 2025-11-25\*\*, 2025-06-18, 2025-03-26, 2024-11-05 | +| v1.0.0 - v1.1.0 | 2025-06-18 | 2025-06-18, 2025-03-26, 2024-11-05 | + +\* Client side OAuth has experimental support. + +\*\* Partial support for 2025-11-25 (client side OAuth and Sampling with tools not available). New releases of the SDK target only supported versions of Go. See https://go.dev/doc/devel/release#policy for more information. diff --git a/auth/auth.go b/auth/auth.go index 8766512..36ff259 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -25,8 +25,7 @@ type TokenInfo struct { // session hijacking by ensuring that all requests for a given session // come from the same user. UserID string - // TODO: add standard JWT fields - Extra map[string]any + Extra map[string]any } // The error that a TokenVerifier should return if the token cannot be verified. @@ -106,6 +105,9 @@ func verify(req *http.Request, verifier TokenVerifier, opts *RequireBearerTokenO } return nil, err.Error(), http.StatusInternalServerError } + if tokenInfo == nil { + return nil, "token validation failed", http.StatusInternalServerError + } // Check scopes. All must be present. if opts != nil { diff --git a/auth/authorization_code.go b/auth/authorization_code.go new file mode 100644 index 0000000..2a6ed32 --- /dev/null +++ b/auth/authorization_code.go @@ -0,0 +1,548 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +//go:build mcp_go_client_oauth + +package auth + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "net/http" + "net/url" + "slices" + "strings" + + "github.com/modelcontextprotocol/go-sdk/oauthex" + "golang.org/x/oauth2" +) + +// ClientSecretAuthConfig is used to configure client authentication using client_secret. +// Authentication method will be selected based on the authorization server's supported methods, +// according to the following preference order: +// 1. client_secret_post +// 2. client_secret_basic +type ClientSecretAuthConfig struct { + // ClientID is the client ID to be used for client authentication. + ClientID string + // ClientSecret is the client secret to be used for client authentication. + ClientSecret string +} + +// ClientIDMetadataDocumentConfig is used to configure the Client ID Metadata Document +// based client registration per +// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#client-id-metadata-documents. +// See https://client.dev/ for more information. +type ClientIDMetadataDocumentConfig struct { + // URL is the client identifier URL as per + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00#section-3. + URL string +} + +// PreregisteredClientConfig is used to configure a pre-registered client per +// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration. +// Currently only "client_secret_basic" and "client_secret_post" authentication methods are supported. +type PreregisteredClientConfig struct { + // ClientSecretAuthConfig is the client_secret based configuration to be used for client authentication. + ClientSecretAuthConfig *ClientSecretAuthConfig +} + +// DynamicClientRegistrationConfig is used to configure dynamic client registration per +// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#dynamic-client-registration. +type DynamicClientRegistrationConfig struct { + // Metadata to be used in dynamic client registration request as per + // https://datatracker.ietf.org/doc/html/rfc7591#section-2. + Metadata *oauthex.ClientRegistrationMetadata +} + +// AuthorizationResult is the result of an authorization flow. +// It is returned by [AuthorizationCodeHandler].AuthorizationCodeFetcher implementations. +type AuthorizationResult struct { + // Code is the authorization code obtained from the authorization server. + Code string + // State string returned by the authorization server. + State string +} + +// AuthorizationArgs is the input to [AuthorizationCodeHandlerConfig].AuthorizationCodeFetcher. +type AuthorizationArgs struct { + // Authorization URL to be opened in a browser for the user to start the authorization process. + URL string +} + +// AuthorizationCodeHandlerConfig is the configuration for [AuthorizationCodeHandler]. +type AuthorizationCodeHandlerConfig struct { + // Client registration configuration. + // It is attempted in the following order: + // 1. Client ID Metadata Document + // 2. Preregistration + // 3. Dynamic Client Registration + // At least one method must be configured. + ClientIDMetadataDocumentConfig *ClientIDMetadataDocumentConfig + PreregisteredClientConfig *PreregisteredClientConfig + DynamicClientRegistrationConfig *DynamicClientRegistrationConfig + + // RedirectURL is a required URL to redirect to after authorization. + // The caller is responsible for handling the redirect out of band. + // + // If Dynamic Client Registration is used: + // - this field is permitted to be empty, in which case it will be set + // to the first redirect URI from + // DynamicClientRegistrationConfig.Metadata.RedirectURIs. + // - if the field is not empty, it must be one of the redirect URIs in + // DynamicClientRegistrationConfig.Metadata.RedirectURIs. + RedirectURL string + + // AuthorizationCodeFetcher is a required function called to initiate the authorization flow. + // It is responsible for opening the URL in a browser for the user to start the authorization process. + // It should return the authorization code and state once the Authorization Server + // redirects back to the RedirectURL. + AuthorizationCodeFetcher func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) +} + +// AuthorizationCodeHandler is an implementation of [OAuthHandler] that uses +// the authorization code flow to obtain access tokens. +type AuthorizationCodeHandler struct { + config *AuthorizationCodeHandlerConfig + + // tokenSource is the token source to use for authorization. + tokenSource oauth2.TokenSource +} + +var _ OAuthHandler = (*AuthorizationCodeHandler)(nil) + +func (h *AuthorizationCodeHandler) isOAuthHandler() {} + +func (h *AuthorizationCodeHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) { + return h.tokenSource, nil +} + +// NewAuthorizationCodeHandler creates a new AuthorizationCodeHandler. +// It performs validation of the configuration and returns an error if it is invalid. +// The passed config is consumed by the handler and should not be modified after. +func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*AuthorizationCodeHandler, error) { + if config == nil { + return nil, errors.New("config must be provided") + } + if config.ClientIDMetadataDocumentConfig == nil && + config.PreregisteredClientConfig == nil && + config.DynamicClientRegistrationConfig == nil { + return nil, errors.New("at least one client registration configuration must be provided") + } + if config.AuthorizationCodeFetcher == nil { + return nil, errors.New("AuthorizationCodeFetcher is required") + } + if config.ClientIDMetadataDocumentConfig != nil && !isNonRootHTTPSURL(config.ClientIDMetadataDocumentConfig.URL) { + return nil, fmt.Errorf("client ID metadata document URL must be a non-root HTTPS URL") + } + preCfg := config.PreregisteredClientConfig + if preCfg != nil { + if preCfg.ClientSecretAuthConfig == nil { + return nil, errors.New("ClientSecretAuthConfig is required for pre-registered client") + } + if preCfg.ClientSecretAuthConfig.ClientID == "" || preCfg.ClientSecretAuthConfig.ClientSecret == "" { + return nil, fmt.Errorf("pre-registered client ID or secret is empty") + } + } + dCfg := config.DynamicClientRegistrationConfig + if dCfg != nil { + if dCfg.Metadata == nil { + return nil, errors.New("Metadata is required for dynamic client registration") + } + if len(dCfg.Metadata.RedirectURIs) == 0 { + return nil, errors.New("Metadata.RedirectURIs is required for dynamic client registration") + } + if config.RedirectURL == "" { + config.RedirectURL = dCfg.Metadata.RedirectURIs[0] + } else if !slices.Contains(dCfg.Metadata.RedirectURIs, config.RedirectURL) { + return nil, fmt.Errorf("RedirectURL %q is not in the list of allowed redirect URIs for dynamic client registration", config.RedirectURL) + } + } + if config.RedirectURL == "" { + // If the RedirectURL was supposed to be set by the dynamic client registration, + // it should have been set by now. Otherwise, it is required. + return nil, errors.New("RedirectURL is required") + } + return &AuthorizationCodeHandler{config: config}, nil +} + +func isNonRootHTTPSURL(u string) bool { + pu, err := url.Parse(u) + if err != nil { + return false + } + return pu.Scheme == "https" && pu.Path != "" +} + +// Authorize performs the authorization flow. +// It is designed to perform the whole Authorization Code Grant flow. +// On success, [AuthorizationCodeHandler.TokenSource] will return a token source with the fetched token. +func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error { + defer resp.Body.Close() + + wwwChallenges, err := oauthex.ParseWWWAuthenticate(resp.Header[http.CanonicalHeaderKey("WWW-Authenticate")]) + if err != nil { + return fmt.Errorf("failed to parse WWW-Authenticate header: %v", err) + } + + if resp.StatusCode == http.StatusForbidden && errorFromChallenges(wwwChallenges) != "insufficient_scope" { + // We only want to perform step-up authorization for insufficient_scope errors. + // Returning nil, so that the call is retried immediately and the response + // is handled appropriately by the connection. + // Step-up authorization is defined at + // https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#step-up-authorization-flow + return nil + } + + prm, err := h.getProtectedResourceMetadata(ctx, wwwChallenges, req.URL.String()) + if err != nil { + return err + } + + asm, err := h.getAuthServerMetadata(ctx, prm) + if err != nil { + return err + } + + resolvedClientConfig, err := h.handleRegistration(ctx, asm) + if err != nil { + return err + } + + scps := scopesFromChallenges(wwwChallenges) + if len(scps) == 0 && len(prm.ScopesSupported) > 0 { + scps = prm.ScopesSupported + } + + cfg := &oauth2.Config{ + ClientID: resolvedClientConfig.clientID, + ClientSecret: resolvedClientConfig.clientSecret, + + Endpoint: oauth2.Endpoint{ + AuthURL: asm.AuthorizationEndpoint, + TokenURL: asm.TokenEndpoint, + AuthStyle: resolvedClientConfig.authStyle, + }, + RedirectURL: h.config.RedirectURL, + Scopes: scps, + } + + authRes, err := h.getAuthorizationCode(ctx, cfg, req.URL.String()) + if err != nil { + // Purposefully leaving the error unwrappable so it can be handled by the caller. + return err + } + + return h.exchangeAuthorizationCode(ctx, cfg, authRes, prm.Resource) +} + +// resourceMetadataURLFromChallenges returns a resource metadata URL from the given "WWW-Authenticate" header challenges, +// or the empty string if there is none. +func resourceMetadataURLFromChallenges(cs []oauthex.Challenge) string { + for _, c := range cs { + if u := c.Params["resource_metadata"]; u != "" { + return u + } + } + return "" +} + +// scopesFromChallenges returns the scopes from the given "WWW-Authenticate" header challenges. +// It only looks at challenges with the "Bearer" scheme. +func scopesFromChallenges(cs []oauthex.Challenge) []string { + for _, c := range cs { + if c.Scheme == "bearer" && c.Params["scope"] != "" { + return strings.Fields(c.Params["scope"]) + } + } + return nil +} + +// errorFromChallenges returns the error from the given "WWW-Authenticate" header challenges. +// It only looks at challenges with the "Bearer" scheme. +func errorFromChallenges(cs []oauthex.Challenge) string { + for _, c := range cs { + if c.Scheme == "bearer" && c.Params["error"] != "" { + return c.Params["error"] + } + } + return "" +} + +// getProtectedResourceMetadata returns the protected resource metadata. +// If no metadata was found or the fetched metadata fails security checks, +// it returns an error. +func (h *AuthorizationCodeHandler) getProtectedResourceMetadata(ctx context.Context, wwwChallenges []oauthex.Challenge, mcpServerURL string) (*oauthex.ProtectedResourceMetadata, error) { + var errs []error + // Use MCP server URL as the resource URI per + // https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#canonical-server-uri. + for _, url := range protectedResourceMetadataURLs(resourceMetadataURLFromChallenges(wwwChallenges), mcpServerURL) { + prm, err := oauthex.GetProtectedResourceMetadata(ctx, url.URL, url.Resource, http.DefaultClient) + if err != nil { + errs = append(errs, err) + continue + } + if prm == nil { + errs = append(errs, fmt.Errorf("protected resource metadata is nil")) + continue + } + return prm, nil + } + return nil, fmt.Errorf("failed to get protected resource metadata: %v", errors.Join(errs...)) +} + +type prmURL struct { + // URL represents a URL where Protected Resource Metadata may be retrieved. + URL string + // Resource represents the corresponding resource URL for [URL]. + // It is required to perform validation described in RFC 9728, section 3.3. + Resource string +} + +// protectedResourceMetadataURLs returns a list of URLs to try when looking for +// protected resource metadata as mandated by the MCP specification: +// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements +func protectedResourceMetadataURLs(metadataURL, resourceURL string) []prmURL { + var urls []prmURL + if metadataURL != "" { + urls = append(urls, prmURL{ + URL: metadataURL, + Resource: resourceURL, + }) + } + ru, err := url.Parse(resourceURL) + if err != nil { + return urls + } + mu := *ru + // "At the path of the server's MCP endpoint". + mu.Path = "/.well-known/oauth-protected-resource/" + strings.TrimLeft(ru.Path, "/") + urls = append(urls, prmURL{ + URL: mu.String(), + Resource: resourceURL, + }) + // "At the root". + mu.Path = "/.well-known/oauth-protected-resource" + ru.Path = "" + urls = append(urls, prmURL{ + URL: mu.String(), + Resource: ru.String(), + }) + return urls +} + +// getAuthServerMetadata returns the authorization server metadata. +// The provided Protected Resource Metadata must not be nil. +// It returns an error if the metadata request fails with non-4xx HTTP status code +// or the fetched metadata fails security checks. +// If no metadata was found, it returns a minimal set of endpoints +// as a fallback to 2025-03-26 spec. +func (h *AuthorizationCodeHandler) getAuthServerMetadata(ctx context.Context, prm *oauthex.ProtectedResourceMetadata) (*oauthex.AuthServerMeta, error) { + var authServerURL string + if len(prm.AuthorizationServers) > 0 { + // Use the first authorization server, similarly to other SDKs. + authServerURL = prm.AuthorizationServers[0] + } else { + // Fallback to 2025-03-26 spec: MCP server base URL acts as Authorization Server. + authURL, err := url.Parse(prm.Resource) + if err != nil { + return nil, fmt.Errorf("failed to parse resource URL: %v", err) + } + authURL.Path = "" + authServerURL = authURL.String() + } + + for _, u := range authorizationServerMetadataURLs(authServerURL) { + asm, err := oauthex.GetAuthServerMeta(ctx, u, authServerURL, http.DefaultClient) + if err != nil { + return nil, fmt.Errorf("failed to get authorization server metadata: %w", err) + } + if asm != nil { + return asm, nil + } + } + + // Fallback to 2025-03-26 spec: predefined endpoints. + // https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery + asm := &oauthex.AuthServerMeta{ + Issuer: authServerURL, + AuthorizationEndpoint: authServerURL + "/authorize", + TokenEndpoint: authServerURL + "/token", + RegistrationEndpoint: authServerURL + "/register", + } + return asm, nil +} + +// authorizationServerMetadataURLs returns a list of URLs to try when looking for +// authorization server metadata as mandated by the MCP specification: +// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery. +func authorizationServerMetadataURLs(issuerURL string) []string { + var urls []string + + baseURL, err := url.Parse(issuerURL) + if err != nil { + return nil + } + + if baseURL.Path == "" { + // "OAuth 2.0 Authorization Server Metadata". + baseURL.Path = "/.well-known/oauth-authorization-server" + urls = append(urls, baseURL.String()) + // "OpenID Connect Discovery 1.0". + baseURL.Path = "/.well-known/openid-configuration" + urls = append(urls, baseURL.String()) + return urls + } + + originalPath := baseURL.Path + // "OAuth 2.0 Authorization Server Metadata with path insertion". + baseURL.Path = "/.well-known/oauth-authorization-server/" + strings.TrimLeft(originalPath, "/") + urls = append(urls, baseURL.String()) + // "OpenID Connect Discovery 1.0 with path insertion". + baseURL.Path = "/.well-known/openid-configuration/" + strings.TrimLeft(originalPath, "/") + urls = append(urls, baseURL.String()) + // "OpenID Connect Discovery 1.0 with path appending". + baseURL.Path = "/" + strings.Trim(originalPath, "/") + "/.well-known/openid-configuration" + urls = append(urls, baseURL.String()) + return urls +} + +type registrationType int + +const ( + registrationTypeClientIDMetadataDocument registrationType = iota + registrationTypePreregistered + registrationTypeDynamic +) + +type resolvedClientConfig struct { + registrationType registrationType + clientID string + clientSecret string + authStyle oauth2.AuthStyle +} + +func selectTokenAuthMethod(supported []string) oauth2.AuthStyle { + prefOrder := []string{ + // Preferred in OAuth 2.1 draft: https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-14.html#name-client-secret. + "client_secret_post", + "client_secret_basic", + } + for _, method := range prefOrder { + if slices.Contains(supported, method) { + return authMethodToStyle(method) + } + } + return oauth2.AuthStyleAutoDetect +} + +func authMethodToStyle(method string) oauth2.AuthStyle { + switch method { + case "client_secret_post": + return oauth2.AuthStyleInParams + case "client_secret_basic": + return oauth2.AuthStyleInHeader + case "none": + // "none" is equivalent to "client_secret_post" but without sending client secret. + return oauth2.AuthStyleInParams + default: + // "client_secret_basic" is the default per https://datatracker.ietf.org/doc/html/rfc7591#section-2. + return oauth2.AuthStyleInHeader + } +} + +// handleRegistration handles client registration. +// The provided authorization server metadata must be non-nil. +// Support for different registration methods is defined as follows: +// - Client ID Metadata Document: metadata must have +// `ClientIDMetadataDocumentSupported` set to true. +// - Pre-registered client: assumed to be supported. +// - Dynamic client registration: metadata must have +// `RegistrationEndpoint` set to a non-empty value. +func (h *AuthorizationCodeHandler) handleRegistration(ctx context.Context, asm *oauthex.AuthServerMeta) (*resolvedClientConfig, error) { + // 1. Attempt to use Client ID Metadata Document (SEP-991). + cimdCfg := h.config.ClientIDMetadataDocumentConfig + if cimdCfg != nil && asm.ClientIDMetadataDocumentSupported { + return &resolvedClientConfig{ + registrationType: registrationTypeClientIDMetadataDocument, + clientID: cimdCfg.URL, + }, nil + } + // 2. Attempt to use pre-registered client configuration. + pCfg := h.config.PreregisteredClientConfig + if pCfg != nil { + authStyle := selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported) + return &resolvedClientConfig{ + registrationType: registrationTypePreregistered, + clientID: pCfg.ClientSecretAuthConfig.ClientID, + clientSecret: pCfg.ClientSecretAuthConfig.ClientSecret, + authStyle: authStyle, + }, nil + } + // 3. Attempt to use dynamic client registration. + dcrCfg := h.config.DynamicClientRegistrationConfig + if dcrCfg != nil && asm.RegistrationEndpoint != "" { + regResp, err := oauthex.RegisterClient(ctx, asm.RegistrationEndpoint, dcrCfg.Metadata, http.DefaultClient) + if err != nil { + return nil, fmt.Errorf("failed to register client: %w", err) + } + cfg := &resolvedClientConfig{ + registrationType: registrationTypeDynamic, + clientID: regResp.ClientID, + clientSecret: regResp.ClientSecret, + authStyle: authMethodToStyle(regResp.TokenEndpointAuthMethod), + } + return cfg, nil + } + return nil, fmt.Errorf("no configured client registration methods are supported by the authorization server") +} + +type authResult struct { + *AuthorizationResult + // usedCodeVerifier is the PKCE code verifier used to obtain the authorization code. + // It is preserved for the token exchange step. + usedCodeVerifier string +} + +// getAuthorizationCode uses the [AuthorizationCodeHandler.AuthorizationCodeFetcher] +// to obtain an authorization code. +func (h *AuthorizationCodeHandler) getAuthorizationCode(ctx context.Context, cfg *oauth2.Config, resourceURL string) (*authResult, error) { + codeVerifier := oauth2.GenerateVerifier() + state := rand.Text() + + authURL := cfg.AuthCodeURL(state, + oauth2.S256ChallengeOption(codeVerifier), + oauth2.SetAuthURLParam("resource", resourceURL), + ) + + authRes, err := h.config.AuthorizationCodeFetcher(ctx, &AuthorizationArgs{URL: authURL}) + if err != nil { + // Purposefully leaving the error unwrappable so it can be handled by the caller. + return nil, err + } + if authRes.State != state { + return nil, fmt.Errorf("state mismatch") + } + return &authResult{ + AuthorizationResult: authRes, + usedCodeVerifier: codeVerifier, + }, nil +} + +// exchangeAuthorizationCode exchanges the authorization code for a token +// and stores it in a token source. +func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context, cfg *oauth2.Config, authResult *authResult, resourceURL string) error { + opts := []oauth2.AuthCodeOption{ + oauth2.VerifierOption(authResult.usedCodeVerifier), + oauth2.SetAuthURLParam("resource", resourceURL), + } + token, err := cfg.Exchange(ctx, authResult.Code, opts...) + if err != nil { + return fmt.Errorf("token exchange failed: %w", err) + } + h.tokenSource = cfg.TokenSource(ctx, token) + return nil +} diff --git a/auth/authorization_code_test.go b/auth/authorization_code_test.go new file mode 100644 index 0000000..77214cd --- /dev/null +++ b/auth/authorization_code_test.go @@ -0,0 +1,658 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +//go:build mcp_go_client_oauth + +package auth + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/http/httputil" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/modelcontextprotocol/go-sdk/internal/oauthtest" + "github.com/modelcontextprotocol/go-sdk/oauthex" + "golang.org/x/oauth2" +) + +func TestAuthorize(t *testing.T) { + authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + RegistrationConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "test_client_id": { + Secret: "test_client_secret", + RedirectURIs: []string{"http://localhost:12345/callback"}, + }, + }, + }, + }) + authServer.Start(t) + + resourceMux := http.NewServeMux() + resourceServer := httptest.NewServer(resourceMux) + t.Cleanup(resourceServer.Close) + resourceURL := resourceServer.URL + "/resource" + + resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{ + Resource: resourceURL, + AuthorizationServers: []string{authServer.URL()}, + })) + + handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{ + RedirectURL: "http://localhost:12345/callback", + PreregisteredClientConfig: &PreregisteredClientConfig{ + ClientSecretAuthConfig: &ClientSecretAuthConfig{ + ClientID: "test_client_id", + ClientSecret: "test_client_secret", + }, + }, + AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + // The fake authorization server will redirect to an URL with code and state. + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := client.Get(args.URL) + if err != nil { + return nil, fmt.Errorf("failed to visit auth URL: %v", err) + } + defer resp.Body.Close() + dump, err := httputil.DumpResponse(resp, true) + if err != nil { + t.Fatalf("failed to dump response: %v", err) + } + t.Log(string(dump)) + + location, err := resp.Location() + if err != nil { + return nil, fmt.Errorf("failed to get location header: %v", err) + } + return &AuthorizationResult{ + Code: location.Query().Get("code"), + State: location.Query().Get("state"), + }, nil + }, + }) + if err != nil { + t.Fatalf("NewAuthorizationCodeHandler failed: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, resourceURL, nil) + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: http.NoBody, + Request: req, + } + resp.Header.Set( + "WWW-Authenticate", + "Bearer resource_metadata="+resourceServer.URL+"/.well-known/oauth-protected-resource/resource", + ) + + if err := handler.Authorize(context.Background(), req, resp); err != nil { + t.Fatalf("Authorize failed: %v", err) + } + + tokenSource, err := handler.TokenSource(t.Context()) + if err != nil { + t.Fatalf("Failed to get token source: %v", err) + } + token, err := tokenSource.Token() + if err != nil { + t.Fatalf("Failed to get token: %v", err) + } + if token.AccessToken != "test_access_token" { + t.Errorf("Expected access token 'test_access_token', got '%s'", token.AccessToken) + } +} + +func TestAuthorize_ForbiddenUnhandledError(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://example.com/resource", nil) + resp := &http.Response{ + StatusCode: http.StatusForbidden, + Header: make(http.Header), + Body: http.NoBody, + Request: req, + } + resp.Header.Set( + "WWW-Authenticate", + "Bearer error=invalid_token", + ) + handler := &AuthorizationCodeHandler{} // No config needed for this test. + err := handler.Authorize(t.Context(), req, resp) + if err != nil { + t.Fatalf("Authorize() failed: %v", err) + } +} + +func TestNewAuthorizationCodeHandler_Success(t *testing.T) { + simpleHandler := func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + return nil, nil + } + tests := []struct { + name string + config *AuthorizationCodeHandlerConfig + }{ + { + name: "ClientIDMetadataDocumentConfig", + config: &AuthorizationCodeHandlerConfig{ + ClientIDMetadataDocumentConfig: &ClientIDMetadataDocumentConfig{URL: "https://example.com/client"}, + RedirectURL: "https://example.com/callback", + AuthorizationCodeFetcher: simpleHandler, + }, + }, + { + name: "PreregisteredClientConfig", + config: &AuthorizationCodeHandlerConfig{ + PreregisteredClientConfig: &PreregisteredClientConfig{ + ClientSecretAuthConfig: &ClientSecretAuthConfig{ + ClientID: "test_client_id", + ClientSecret: "test_client_secret", + }, + }, + RedirectURL: "https://example.com/callback", + AuthorizationCodeFetcher: simpleHandler, + }, + }, + { + name: "DynamicClientRegistrationConfig_NoRedirectURL", + config: &AuthorizationCodeHandlerConfig{ + DynamicClientRegistrationConfig: &DynamicClientRegistrationConfig{ + Metadata: &oauthex.ClientRegistrationMetadata{ + RedirectURIs: []string{ + "https://example.com/callback", + }, + }, + }, + AuthorizationCodeFetcher: simpleHandler, + }, + }, + { + name: "DynamicClientRegistrationConfig_WithRedirectURL", + config: &AuthorizationCodeHandlerConfig{ + DynamicClientRegistrationConfig: &DynamicClientRegistrationConfig{ + Metadata: &oauthex.ClientRegistrationMetadata{ + RedirectURIs: []string{ + "https://example.com/callback", + }, + }, + }, + RedirectURL: "https://example.com/callback", + AuthorizationCodeFetcher: simpleHandler, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := NewAuthorizationCodeHandler(tt.config); err != nil { + t.Fatalf("NewAuthorizationCodeHandler failed: %v", err) + } + }) + } +} + +func TestNewAuthorizationCodeHandler_Error(t *testing.T) { + validConfig := func() *AuthorizationCodeHandlerConfig { + return &AuthorizationCodeHandlerConfig{ + ClientIDMetadataDocumentConfig: &ClientIDMetadataDocumentConfig{URL: "https://example.com/client"}, + RedirectURL: "https://example.com/callback", + AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + return nil, nil + }, + } + } + // Ensure the base config is valid + if _, err := NewAuthorizationCodeHandler(validConfig()); err != nil { + t.Fatalf("NewAuthorizationCodeHandler failed: %v", err) + } + + tests := []struct { + name string + config func() *AuthorizationCodeHandlerConfig + }{ + { + name: "NilConfig", + config: func() *AuthorizationCodeHandlerConfig { + return nil + }, + }, + { + name: "NoRegistrationConfig", + config: func() *AuthorizationCodeHandlerConfig { + cfg := validConfig() + cfg.ClientIDMetadataDocumentConfig = nil + cfg.PreregisteredClientConfig = nil + cfg.DynamicClientRegistrationConfig = nil + return cfg + }, + }, + { + name: "MissingRedirectURL", + config: func() *AuthorizationCodeHandlerConfig { + cfg := validConfig() + cfg.RedirectURL = "" + return cfg + }, + }, + { + name: "MissingAuthorizationCodeFetcher", + config: func() *AuthorizationCodeHandlerConfig { + cfg := validConfig() + cfg.AuthorizationCodeFetcher = nil + return cfg + }, + }, + { + name: "InvalidMetadataURL", + config: func() *AuthorizationCodeHandlerConfig { + cfg := validConfig() + cfg.ClientIDMetadataDocumentConfig.URL = "https://example.com" + return cfg + }, + }, + { + name: "InvalidPreregistered_MissingSecretConfig", + config: func() *AuthorizationCodeHandlerConfig { + cfg := validConfig() + cfg.PreregisteredClientConfig = &PreregisteredClientConfig{} + return cfg + }, + }, + { + name: "InvalidPreregistered_EmptyID", + config: func() *AuthorizationCodeHandlerConfig { + cfg := validConfig() + cfg.PreregisteredClientConfig = &PreregisteredClientConfig{ + ClientSecretAuthConfig: &ClientSecretAuthConfig{ + ClientSecret: "secret", + }, + } + return cfg + }, + }, + { + name: "InvalidPreregistered_EmptySecret", + config: func() *AuthorizationCodeHandlerConfig { + cfg := validConfig() + cfg.PreregisteredClientConfig = &PreregisteredClientConfig{ + ClientSecretAuthConfig: &ClientSecretAuthConfig{ + ClientID: "test_client_id", + }, + } + return cfg + }, + }, + { + name: "InvalidDynamic_MissingMetadata", + config: func() *AuthorizationCodeHandlerConfig { + cfg := validConfig() + cfg.DynamicClientRegistrationConfig = &DynamicClientRegistrationConfig{} + return cfg + }, + }, + { + name: "InvalidDynamic_InconsistentRedirectURI", + config: func() *AuthorizationCodeHandlerConfig { + cfg := validConfig() + cfg.DynamicClientRegistrationConfig = &DynamicClientRegistrationConfig{ + Metadata: &oauthex.ClientRegistrationMetadata{ + RedirectURIs: []string{"https://example.com/callback1"}, + }, + } + cfg.RedirectURL = "https://example.com/callback2" + return cfg + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewAuthorizationCodeHandler(tt.config()) + if err == nil { + t.Errorf("NewAuthorizationCodeHandler() = nil, want error") + } + }) + } +} + +func TestGetProtectedResourceMetadata(t *testing.T) { + handler := &AuthorizationCodeHandler{} // No config needed for this method + pathForChallenge := "/protected-resource" + + tests := []struct { + name string + challengesProvided bool + prmPath string + resourcePath string + wantError bool + }{ + { + name: "FromChallenges", + challengesProvided: true, + prmPath: pathForChallenge, + resourcePath: "/resource", + wantError: false, + }, + { + name: "FallbackToEndpoint", + challengesProvided: false, + prmPath: "/.well-known/oauth-protected-resource/resource", + resourcePath: "/resource", + wantError: false, + }, + { + name: "FallbackToRoot", + challengesProvided: false, + prmPath: "/.well-known/oauth-protected-resource", + resourcePath: "", + wantError: false, + }, + { + name: "NoMetadata", + challengesProvided: false, + prmPath: "/incorrect", + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + resourceURL := server.URL + tt.resourcePath + metadata := &oauthex.ProtectedResourceMetadata{ + Resource: resourceURL, + ScopesSupported: []string{"read", "write"}, + } + mux.Handle(tt.prmPath, ProtectedResourceMetadataHandler(metadata)) + var challenges []oauthex.Challenge + if tt.challengesProvided { + challenges = []oauthex.Challenge{ + { + Scheme: "Bearer", + Params: map[string]string{ + "resource_metadata": server.URL + pathForChallenge, + }, + }, + } + } + + got, err := handler.getProtectedResourceMetadata(t.Context(), challenges, resourceURL) + if err != nil { + if !tt.wantError { + t.Fatalf("getProtectedResourceMetadata() error = %v, want nil", err) + } + return + } + if got == nil { + t.Fatal("getProtectedResourceMetadata() got nil, want metadata") + } + if diff := cmp.Diff(metadata, got); diff != "" { + t.Errorf("getProtectedResourceMetadata() metadata mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestGetAuthServerMetadata(t *testing.T) { + handler := &AuthorizationCodeHandler{} // No config needed for this method + + tests := []struct { + name string + authorizationAtMCPServer bool + issuerPath string + endpointConfig *oauthtest.MetadataEndpointConfig + }{ + { + name: "OAuthEndpoint_Root", + authorizationAtMCPServer: false, + issuerPath: "", + endpointConfig: &oauthtest.MetadataEndpointConfig{ + ServeOAuthInsertedEndpoint: true, + }, + }, + { + name: "OpenIDEndpoint_Root", + authorizationAtMCPServer: false, + issuerPath: "", + endpointConfig: &oauthtest.MetadataEndpointConfig{ + ServeOpenIDInsertedEndpoint: true, + }, + }, + { + name: "OAuthEndpoint_Path", + authorizationAtMCPServer: false, + issuerPath: "/oauth", + endpointConfig: &oauthtest.MetadataEndpointConfig{ + ServeOAuthInsertedEndpoint: true, + }, + }, + { + name: "OpenIDEndpoint_Path", + authorizationAtMCPServer: false, + issuerPath: "/openid", + endpointConfig: &oauthtest.MetadataEndpointConfig{ + ServeOpenIDInsertedEndpoint: true, + }, + }, + { + name: "OpenIDAppendedEndpoint_Path", + authorizationAtMCPServer: false, + issuerPath: "/openid", + endpointConfig: &oauthtest.MetadataEndpointConfig{ + ServeOpenIDAppendedEndpoint: true, + }, + }, + { + name: "FallbackToMCPServer", + authorizationAtMCPServer: true, + }, + { + name: "NoMetadata", + issuerPath: "", + endpointConfig: &oauthtest.MetadataEndpointConfig{ + // All metadata endpoints disabled. + ServeOAuthInsertedEndpoint: false, + ServeOpenIDInsertedEndpoint: false, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + IssuerPath: tt.issuerPath, + MetadataEndpointConfig: tt.endpointConfig, + }) + s.Start(t) + issuerURL := s.URL() + tt.issuerPath + resourceURL := "https://example.com/resource" + authServers := []string{issuerURL} + if tt.authorizationAtMCPServer { + resourceURL = issuerURL + authServers = nil + } + prm := &oauthex.ProtectedResourceMetadata{ + Resource: resourceURL, + AuthorizationServers: authServers, + } + + got, err := handler.getAuthServerMetadata(t.Context(), prm) + if err != nil { + t.Fatalf("getAuthServerMetadata() error = %v, want nil", err) + } + if got == nil { + t.Fatal("getAuthServerMetadata() got nil, want metadata") + } + if got.Issuer != issuerURL { + t.Errorf("getAuthServerMetadata() issuer = %q, want %q", got.Issuer, issuerURL) + } + }) + } +} + +func TestSelectTokenAuthMethod(t *testing.T) { + tests := []struct { + name string + supported []string + want oauth2.AuthStyle + }{ + { + name: "PostPreferredOverBasic", + supported: []string{"client_secret_basic", "client_secret_post"}, + want: oauth2.AuthStyleInParams, + }, + { + name: "BasicChosenIfPostNotSupported", + supported: []string{"private_key_jwt", "client_secret_basic"}, + want: oauth2.AuthStyleInHeader, + }, + { + name: "NoneSupported", + supported: []string{"private_key_jwt"}, + want: oauth2.AuthStyleAutoDetect, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := selectTokenAuthMethod(tt.supported) + if got != tt.want { + t.Errorf("selectTokenAuthMethod() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHandleRegistration(t *testing.T) { + tests := []struct { + name string + serverConfig *oauthtest.RegistrationConfig + handlerConfig *AuthorizationCodeHandlerConfig + asm *oauthex.AuthServerMeta + want *resolvedClientConfig + wantError bool + }{ + { + name: "ClientIDMetadataDocument", + serverConfig: &oauthtest.RegistrationConfig{ + ClientIDMetadataDocumentSupported: true, + }, + handlerConfig: &AuthorizationCodeHandlerConfig{ + ClientIDMetadataDocumentConfig: &ClientIDMetadataDocumentConfig{URL: "https://client.example.com"}, + }, + want: &resolvedClientConfig{ + registrationType: registrationTypeClientIDMetadataDocument, + clientID: "https://client.example.com", + }, + }, + { + name: "Preregistered", + serverConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "pre_client_id": { + Secret: "pre_client_secret", + }, + }, + }, + handlerConfig: &AuthorizationCodeHandlerConfig{ + PreregisteredClientConfig: &PreregisteredClientConfig{ + ClientSecretAuthConfig: &ClientSecretAuthConfig{ + ClientID: "pre_client_id", + ClientSecret: "pre_client_secret", + }, + }, + }, + want: &resolvedClientConfig{ + registrationType: registrationTypePreregistered, + clientID: "pre_client_id", + clientSecret: "pre_client_secret", + authStyle: oauth2.AuthStyleInParams, + }, + }, + { + name: "NoneSupported", + handlerConfig: &AuthorizationCodeHandlerConfig{ + ClientIDMetadataDocumentConfig: &ClientIDMetadataDocumentConfig{URL: "https://client.example.com"}, + }, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{RegistrationConfig: tt.serverConfig}) + s.Start(t) + handler := &AuthorizationCodeHandler{config: tt.handlerConfig} + asm, err := handler.getAuthServerMetadata(t.Context(), &oauthex.ProtectedResourceMetadata{ + AuthorizationServers: []string{s.URL()}, + }) + if err != nil { + t.Fatalf("getAuthServerMetadata() error = %v, want nil", err) + } + got, err := handler.handleRegistration(t.Context(), asm) + if err != nil { + if !tt.wantError { + t.Fatalf("handleRegistration() unexpected error = %v", err) + } + return + } + if got.registrationType != tt.want.registrationType { + t.Errorf("handleRegistration() registrationType = %v, want %v", got.registrationType, tt.want.registrationType) + } + if got.clientID != tt.want.clientID { + t.Errorf("handleRegistration() clientID = %q, want %q", got.clientID, tt.want.clientID) + } + if got.clientSecret != tt.want.clientSecret { + t.Errorf("handleRegistration() clientSecret = %q, want %q", got.clientSecret, tt.want.clientSecret) + } + if got.authStyle != tt.want.authStyle { + t.Errorf("handleRegistration() authStyle = %v, want %v", got.authStyle, tt.want.authStyle) + } + }) + } +} + +func TestDynamicRegistration(t *testing.T) { + s := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + RegistrationConfig: &oauthtest.RegistrationConfig{ + DynamicClientRegistrationEnabled: true, + }, + }) + s.Start(t) + handler := &AuthorizationCodeHandler{config: &AuthorizationCodeHandlerConfig{ + DynamicClientRegistrationConfig: &DynamicClientRegistrationConfig{ + Metadata: &oauthex.ClientRegistrationMetadata{}, + }, + }} + asm, err := handler.getAuthServerMetadata(t.Context(), &oauthex.ProtectedResourceMetadata{ + AuthorizationServers: []string{s.URL()}, + }) + if err != nil { + t.Fatalf("getAuthServerMetadata() error = %v, want nil", err) + } + got, err := handler.handleRegistration(t.Context(), asm) + if err != nil { + t.Fatalf("handleRegistration() error = %v, want nil", err) + } + if got.registrationType != registrationTypeDynamic { + t.Errorf("handleRegistration() registrationType = %v, want %v", got.registrationType, registrationTypeDynamic) + } + if got.clientID == "" { + t.Errorf("handleRegistration() clientID = %q, want non-empty", got.clientID) + } + if got.clientSecret == "" { + t.Errorf("handleRegistration() clientSecret = %q, want non-empty", got.clientSecret) + } + if got.authStyle != oauth2.AuthStyleInHeader { + t.Errorf("handleRegistration() authStyle = %v, want %v", got.authStyle, oauth2.AuthStyleInHeader) + } +} diff --git a/auth/client.go b/auth/client.go index acadc51..0af6963 100644 --- a/auth/client.go +++ b/auth/client.go @@ -2,122 +2,41 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. -//go:build mcp_go_client_oauth - package auth import ( - "bytes" - "errors" - "io" + "context" "net/http" - "sync" "golang.org/x/oauth2" ) -// An OAuthHandler conducts an OAuth flow and returns a [oauth2.TokenSource] if the authorization -// is approved, or an error if not. -// The handler receives the HTTP request and response that triggered the authentication flow. -// To obtain the protected resource metadata, call [oauthex.GetProtectedResourceMetadataFromHeader]. -type OAuthHandler func(req *http.Request, res *http.Response) (oauth2.TokenSource, error) +// OAuthHandler is an interface for handling OAuth flows. +// +// If a transport wishes to support OAuth 2 authorization, it should support +// being configured with an OAuthHandler. It should call the handler's +// TokenSource method whenever it sends an HTTP request to set the +// Authorization header. If a request fails with a 401 or 403, it should call +// Authorize, and if that returns nil, it should retry the request. It should +// not call Authorize after the second failure. See +// [github.com/modelcontextprotocol/go-sdk/mcp.StreamableClientTransport] +// for an example. +type OAuthHandler interface { + isOAuthHandler() -// HTTPTransport is an [http.RoundTripper] that follows the MCP -// OAuth protocol when it encounters a 401 Unauthorized response. -type HTTPTransport struct { - handler OAuthHandler - mu sync.Mutex // protects opts.Base - opts HTTPTransportOptions -} - -// NewHTTPTransport returns a new [*HTTPTransport]. -// The handler is invoked when an HTTP request results in a 401 Unauthorized status. -// It is called only once per transport. Once a TokenSource is obtained, it is used -// for the lifetime of the transport; subsequent 401s are not processed. -func NewHTTPTransport(handler OAuthHandler, opts *HTTPTransportOptions) (*HTTPTransport, error) { - if handler == nil { - return nil, errors.New("handler cannot be nil") - } - t := &HTTPTransport{ - handler: handler, - } - if opts != nil { - t.opts = *opts - } - if t.opts.Base == nil { - t.opts.Base = http.DefaultTransport - } - return t, nil -} - -// HTTPTransportOptions are options to [NewHTTPTransport]. -type HTTPTransportOptions struct { - // Base is the [http.RoundTripper] to use. - // If nil, [http.DefaultTransport] is used. - Base http.RoundTripper -} - -func (t *HTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { - t.mu.Lock() - base := t.opts.Base - t.mu.Unlock() - - var ( - // If haveBody is set, the request has a nontrivial body, and we need avoid - // reading (or closing) it multiple times. In that case, bodyBytes is its - // content. - haveBody bool - bodyBytes []byte - ) - if req.Body != nil && req.Body != http.NoBody { - // if we're setting Body, we must mutate first. - req = req.Clone(req.Context()) - haveBody = true - var err error - bodyBytes, err = io.ReadAll(req.Body) - if err != nil { - return nil, err - } - // Now that we've read the request body, http.RoundTripper requires that we - // close it. - req.Body.Close() // ignore error - req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) - } - - resp, err := base.RoundTrip(req) - if err != nil { - return nil, err - } - if resp.StatusCode != http.StatusUnauthorized { - return resp, nil - } - if _, ok := base.(*oauth2.Transport); ok { - // We failed to authorize even with a token source; give up. - return resp, nil - } - - resp.Body.Close() - // Try to authorize. - t.mu.Lock() - defer t.mu.Unlock() - // If we don't have a token source, get one by following the OAuth flow. - // (We may have obtained one while t.mu was not held above.) - // TODO: We hold the lock for the entire OAuth flow. This could be a long - // time. Is there a better way? - if _, ok := t.opts.Base.(*oauth2.Transport); !ok { - ts, err := t.handler(req, resp) - if err != nil { - return nil, err - } - t.opts.Base = &oauth2.Transport{Base: t.opts.Base, Source: ts} - } - - // If we don't have a body, the request is reusable, though it will be cloned - // by the base. However, if we've had to read the body, we must clone. - if haveBody { - req = req.Clone(req.Context()) - req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) - } - - return t.opts.Base.RoundTrip(req) + // TokenSource returns a token source to be used for outgoing requests. + // Returned token source might be nil. In that case, the transport will not + // add any authorization headers to the request. + TokenSource(context.Context) (oauth2.TokenSource, error) + + // Authorize is called when an HTTP request results in an error that may + // be addressed by the authorization flow (currently 401 Unauthorized and 403 Forbidden). + // It is responsible for performing the OAuth flow to obtain an access token. + // The arguments are the request that failed and the response that was received for it. + // The headers of the request are available, but the body will have already been consumed + // when Authorize is called. + // If the returned error is nil, TokenSource is expected to return a non-nil token source. + // After a successful call to Authorize, the HTTP request will be retried by the transport. + // The function is responsible for closing the response body. + Authorize(context.Context, *http.Request, *http.Response) error } diff --git a/auth/client_private.go b/auth/client_private.go new file mode 100644 index 0000000..767c59e --- /dev/null +++ b/auth/client_private.go @@ -0,0 +1,135 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +//go:build mcp_go_client_oauth + +package auth + +import ( + "bytes" + "errors" + "io" + "net/http" + "sync" + + "golang.org/x/oauth2" +) + +// An OAuthHandlerLegacy conducts an OAuth flow and returns a [oauth2.TokenSource] if the authorization +// is approved, or an error if not. +// The handler receives the HTTP request and response that triggered the authentication flow. +// To obtain the protected resource metadata, call [oauthex.GetProtectedResourceMetadataFromHeader]. +// +// Deprecated: Please use the new [OAuthHandler] abstraction that is built +// into the streamable transport. This struct will be removed in v1.5.0. +type OAuthHandlerLegacy func(req *http.Request, res *http.Response) (oauth2.TokenSource, error) + +// HTTPTransport is an [http.RoundTripper] that follows the MCP +// OAuth protocol when it encounters a 401 Unauthorized response. +// +// Deprecated: Please use the new [OAuthHandler] abstraction that is built +// into the streamable transport. This struct will be removed in v1.5.0. +type HTTPTransport struct { + handler OAuthHandlerLegacy + mu sync.Mutex // protects opts.Base + opts HTTPTransportOptions +} + +// NewHTTPTransport returns a new [*HTTPTransport]. +// The handler is invoked when an HTTP request results in a 401 Unauthorized status. +// It is called only once per transport. Once a TokenSource is obtained, it is used +// for the lifetime of the transport; subsequent 401s are not processed. +// +// Deprecated: Please use the new [OAuthHandler] abstraction that is built +// into the streamable transport. This struct will be removed in v1.5.0. +func NewHTTPTransport(handler OAuthHandlerLegacy, opts *HTTPTransportOptions) (*HTTPTransport, error) { + if handler == nil { + return nil, errors.New("handler cannot be nil") + } + t := &HTTPTransport{ + handler: handler, + } + if opts != nil { + t.opts = *opts + } + if t.opts.Base == nil { + t.opts.Base = http.DefaultTransport + } + return t, nil +} + +// HTTPTransportOptions are options to [NewHTTPTransport]. +// +// Deprecated: Please use the new [OAuthHandler] abstraction that is built +// into the streamable transport. This struct will be removed in v1.5.0. +type HTTPTransportOptions struct { + // Base is the [http.RoundTripper] to use. + // If nil, [http.DefaultTransport] is used. + Base http.RoundTripper +} + +func (t *HTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { + t.mu.Lock() + base := t.opts.Base + t.mu.Unlock() + + var ( + // If haveBody is set, the request has a nontrivial body, and we need avoid + // reading (or closing) it multiple times. In that case, bodyBytes is its + // content. + haveBody bool + bodyBytes []byte + ) + if req.Body != nil && req.Body != http.NoBody { + // if we're setting Body, we must mutate first. + req = req.Clone(req.Context()) + haveBody = true + var err error + bodyBytes, err = io.ReadAll(req.Body) + if err != nil { + return nil, err + } + // Now that we've read the request body, http.RoundTripper requires that we + // close it. + req.Body.Close() // ignore error + req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + } + + resp, err := base.RoundTrip(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusUnauthorized { + return resp, nil + } + if _, ok := base.(*oauth2.Transport); ok { + // We failed to authorize even with a token source; give up. + return resp, nil + } + + resp.Body.Close() + // Try to authorize. + t.mu.Lock() + defer t.mu.Unlock() + // If we don't have a token source, get one by following the OAuth flow. + // (We may have obtained one while t.mu was not held above.) + // TODO: We hold the lock for the entire OAuth flow. This could be a long + // time. Is there a better way? + if _, ok := t.opts.Base.(*oauth2.Transport); !ok { + ts, err := t.handler(req, resp) + if err != nil { + return nil, err + } + t.opts.Base = &oauth2.Transport{Base: t.opts.Base, Source: ts} + } + + // If we don't have a body, the request is reusable, though it will be cloned + // by the base. However, if we've had to read the body, we must clone. + if haveBody { + req = req.Clone(req.Context()) + req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + } + + return t.opts.Base.RoundTrip(req) +} diff --git a/conformance/baseline.yml b/conformance/baseline.yml index ae1f9c6..9d7bef8 100644 --- a/conformance/baseline.yml +++ b/conformance/baseline.yml @@ -1,20 +1,2 @@ server: [] # All tests pass! -client: -- auth/basic-cimd -- auth/metadata-default -- auth/metadata-var1 -- auth/metadata-var2 -- auth/metadata-var3 -- auth/2025-03-26-oauth-metadata-backcompat -- auth/2025-03-26-oauth-endpoint-fallback -- auth/scope-from-www-authenticate -- auth/scope-from-scopes-supported -- auth/scope-omitted-when-undefined -- auth/scope-step-up -- auth/scope-retry-limit -- auth/token-endpoint-auth-basic -- auth/token-endpoint-auth-post -- auth/token-endpoint-auth-none -- auth/client-credentials-jwt -- auth/client-credentials-basic -- auth/pre-registration +client: [] # All tests pass! diff --git a/conformance/everything-client/client_private.go b/conformance/everything-client/client_private.go new file mode 100644 index 0000000..3b0c659 --- /dev/null +++ b/conformance/everything-client/client_private.go @@ -0,0 +1,139 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// The conformance client implements features required for MCP conformance testing. +// It mirrors the functionality of the TypeScript conformance client at +// https://github.com/modelcontextprotocol/typescript-sdk/blob/main/src/conformance/everything-client.ts + +//go:build mcp_go_client_oauth + +package main + +import ( + "context" + "fmt" + "net/http" + "net/url" + + "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/modelcontextprotocol/go-sdk/oauthex" +) + +func init() { + authScenarios := []string{ + "auth/2025-03-26-oauth-metadata-backcompat", + "auth/2025-03-26-oauth-endpoint-fallback", + "auth/basic-cimd", + "auth/metadata-default", + "auth/metadata-var1", + "auth/metadata-var2", + "auth/metadata-var3", + "auth/pre-registration", + "auth/resource-mismatch", + "auth/scope-from-www-authenticate", + "auth/scope-from-scopes-supported", + "auth/scope-omitted-when-undefined", + "auth/scope-step-up", + "auth/scope-retry-limit", + "auth/token-endpoint-auth-basic", + "auth/token-endpoint-auth-post", + "auth/token-endpoint-auth-none", + } + for _, scenario := range authScenarios { + registerScenario(scenario, runAuthClient) + } +} + +// ============================================================================ +// Auth scenarios +// ============================================================================ + +func fetchAuthorizationCodeAndState(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + req, err := http.NewRequestWithContext(ctx, "GET", args.URL, nil) + if err != nil { + return nil, err + } + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // In conformance tests the authorization server immediately redirects + // to the callback URL with the authorization code and state. + locURL, err := url.Parse(resp.Header.Get("Location")) + if err != nil { + return nil, fmt.Errorf("parse location: %v", err) + } + + return &auth.AuthorizationResult{ + Code: locURL.Query().Get("code"), + State: locURL.Query().Get("state"), + }, nil +} + +func runAuthClient(ctx context.Context, serverURL string, configCtx map[string]any) error { + authConfig := &auth.AuthorizationCodeHandlerConfig{ + RedirectURL: "http://localhost:3000/callback", + AuthorizationCodeFetcher: fetchAuthorizationCodeAndState, + // Try client ID metadata document based registration. + ClientIDMetadataDocumentConfig: &auth.ClientIDMetadataDocumentConfig{ + URL: "https://conformance-test.local/client-metadata.json", + }, + // Try dynamic client registration. + DynamicClientRegistrationConfig: &auth.DynamicClientRegistrationConfig{ + Metadata: &oauthex.ClientRegistrationMetadata{ + RedirectURIs: []string{"http://localhost:3000/callback"}, + }, + }, + } + // Try pre-registered client information if provided in the context. + if clientID, ok := configCtx["client_id"].(string); ok { + if clientSecret, ok := configCtx["client_secret"].(string); ok { + authConfig.PreregisteredClientConfig = &auth.PreregisteredClientConfig{ + ClientSecretAuthConfig: &auth.ClientSecretAuthConfig{ + ClientID: clientID, + ClientSecret: clientSecret, + }, + } + } + } + + authHandler, err := auth.NewAuthorizationCodeHandler(authConfig) + if err != nil { + return fmt.Errorf("failed to create auth handler: %w", err) + } + + session, err := connectToServer(ctx, serverURL, withOAuthHandler(authHandler)) + if err != nil { + return err + } + defer session.Close() + + if _, err := session.ListTools(ctx, nil); err != nil { + return fmt.Errorf("session.ListTools(): %v", err) + } + + if _, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "test-tool", + Arguments: map[string]any{}, + }); err != nil { + return fmt.Errorf("session.CallTool('test-tool'): %v", err) + } + + return nil +} + +func withOAuthHandler(handler auth.OAuthHandler) connectOption { + return func(c *connectConfig) { + c.oauthHandler = handler + } +} diff --git a/conformance/everything-client/main.go b/conformance/everything-client/main.go index 9674dbb..c05fa6f 100644 --- a/conformance/everything-client/main.go +++ b/conformance/everything-client/main.go @@ -9,6 +9,7 @@ package main import ( "context" + "encoding/json" "fmt" "log" "os" @@ -16,12 +17,13 @@ import ( "sort" "strings" + "github.com/modelcontextprotocol/go-sdk/auth" "github.com/modelcontextprotocol/go-sdk/mcp" ) // scenarioHandler is the function signature for all conformance test scenarios. // It takes a context and the server URL to connect to. -type scenarioHandler func(ctx context.Context, serverURL string) error +type scenarioHandler func(ctx context.Context, serverURL string, configCtx map[string]any) error var ( // registry stores all registered scenario handlers. @@ -48,7 +50,7 @@ func init() { // Basic scenarios // ============================================================================ -func runBasicClient(ctx context.Context, serverURL string) error { +func runBasicClient(ctx context.Context, serverURL string, _ map[string]any) error { session, err := connectToServer(ctx, serverURL) if err != nil { return err @@ -63,7 +65,7 @@ func runBasicClient(ctx context.Context, serverURL string) error { return nil } -func runToolsCallClient(ctx context.Context, serverURL string) error { +func runToolsCallClient(ctx context.Context, serverURL string, _ map[string]any) error { session, err := connectToServer(ctx, serverURL) if err != nil { return err @@ -97,7 +99,7 @@ func runToolsCallClient(ctx context.Context, serverURL string) error { // Elicitation scenarios // ============================================================================ -func runElicitationDefaultsClient(ctx context.Context, serverURL string) error { +func runElicitationDefaultsClient(ctx context.Context, serverURL string, _ map[string]any) error { elicitationHandler := func(ctx context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { return &mcp.ElicitResult{ Action: "accept", @@ -141,8 +143,7 @@ func runElicitationDefaultsClient(ctx context.Context, serverURL string) error { // SSE retry scenario // ============================================================================ -func runSSERetryClient(ctx context.Context, serverURL string) error { - // TODO: this scenario is not passing yet. It requires a fix in the client SSE handling. +func runSSERetryClient(ctx context.Context, serverURL string, _ map[string]any) error { session, err := connectToServer(ctx, serverURL) if err != nil { return err @@ -185,6 +186,7 @@ func main() { serverURL := os.Args[1] scenarioName := os.Getenv("MCP_CONFORMANCE_SCENARIO") + configCtx := getConformanceContext() if scenarioName == "" { printUsageAndExit("MCP_CONFORMANCE_SCENARIO not set") @@ -196,11 +198,21 @@ func main() { } ctx := context.Background() - if err := handler(ctx, serverURL); err != nil { + if err := handler(ctx, serverURL, configCtx); err != nil { log.Fatalf("Scenario %q failed: %v", scenarioName, err) } } +func getConformanceContext() map[string]any { + ctxStr := os.Getenv("MCP_CONFORMANCE_CONTEXT") + if ctxStr == "" { + return nil + } + var ctx map[string]any + _ = json.Unmarshal([]byte(ctxStr), &ctx) + return ctx +} + func printUsageAndExit(format string, args ...any) { var scenarios []string for name := range registry { @@ -214,6 +226,7 @@ func printUsageAndExit(format string, args ...any) { type connectConfig struct { clientOptions *mcp.ClientOptions + oauthHandler auth.OAuthHandler } type connectOption func(*connectConfig) @@ -237,11 +250,14 @@ func connectToServer(ctx context.Context, serverURL string, opts ...connectOptio Version: "1.0.0", }, config.clientOptions) - transport := &mcp.StreamableClientTransport{Endpoint: serverURL} + transport := &mcp.StreamableClientTransport{ + Endpoint: serverURL, + OAuthHandler: config.oauthHandler, + } session, err := client.Connect(ctx, transport, nil) if err != nil { - return nil, fmt.Errorf("client.Connect(): %v", err) + return nil, fmt.Errorf("client.Connect(): %w", err) } return session, nil diff --git a/docs/protocol.md b/docs/protocol.md index 16ba0bf..abdf50f 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -306,9 +306,55 @@ The [_auth middleware example_](https://github.com/modelcontextprotocol/go-sdk/ ### Client -Client-side OAuth is implemented by setting -[`StreamableClientTransport.HTTPClient`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk@v0.5.0/mcp#StreamableClientTransport.HTTPClient) to a custom [`http.Client`](https://pkg.go.dev/net/http#Client) -Additional support is forthcoming; see modelcontextprotocol/go-sdk#493. +> [!IMPORTANT] +> Client-side OAuth support is currently experimental and requires the `mcp_go_client_oauth` build tag to compile. +> API changes may still be made, based on developer feedback. The build tag will be removed in `v1.5.0`, which +> is planned to be released by the end of March 2026. + +Client-side authorization is supported via the +[`StreamableClientTransport.OAuthHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#StreamableClientTransport.OAuthHandler) +field. If the handler is provided, the transport will automatically use it to +add an `Authorization: Bearer ` header to every request. The transport +will also call the handler's `Authorize` method if the server returns +`401 Unauthorized` or `403 Forbidden` errors to perform the authorization flow +or facilitate scope step-up authorization. + +The SDK implements the Authorization Code flow in +[`auth.AuthorizationCodeHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#AuthorizationCodeHandler). +This handler supports: + +- [Client ID Metadata Documents](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#client-id-metadata-documents) +- [Pre-registered clients](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration) +- [Dynamic Client Registration](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#dynamic-client-registration) + +To use it, configure the handler and assign it to the transport: + +```go +authHandler, _ := auth.NewAuthorizationCodeHandler(&auth.AuthorizationCodeHandlerConfig{ + RedirectURL: "https://myapp.com/oauth2-callback", + // Configure one of the following: + // ClientIDMetadataDocumentConfig: ... + // PreregisteredClientConfig: ... + // DynamicClientRegistrationConfig: ... + AuthorizationCodeFetcher: func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { + // Open the args.URL in a browser and return the resulting code and state. + // See full example in examples/auth/client/main.go. + code := ... + state := ... + return &auth.AuthorizationResult{Code: code, State: state}, nil + }, +}) + +transport := &mcp.StreamableClientTransport{ + Endpoint: "https://example.com/mcp", + OAuthHandler: authHandler, +} +client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) +session, err := client.Connect(ctx, transport, nil) +``` + +The `auth.AuthorizationCodeHandler` automatically manages token refreshing +and step-up authentication (when the server returns `insufficient_scope` error). ## Security @@ -317,9 +363,12 @@ the MCP spec's [Security Best Practices](https://modelcontextprotocol.io/specifi ### Confused Deputy -The [mitigation](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#mitigation), obtaining user consent for dynamically registered clients, -happens on the MCP client. At present we don't provide client-side OAuth support. - +The [mitigation](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#mitigation), +obtaining user consent for dynamically registered clients, is mostly the +responsibility of the MCP Proxy server implementation. The SDK client does +generate cryptographically secure random `state` values for each authorization +request by default and validates them when the authorization code is returned. +Mismatched state values will result in an error. ### Token Passthrough diff --git a/examples/auth/client/main.go b/examples/auth/client/main.go new file mode 100644 index 0000000..32de488 --- /dev/null +++ b/examples/auth/client/main.go @@ -0,0 +1,130 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +//go:build mcp_go_client_oauth + +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log" + "net" + "net/http" + + "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var ( + // URL of the MCP server. + serverURL = flag.String("server_url", "http://localhost:8000/mcp", "URL of the MCP server.") + // Port for the local HTTP server that will receive the authorization code. + callbackPort = flag.Int("callback_port", 3142, "Port for the local HTTP server that will receive the authorization code.") +) + +type codeReceiver struct { + authChan chan *auth.AuthorizationResult + errChan chan error + listener net.Listener + server *http.Server +} + +func (r *codeReceiver) serveRedirectHandler(listener net.Listener) { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + r.authChan <- &auth.AuthorizationResult{ + Code: req.URL.Query().Get("code"), + State: req.URL.Query().Get("state"), + } + fmt.Fprint(w, "Authentication successful. You can close this window.") + }) + + r.server = &http.Server{ + Addr: fmt.Sprintf("localhost:%d", *callbackPort), + Handler: mux, + } + if err := r.server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + r.errChan <- err + } +} + +func (r *codeReceiver) getAuthorizationCode(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { + select { + case authRes := <-r.authChan: + return authRes, nil + case err := <-r.errChan: + return nil, err + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (r *codeReceiver) close() { + if r.server != nil { + r.server.Close() + } +} + +func main() { + flag.Parse() + receiver := &codeReceiver{ + authChan: make(chan *auth.AuthorizationResult), + errChan: make(chan error), + } + listener, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", *callbackPort)) + if err != nil { + log.Fatalf("failed to listen: %v", err) + } + go receiver.serveRedirectHandler(listener) + defer receiver.close() + + authHandler, err := auth.NewAuthorizationCodeHandler(&auth.AuthorizationCodeHandlerConfig{ + RedirectURL: fmt.Sprintf("http://localhost:%d", *callbackPort), + AuthorizationCodeFetcher: receiver.getAuthorizationCode, + // Uncomment the client configuration you want to use. + // PreregisteredClientConfig: &auth.PreregisteredClientConfig{ + // ClientID: "", + // ClientSecret: "", + // }, + // DynamicClientRegistrationConfig: &auth.DynamicClientRegistrationConfig{ + // Metadata: &oauthex.ClientRegistrationMetadata{ + // ClientName: "Dynamically registered MCP client", + // RedirectURIs: []string{fmt.Sprintf("http://localhost:%d", *callbackPort)}, + // Scope: "read", + // }, + // }, + }) + if err != nil { + log.Fatalf("failed to create auth handler: %v", err) + } + + transport := &mcp.StreamableClientTransport{ + Endpoint: *serverURL, + OAuthHandler: authHandler, + } + + ctx := context.Background() + client := mcp.NewClient(&mcp.Implementation{ + Name: "test-client", + Version: "1.0.0", + }, nil) + + session, err := client.Connect(ctx, transport, nil) + if err != nil { + log.Fatalf("client.Connect(): %v", err) + } + defer session.Close() + + tools, err := session.ListTools(ctx, nil) + if err != nil { + log.Fatalf("session.ListTools(): %v", err) + } + log.Println("Tools:") + for _, tool := range tools.Tools { + log.Printf("- %q", tool.Name) + } +} diff --git a/examples/auth/server/main.go b/examples/auth/server/main.go new file mode 100644 index 0000000..94ad9ae --- /dev/null +++ b/examples/auth/server/main.go @@ -0,0 +1,167 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "time" + + "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/modelcontextprotocol/go-sdk/oauthex" +) + +// Flags. +var ( + port = flag.Int("port", 8000, "Port to listen on") +) + +// Configuration required for this example. +var ( + // Authorization server to return in the protected resource metadata. + authorizationServer = "" + // Introspection endpoint for verifying tokens. + introspectionEndpoint = "" + // Client credentials used in the introspection request. + clientID = "" + clientSecret = "" +) + +func verifyToken(ctx context.Context, token string, _ *http.Request) (*auth.TokenInfo, error) { + data := url.Values{} + data.Set("token", token) + data.Set("token_type_hint", "access_token") + + req, err := http.NewRequestWithContext(ctx, "POST", introspectionEndpoint, strings.NewReader(data.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + req.SetBasicAuth(clientID, clientSecret) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + dump, _ := httputil.DumpResponse(resp, true) + log.Printf("Introspection failed: %s", dump) + return nil, fmt.Errorf("introspection failed with status %d", resp.StatusCode) + } + + var result struct { + Active bool `json:"active"` + Scope string `json:"scope"` + Exp int64 `json:"exp"` + Sub string `json:"sub"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + if !result.Active { + return nil, auth.ErrInvalidToken + } + + return &auth.TokenInfo{ + Scopes: strings.Fields(result.Scope), + Expiration: time.Unix(result.Exp, 0), + UserID: result.Sub, + }, nil +} + +type args struct { + Input string `json:"input"` +} + +func echo(ctx context.Context, req *mcp.CallToolRequest, args args) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: args.Input}, + }, + }, nil, nil +} + +func main() { + flag.Parse() + metadata := &oauthex.ProtectedResourceMetadata{ + Resource: fmt.Sprintf("http://localhost:%d/mcp", *port), + AuthorizationServers: []string{authorizationServer}, + ScopesSupported: []string{"read"}, + } + http.Handle("/.well-known/oauth-protected-resource", auth.ProtectedResourceMetadataHandler(metadata)) + + server := mcp.NewServer(&mcp.Implementation{ + Name: "test-server", + Version: "1.0.0", + }, nil) + server.AddReceivingMiddleware(createLoggingMiddleware()) + mcp.AddTool(server, &mcp.Tool{Name: "echo"}, echo) + + handler := mcp.NewStreamableHTTPHandler(func(req *http.Request) *mcp.Server { + return server + }, nil) + + authMiddleware := auth.RequireBearerToken(verifyToken, &auth.RequireBearerTokenOptions{ + Scopes: []string{"read"}, + ResourceMetadataURL: fmt.Sprintf("http://localhost:%d/.well-known/oauth-protected-resource", *port), + }) + + http.Handle("/mcp", authMiddleware(handler)) + + log.Printf("Starting server on http://localhost:%d", *port) + log.Fatal(http.ListenAndServe(fmt.Sprintf("localhost:%d", *port), nil)) +} + +// createLoggingMiddleware creates an MCP middleware that logs method calls. +func createLoggingMiddleware() mcp.Middleware { + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func( + ctx context.Context, + method string, + req mcp.Request, + ) (mcp.Result, error) { + start := time.Now() + sessionID := req.GetSession().ID() + + // Log request details. + log.Printf("[REQUEST] Session: %s | Method: %s", + sessionID, + method) + + // Call the actual handler. + result, err := next(ctx, method, req) + + // Log response details. + duration := time.Since(start) + + if err != nil { + log.Printf("[RESPONSE] Session: %s | Method: %s | Status: ERROR | Duration: %v | Error: %v", + sessionID, + method, + duration, + err) + } else { + log.Printf("[RESPONSE] Session: %s | Method: %s | Status: OK | Duration: %v", + sessionID, + method, + duration) + } + + return result, err + } + } +} diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index ada3437..3771b58 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -232,9 +232,55 @@ The [_auth middleware example_](https://github.com/modelcontextprotocol/go-sdk/ ### Client -Client-side OAuth is implemented by setting -[`StreamableClientTransport.HTTPClient`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk@v0.5.0/mcp#StreamableClientTransport.HTTPClient) to a custom [`http.Client`](https://pkg.go.dev/net/http#Client) -Additional support is forthcoming; see modelcontextprotocol/go-sdk#493. +> [!IMPORTANT] +> Client-side OAuth support is currently experimental and requires the `mcp_go_client_oauth` build tag to compile. +> API changes may still be made, based on developer feedback. The build tag will be removed in `v1.5.0`, which +> is planned to be released by the end of March 2026. + +Client-side authorization is supported via the +[`StreamableClientTransport.OAuthHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#StreamableClientTransport.OAuthHandler) +field. If the handler is provided, the transport will automatically use it to +add an `Authorization: Bearer ` header to every request. The transport +will also call the handler's `Authorize` method if the server returns +`401 Unauthorized` or `403 Forbidden` errors to perform the authorization flow +or facilitate scope step-up authorization. + +The SDK implements the Authorization Code flow in +[`auth.AuthorizationCodeHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#AuthorizationCodeHandler). +This handler supports: + +- [Client ID Metadata Documents](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#client-id-metadata-documents) +- [Pre-registered clients](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration) +- [Dynamic Client Registration](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#dynamic-client-registration) + +To use it, configure the handler and assign it to the transport: + +```go +authHandler, _ := auth.NewAuthorizationCodeHandler(&auth.AuthorizationCodeHandlerConfig{ + RedirectURL: "https://myapp.com/oauth2-callback", + // Configure one of the following: + // ClientIDMetadataDocumentConfig: ... + // PreregisteredClientConfig: ... + // DynamicClientRegistrationConfig: ... + AuthorizationCodeFetcher: func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { + // Open the args.URL in a browser and return the resulting code and state. + // See full example in examples/auth/client/main.go. + code := ... + state := ... + return &auth.AuthorizationResult{Code: code, State: state}, nil + }, +}) + +transport := &mcp.StreamableClientTransport{ + Endpoint: "https://example.com/mcp", + OAuthHandler: authHandler, +} +client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) +session, err := client.Connect(ctx, transport, nil) +``` + +The `auth.AuthorizationCodeHandler` automatically manages token refreshing +and step-up authentication (when the server returns `insufficient_scope` error). ## Security @@ -243,9 +289,12 @@ the MCP spec's [Security Best Practices](https://modelcontextprotocol.io/specifi ### Confused Deputy -The [mitigation](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#mitigation), obtaining user consent for dynamically registered clients, -happens on the MCP client. At present we don't provide client-side OAuth support. - +The [mitigation](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#mitigation), +obtaining user consent for dynamically registered clients, is mostly the +responsibility of the MCP Proxy server implementation. The SDK client does +generate cryptographically secure random `state` values for each authorization +request by default and validates them when the authorization code is returned. +Mismatched state values will result in an error. ### Token Passthrough diff --git a/internal/oauthtest/fake_authorization_server.go b/internal/oauthtest/fake_authorization_server.go new file mode 100644 index 0000000..1e81f10 --- /dev/null +++ b/internal/oauthtest/fake_authorization_server.go @@ -0,0 +1,304 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +//go:build mcp_go_client_oauth + +package oauthtest + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "net/http" + "net/http/httptest" + "slices" + "testing" + + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" + "github.com/modelcontextprotocol/go-sdk/oauthex" +) + +type ClientInfo struct { + Secret string + RedirectURIs []string +} + +type MetadataEndpointConfig struct { + // Whether to serve the OAuth Authorization Server Metadata at + // /.well-known/oauth-authorization-server + issuerPath. + ServeOAuthInsertedEndpoint bool + // Whether to serve the OAuth Authorization Server Metadata at + // /.well-known/openid-configuration + issuerPath. + ServeOpenIDInsertedEndpoint bool + // Whether to serve the OAuth Authorization Server Metadata at + // issuerPath + /.well-known/openid-configuration. + // Should be used when issuerPath is not empty. + ServeOpenIDAppendedEndpoint bool +} + +type RegistrationConfig struct { + // Whether the client ID metadata document is supported. + ClientIDMetadataDocumentSupported bool + // PreregisteredClients is a map of valid ClientIDs to ClientSecrets. + PreregisteredClients map[string]ClientInfo + // Whether dynamic client registration is enabled. + DynamicClientRegistrationEnabled bool +} + +// Config holds configuration for FakeAuthorizationServer. +type Config struct { + // The optional path component of the issuer URL. + // If non-empty, it should start with a "/". It should not end with a "/". + // It affects the paths of the server endpoints. + IssuerPath string + // Configuration of the metadata endpoint. + MetadataEndpointConfig *MetadataEndpointConfig + // Configuration for client registration. + RegistrationConfig *RegistrationConfig +} + +// FakeAuthorizationServer is a fake OAuth 2.0 Authorization Server for testing. +type FakeAuthorizationServer struct { + server *httptest.Server + Mux *http.ServeMux + config Config + clients map[string]ClientInfo + codes map[string]codeInfo +} + +type codeInfo struct { + CodeChallenge string +} + +// NewFakeAuthorizationServer creates a new FakeAuthorizationServer. +// The server is simple and should not be used outside of testing. +// It supports: +// - Only the authorization Code Grant +// - PKCE verification +// - Client tracking & dynamic registration +// - Client authentication +func NewFakeAuthorizationServer(config Config) *FakeAuthorizationServer { + s := &FakeAuthorizationServer{ + Mux: http.NewServeMux(), + config: config, + codes: make(map[string]codeInfo), + } + if config.RegistrationConfig != nil { + s.clients = maps.Clone(config.RegistrationConfig.PreregisteredClients) + } + if s.clients == nil { + s.clients = make(map[string]ClientInfo) + } + + s.Mux.HandleFunc(s.config.IssuerPath+"/authorize", s.handleAuthorize) + s.Mux.HandleFunc(s.config.IssuerPath+"/token", s.handleToken) + if config.MetadataEndpointConfig != nil { + if config.MetadataEndpointConfig.ServeOAuthInsertedEndpoint { + s.Mux.HandleFunc("/.well-known/oauth-authorization-server"+s.config.IssuerPath, s.handleMetadata) + } + if config.MetadataEndpointConfig.ServeOpenIDInsertedEndpoint { + s.Mux.HandleFunc("/.well-known/openid-configuration"+s.config.IssuerPath, s.handleMetadata) + } + if config.MetadataEndpointConfig.ServeOpenIDAppendedEndpoint && s.config.IssuerPath != "" { + s.Mux.HandleFunc(s.config.IssuerPath+"/.well-known/openid-configuration", s.handleMetadata) + } + } else { + // Serve the default OAuth endpoint. + s.Mux.HandleFunc("/.well-known/oauth-authorization-server", s.handleMetadata) + } + if config.RegistrationConfig != nil && config.RegistrationConfig.DynamicClientRegistrationEnabled { + s.Mux.HandleFunc(s.config.IssuerPath+"/register", s.handleRegister) + } + s.server = httptest.NewUnstartedServer(s.Mux) + + return s +} + +// Start starts the HTTP server and registers a cleanup function on t to close the server. +func (s *FakeAuthorizationServer) Start(t testing.TB) { + s.server.Start() + t.Cleanup(s.server.Close) +} + +// URL returns the base URL of the server (Issuer). +func (s *FakeAuthorizationServer) URL() string { + return s.server.URL +} + +func (s *FakeAuthorizationServer) handleMetadata(w http.ResponseWriter, r *http.Request) { + cimdSupported := false + var registrationEndpoint string + if s.config.RegistrationConfig != nil { + cimdSupported = s.config.RegistrationConfig.ClientIDMetadataDocumentSupported + if s.config.RegistrationConfig.DynamicClientRegistrationEnabled { + registrationEndpoint = s.URL() + s.config.IssuerPath + "/register" + } + } + meta := &oauthex.AuthServerMeta{ + Issuer: s.URL() + s.config.IssuerPath, + AuthorizationEndpoint: s.URL() + s.config.IssuerPath + "/authorize", + TokenEndpoint: s.URL() + s.config.IssuerPath + "/token", + RegistrationEndpoint: registrationEndpoint, + ResponseTypesSupported: []string{"code"}, + CodeChallengeMethodsSupported: []string{"S256"}, + ClientIDMetadataDocumentSupported: cimdSupported, + TokenEndpointAuthMethodsSupported: []string{"client_secret_post", "client_secret_basic"}, + } + // Set CORS headers for cross-origin client discovery. + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + // Handle CORS preflight requests + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + // Only GET allowed for metadata retrieval + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(meta); err != nil { + http.Error(w, "Failed to encode metadata", http.StatusInternalServerError) + return + } +} + +func (s *FakeAuthorizationServer) handleRegister(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var metadata oauthex.ClientRegistrationMetadata + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read request body", http.StatusBadRequest) + return + } + if err := internaljson.Unmarshal(body, &metadata); err != nil { + http.Error(w, "failed to parse request", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + clientID := rand.Text() + ci := ClientInfo{ + Secret: rand.Text(), + RedirectURIs: metadata.RedirectURIs, + } + s.clients[clientID] = ci + metadata.TokenEndpointAuthMethod = "client_secret_basic" + json.NewEncoder(w).Encode(&oauthex.ClientRegistrationResponse{ + ClientID: clientID, + ClientSecret: ci.Secret, + ClientRegistrationMetadata: metadata, + }) +} + +func (s *FakeAuthorizationServer) handleAuthorize(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + clientID := r.URL.Query().Get("client_id") + clientInfo, ok := s.clients[clientID] + if !ok { + http.Error(w, "unknown client_id", http.StatusBadRequest) + return + } + + redirectURI := r.URL.Query().Get("redirect_uri") + if redirectURI == "" { + http.Error(w, "missing redirect_uri", http.StatusBadRequest) + return + } + if !slices.Contains(clientInfo.RedirectURIs, redirectURI) { + http.Error(w, "invalid redirect_uri", http.StatusBadRequest) + return + } + codeChallenge := r.URL.Query().Get("code_challenge") + if codeChallenge == "" { + http.Error(w, "missing code_challenge", http.StatusBadRequest) + return + } + code := rand.Text() + s.codes[code] = codeInfo{ + CodeChallenge: codeChallenge, + } + + state := r.URL.Query().Get("state") + + redirectURL := fmt.Sprintf("%s?code=%s&state=%s", redirectURI, code, state) + http.Redirect(w, r, redirectURL, http.StatusFound) +} + +func (s *FakeAuthorizationServer) handleToken(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "failed to parse form", http.StatusBadRequest) + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if err := s.authenticateClient(r); err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + if r.Form.Get("grant_type") != "authorization_code" { + http.Error(w, "invalid grant_type", http.StatusBadRequest) + return + } + code := r.Form.Get("code") + if code == "" { + http.Error(w, "missing code", http.StatusBadRequest) + return + } + codeInfo, ok := s.codes[code] + if !ok { + http.Error(w, "unknown authorization code", http.StatusBadRequest) + return + } + verifier := r.Form.Get("code_verifier") + if verifier == "" { + http.Error(w, "missing code_verifier", http.StatusBadRequest) + return + } + sha := sha256.Sum256([]byte(verifier)) + expectedChallenge := base64.RawURLEncoding.EncodeToString(sha[:]) + if expectedChallenge != codeInfo.CodeChallenge { + http.Error(w, "PKCE verification failed", http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "test_access_token", + "token_type": "Bearer", + "expires_in": 3600, + }) +} + +func (s *FakeAuthorizationServer) authenticateClient(r *http.Request) error { + clientID, clientSecret, ok := r.BasicAuth() + if !ok { + clientID = r.Form.Get("client_id") + clientSecret = r.Form.Get("client_secret") + } + + clientInfo, ok := s.clients[clientID] + if !ok || clientInfo.Secret != clientSecret { + return errors.New("client not found") + } + return nil +} diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md index fce0fa4..d419b02 100644 --- a/internal/readme/README.src.md +++ b/internal/readme/README.src.md @@ -33,10 +33,15 @@ contains feature documentation, mapping the MCP spec to the packages above. The following table shows which versions of the Go SDK support which versions of the MCP specification: -| SDK Version | Latest MCP Spec | All Supported MCP Specs | -|-----------------|-------------------|------------------------------------------------| -| v1.2.0+ | 2025-06-18 | 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05 | -| v1.0.0 - v1.1.0 | 2025-06-18 | 2025-06-18, 2025-03-26, 2024-11-05 | +| SDK Version | Latest MCP Spec | All Supported MCP Specs | +|-----------------|-------------------|----------------------------------------------------| +| v1.4.0+ | 2025-11-25\* | 2025-11-25\*, 2025-06-18, 2025-03-26, 2024-11-05 | +| v1.2.0 - v1.3.1 | 2025-11-25\*\* | 2025-11-25\*\*, 2025-06-18, 2025-03-26, 2024-11-05 | +| v1.0.0 - v1.1.0 | 2025-06-18 | 2025-06-18, 2025-03-26, 2024-11-05 | + +\* Client side OAuth has experimental support. + +\*\* Partial support for 2025-11-25 (client side OAuth and Sampling with tools not available). New releases of the SDK target only supported versions of Go. See https://go.dev/doc/devel/release#policy for more information. diff --git a/mcp/streamable.go b/mcp/streamable.go index d78697d..0b11eff 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -26,7 +26,6 @@ import ( "strconv" "strings" "sync" - "sync/atomic" "time" "github.com/modelcontextprotocol/go-sdk/auth" @@ -1456,6 +1455,9 @@ type StreamableClientTransport struct { // - You want to avoid maintaining a persistent connection DisableStandaloneSSE bool + // OAuthHandler is an optional field that, if provided, will be used to authorize the requests. + OAuthHandler auth.OAuthHandler + // TODO(rfindley): propose exporting these. // If strict is set, the transport is in 'strict mode', where any violation // of the MCP spec causes a failure. @@ -1531,6 +1533,7 @@ func (t *StreamableClientTransport) Connect(ctx context.Context) (Connection, er cancel: cancel, failed: make(chan struct{}), disableStandaloneSSE: t.DisableStandaloneSSE, + oauthHandler: t.OAuthHandler, } return conn, nil } @@ -1549,6 +1552,9 @@ type streamableClientConn struct { // for receiving server-to-client notifications when no request is in flight. disableStandaloneSSE bool // from [StreamableClientTransport.DisableStandaloneSSE] + // oauthHandler is the OAuth handler for the connection. + oauthHandler auth.OAuthHandler // from [StreamableClientTransport.OAuthHandler] + // Guard calls to Close, as it may be called multiple times. closeOnce sync.Once closeErr error @@ -1718,20 +1724,46 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e return fmt.Errorf("%s: %v", requestSummary, err) } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(data)) + doRequest := func() (*http.Request, *http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(data)) + if err != nil { + return nil, nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + if err := c.setMCPHeaders(req); err != nil { + // Failure to set headers means that the request was not sent. + // Wrap with ErrRejected so the jsonrpc2 connection doesn't set writeErr + // and permanently break the connection. + return nil, nil, fmt.Errorf("%s: %w: %v", requestSummary, jsonrpc2.ErrRejected, err) + } + resp, err := c.client.Do(req) + if err != nil { + // Any error from client.Do means the request didn't reach the server. + // Wrap with ErrRejected so the jsonrpc2 connection doesn't set writeErr + // and permanently break the connection. + err = fmt.Errorf("%s: %w: %v", requestSummary, jsonrpc2.ErrRejected, err) + } + return req, resp, err + } + + req, resp, err := doRequest() if err != nil { return err } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json, text/event-stream") - c.setMCPHeaders(req) - resp, err := c.client.Do(req) - if err != nil { - // Any error from client.Do means the request didn't reach the server. - // Wrap with ErrRejected so the jsonrpc2 connection doesn't set writeErr - // and permanently break the connection. - return fmt.Errorf("%w: %s: %v", jsonrpc2.ErrRejected, requestSummary, err) + if (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) && c.oauthHandler != nil { + if err := c.oauthHandler.Authorize(ctx, req, resp); err != nil { + // Wrap with ErrRejected so the jsonrpc2 connection doesn't set writeErr + // and permanently break the connection. + // Wrap the authorization error as well for client inspection. + return fmt.Errorf("%s: %w: %w", requestSummary, jsonrpc2.ErrRejected, err) + } + // Retry the request after successful authorization. + _, resp, err = doRequest() + if err != nil { + return err + } } if err := c.checkResponse(requestSummary, resp); err != nil { @@ -1799,23 +1831,32 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e return nil } -// testAuth controls whether a fake Authorization header is added to outgoing requests. -// TODO: replace with a better mechanism when client-side auth is in place. -var testAuth atomic.Bool - -func (c *streamableClientConn) setMCPHeaders(req *http.Request) { +func (c *streamableClientConn) setMCPHeaders(req *http.Request) error { c.mu.Lock() defer c.mu.Unlock() + if c.oauthHandler != nil { + ts, err := c.oauthHandler.TokenSource(c.ctx) + if err != nil { + return err + } + if ts != nil { + token, err := ts.Token() + if err != nil { + return err + } + if token != nil { + req.Header.Set("Authorization", "Bearer "+token.AccessToken) + } + } + } if c.initializedResult != nil { req.Header.Set(protocolVersionHeader, c.initializedResult.ProtocolVersion) } if c.sessionID != "" { req.Header.Set(sessionIDHeader, c.sessionID) } - if testAuth.Load() { - req.Header.Set("Authorization", "Bearer foo") - } + return nil } func (c *streamableClientConn) handleJSON(requestSummary string, resp *http.Response) { @@ -2068,7 +2109,9 @@ func (c *streamableClientConn) connectSSE(ctx context.Context, lastEventID strin if err != nil { return nil, err } - c.setMCPHeaders(req) + if err := c.setMCPHeaders(req); err != nil { + return nil, err + } if lastEventID != "" { req.Header.Set(lastEventIDHeader, lastEventID) } @@ -2099,8 +2142,9 @@ func (c *streamableClientConn) Close() error { if err != nil { c.closeErr = err } else { - c.setMCPHeaders(req) - if _, err := c.client.Do(req); err != nil { + if err := c.setMCPHeaders(req); err != nil { + c.closeErr = err + } else if _, err := c.client.Do(req); err != nil { c.closeErr = err } } diff --git a/mcp/streamable_client_auth_test.go b/mcp/streamable_client_auth_test.go new file mode 100644 index 0000000..a1211e4 --- /dev/null +++ b/mcp/streamable_client_auth_test.go @@ -0,0 +1,179 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +//go:build mcp_go_client_oauth + +package mcp + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/auth" + "golang.org/x/oauth2" +) + +type mockOAuthHandler struct { + // Embed to satisfy the interface. + auth.AuthorizationCodeHandler + + token *oauth2.Token + authorizeErr error + authorizeCalled bool +} + +func (h *mockOAuthHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) { + if h.token == nil { + return nil, nil + } + return oauth2.StaticTokenSource(h.token), nil +} + +func (h *mockOAuthHandler) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error { + h.authorizeCalled = true + return h.authorizeErr +} + +func TestStreamableClientOAuth_AuthorizationHeader(t *testing.T) { + ctx := context.Background() + token := &oauth2.Token{AccessToken: "test-token"} + oauthHandler := &mockOAuthHandler{token: token} + + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodInitialize, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "123", + }, + body: jsonBody(t, initResp), + }, + {"POST", "123", notificationInitialized, ""}: { + status: http.StatusAccepted, + wantProtocolVersion: latestProtocolVersion, + }, + {"GET", "123", "", ""}: { + header: header{ + "Content-Type": "text/event-stream", + }, + }, + {"DELETE", "123", "", ""}: {}, + }, + } + verifier := func(ctx context.Context, token string, req *http.Request) (*auth.TokenInfo, error) { + if token != "test-token" { + return nil, auth.ErrInvalidToken + } + return &auth.TokenInfo{Expiration: time.Now().Add(time.Hour)}, nil + } + httpServer := httptest.NewServer(auth.RequireBearerToken(verifier, nil)(fake)) + t.Cleanup(httpServer.Close) + + transport := &StreamableClientTransport{ + Endpoint: httpServer.URL, + OAuthHandler: oauthHandler, + } + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, nil) + if err != nil { + t.Fatalf("client.Connect() failed: %v", err) + } + session.Close() +} + +func TestStreamableClientOAuth_401(t *testing.T) { + ctx := context.Background() + oauthHandler := &mockOAuthHandler{token: nil} + + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodInitialize, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "123", + }, + body: jsonBody(t, initResp), + }, + }, + } + verifier := func(ctx context.Context, token string, req *http.Request) (*auth.TokenInfo, error) { + // Accept any token. + return &auth.TokenInfo{Expiration: time.Now().Add(time.Hour)}, nil + } + httpServer := httptest.NewServer(auth.RequireBearerToken(verifier, nil)(fake)) + t.Cleanup(httpServer.Close) + + transport := &StreamableClientTransport{ + Endpoint: httpServer.URL, + OAuthHandler: oauthHandler, + } + client := NewClient(testImpl, nil) + _, err := client.Connect(ctx, transport, nil) + if err == nil || !strings.Contains(err.Error(), "Unauthorized") { + t.Fatalf("client.Connect() error does not contain 'Unauthorized': %v", err) + } + + if !oauthHandler.authorizeCalled { + t.Errorf("expected Authorize to be called") + } +} + +func TestTokenInfo(t *testing.T) { + ctx := context.Background() + + // Create a server with a tool that returns TokenInfo. + tokenInfo := func(ctx context.Context, req *CallToolRequest, _ struct{}) (*CallToolResult, any, error) { + return &CallToolResult{Content: []Content{&TextContent{Text: fmt.Sprintf("%v", req.Extra.TokenInfo)}}}, nil, nil + } + server := NewServer(testImpl, nil) + AddTool(server, &Tool{Name: "tokenInfo", Description: "return token info"}, tokenInfo) + + streamHandler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) + verifier := func(ctx context.Context, token string, req *http.Request) (*auth.TokenInfo, error) { + if token != "test-token" { + return nil, auth.ErrInvalidToken + } + return &auth.TokenInfo{ + Scopes: []string{"scope"}, + // Expiration is far, far in the future. + Expiration: time.Date(5000, 1, 2, 3, 4, 5, 0, time.UTC), + }, nil + } + handler := auth.RequireBearerToken(verifier, nil)(streamHandler) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + defer httpServer.Close() + + transport := &StreamableClientTransport{ + Endpoint: httpServer.URL, + OAuthHandler: &mockOAuthHandler{token: &oauth2.Token{AccessToken: "test-token"}}, + } + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, nil) + if err != nil { + t.Fatalf("client.Connect() failed: %v", err) + } + defer session.Close() + + res, err := session.CallTool(ctx, &CallToolParams{Name: "tokenInfo"}) + if err != nil { + t.Fatal(err) + } + if len(res.Content) == 0 { + t.Fatal("missing content") + } + tc, ok := res.Content[0].(*TextContent) + if !ok { + t.Fatal("not TextContent") + } + if g, w := tc.Text, "&{[scope] 5000-01-02 03:04:05 +0000 UTC map[]}"; g != w { + t.Errorf("got %q, want %q", g, w) + } +} diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index e3adfe9..d189ca4 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -17,7 +17,6 @@ import ( "time" "github.com/google/go-cmp/cmp" - "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 2cbe400..22d0d1c 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1666,55 +1666,6 @@ func textContent(t *testing.T, res *CallToolResult) string { return text.Text } -func TestTokenInfo(t *testing.T) { - oldAuth := testAuth.Load() - defer testAuth.Store(oldAuth) - testAuth.Store(true) - ctx := context.Background() - - // Create a server with a tool that returns TokenInfo. - tokenInfo := func(ctx context.Context, req *CallToolRequest, _ struct{}) (*CallToolResult, any, error) { - return &CallToolResult{Content: []Content{&TextContent{Text: fmt.Sprintf("%v", req.Extra.TokenInfo)}}}, nil, nil - } - server := NewServer(testImpl, nil) - AddTool(server, &Tool{Name: "tokenInfo", Description: "return token info"}, tokenInfo) - - streamHandler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) - verifier := func(context.Context, string, *http.Request) (*auth.TokenInfo, error) { - return &auth.TokenInfo{ - Scopes: []string{"scope"}, - // Expiration is far, far in the future. - Expiration: time.Date(5000, 1, 2, 3, 4, 5, 0, time.UTC), - }, nil - } - handler := auth.RequireBearerToken(verifier, nil)(streamHandler) - httpServer := httptest.NewServer(mustNotPanic(t, handler)) - defer httpServer.Close() - - transport := &StreamableClientTransport{Endpoint: httpServer.URL} - client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, nil) - if err != nil { - t.Fatalf("client.Connect() failed: %v", err) - } - defer session.Close() - - res, err := session.CallTool(ctx, &CallToolParams{Name: "tokenInfo"}) - if err != nil { - t.Fatal(err) - } - if len(res.Content) == 0 { - t.Fatal("missing content") - } - tc, ok := res.Content[0].(*TextContent) - if !ok { - t.Fatal("not TextContent") - } - if g, w := tc.Text, "&{[scope] 5000-01-02 03:04:05 +0000 UTC map[]}"; g != w { - t.Errorf("got %q, want %q", g, w) - } -} - func TestSessionHijackingPrevention(t *testing.T) { // This test verifies that sessions bound to a user ID cannot be accessed // by a different user (session hijacking prevention). diff --git a/oauthex/auth_meta.go b/oauthex/auth_meta.go index 9aa0c8d..b05d80b 100644 --- a/oauthex/auth_meta.go +++ b/oauthex/auth_meta.go @@ -14,6 +14,9 @@ import ( "errors" "fmt" "net/http" + "net/url" + + "github.com/modelcontextprotocol/go-sdk/internal/util" ) // AuthServerMeta represents the metadata for an OAuth 2.0 authorization server, @@ -28,8 +31,6 @@ import ( // // [RFC 8414]: https://tools.ietf.org/html/rfc8414) type AuthServerMeta struct { - // GENERATED BY GEMINI 2.5. - // Issuer is the REQUIRED URL identifying the authorization server. Issuer string `json:"issuer"` @@ -113,51 +114,61 @@ type AuthServerMeta struct { // CodeChallengeMethodsSupported is a RECOMMENDED JSON array of strings containing a list of // PKCE code challenge methods supported by this authorization server. CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"` -} -var wellKnownPaths = []string{ - "/.well-known/oauth-authorization-server", - "/.well-known/openid-configuration", + // ClientIDMetadataDocumentSupported is a boolean indicating whether the authorization server + // supports client ID metadata documents. + ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported,omitempty"` } // GetAuthServerMeta issues a GET request to retrieve authorization server metadata -// from an OAuth authorization server with the given issuerURL. +// from an OAuth authorization server with the given metadataURL. // // It follows [RFC 8414]: -// - The well-known paths specified there are inserted into the URL's path, one at time. -// The first to succeed is used. -// - The Issuer field is checked against issuerURL. +// - The metadataURL must use HTTPS or be a local address. +// - The Issuer field is checked against metadataURL.Issuer. +// +// It also verifies that the authorization server supports PKCE and that the URLs +// in the metadata don't use dangerous schemes. +// +// It returns an error if the request fails with a non-4xx status code or the fetched +// metadata doesn't pass security validations. +// It returns nil if the request fails with a 4xx status code. // // [RFC 8414]: https://tools.ietf.org/html/rfc8414 -func GetAuthServerMeta(ctx context.Context, issuerURL string, c *http.Client) (*AuthServerMeta, error) { - var errs []error - for _, p := range wellKnownPaths { - u, err := prependToPath(issuerURL, p) - if err != nil { - // issuerURL is bad; no point in continuing. - return nil, err - } - asm, err := getJSON[AuthServerMeta](ctx, c, u, 1<<20) - if err == nil { - if asm.Issuer != issuerURL { // section 3.3 - // Security violation; don't keep trying. - return nil, fmt.Errorf("metadata issuer %q does not match issuer URL %q", asm.Issuer, issuerURL) - } - - if len(asm.CodeChallengeMethodsSupported) == 0 { - return nil, fmt.Errorf("authorization server at %s does not implement PKCE", issuerURL) - } - - // Validate endpoint URLs to prevent XSS attacks (see #526). - if err := validateAuthServerMetaURLs(asm); err != nil { - return nil, err - } - - return asm, nil - } - errs = append(errs, err) +func GetAuthServerMeta(ctx context.Context, metadataURL, issuer string, c *http.Client) (*AuthServerMeta, error) { + u, err := url.Parse(metadataURL) + if err != nil { + return nil, err } - return nil, fmt.Errorf("failed to get auth server metadata from %q: %w", issuerURL, errors.Join(errs...)) + // Only allow HTTP for local addresses (testing or development purposes). + if !util.IsLoopback(u.Host) && u.Scheme != "https" { + return nil, fmt.Errorf("metadataURL %q does not use HTTPS", metadataURL) + } + asm, err := getJSON[AuthServerMeta](ctx, c, metadataURL, 1<<20) + if err != nil { + var httpErr *httpStatusError + if errors.As(err, &httpErr) { + if 400 <= httpErr.StatusCode && httpErr.StatusCode < 500 { + return nil, nil + } + } + return nil, fmt.Errorf("%v", err) // Do not expose error types. + } + if asm.Issuer != issuer { + // Validate the Issuer field (see RFC 8414, section 3.3). + return nil, fmt.Errorf("metadata issuer %q does not match issuer URL %q", asm.Issuer, issuer) + } + + if len(asm.CodeChallengeMethodsSupported) == 0 { + return nil, fmt.Errorf("authorization server at %s does not implement PKCE", issuer) + } + + // Validate endpoint URLs to prevent XSS attacks (see #526). + if err := validateAuthServerMetaURLs(asm); err != nil { + return nil, err + } + + return asm, nil } // validateAuthServerMetaURLs validates all URL fields in AuthServerMeta diff --git a/oauthex/auth_meta_test.go b/oauthex/auth_meta_test.go index 1e60882..8b67e59 100644 --- a/oauthex/auth_meta_test.go +++ b/oauthex/auth_meta_test.go @@ -86,6 +86,7 @@ func TestGetAuthServerMetaPKCESupport(t *testing.T) { // The fake server sets issuer to https://localhost:, so compute that issuer. u, _ := url.Parse(ts.URL) issuer := "https://localhost:" + u.Port() + metadataURL := issuer + "/.well-known/oauth-authorization-server" // The fake server presents a cert for example.com; set ServerName accordingly. httpClient := ts.Client() @@ -95,7 +96,7 @@ func TestGetAuthServerMetaPKCESupport(t *testing.T) { httpClient.Transport = clone } - meta, err := GetAuthServerMeta(ctx, issuer, httpClient) + meta, err := GetAuthServerMeta(ctx, metadataURL, issuer, httpClient) if tt.wantError != "" { if err == nil { t.Fatal("wanted error but got none") diff --git a/oauthex/oauth2.go b/oauthex/oauth2.go index cdda695..836a420 100644 --- a/oauthex/oauth2.go +++ b/oauthex/oauth2.go @@ -19,21 +19,12 @@ import ( "strings" ) -// prependToPath prepends pre to the path of urlStr. -// When pre is the well-known path, this is the algorithm specified in both RFC 9728 -// section 3.1 and RFC 8414 section 3.1. -func prependToPath(urlStr, pre string) (string, error) { - u, err := url.Parse(urlStr) - if err != nil { - return "", err - } - p := "/" + strings.Trim(pre, "/") - if u.Path != "" { - p += "/" - } +type httpStatusError struct { + StatusCode int +} - u.Path = p + strings.TrimLeft(u.Path, "/") - return u.String(), nil +func (e *httpStatusError) Error() string { + return fmt.Sprintf("bad status %d", e.StatusCode) } // getJSON retrieves JSON and unmarshals JSON from the URL, as specified in both @@ -53,11 +44,9 @@ func getJSON[T any](ctx context.Context, c *http.Client, url string, limit int64 } defer res.Body.Close() - // Specs require a 200. if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("bad status %s", res.Status) + return nil, &httpStatusError{StatusCode: res.StatusCode} } - // Specs require application/json. ct := res.Header.Get("Content-Type") mediaType, _, err := mime.ParseMediaType(ct) if err != nil || mediaType != "application/json" { diff --git a/oauthex/oauth2_test.go b/oauthex/oauth2_test.go index 08d2d31..36f732e 100644 --- a/oauthex/oauth2_test.go +++ b/oauthex/oauth2_test.go @@ -82,13 +82,13 @@ func TestParseSingleChallenge(t *testing.T) { tests := []struct { name string input string - want challenge + want Challenge wantErr bool }{ { name: "scheme only", input: "Basic", - want: challenge{ + want: Challenge{ Scheme: "basic", }, wantErr: false, @@ -96,7 +96,7 @@ func TestParseSingleChallenge(t *testing.T) { { name: "scheme with one quoted param", input: `Bearer realm="example.com"`, - want: challenge{ + want: Challenge{ Scheme: "bearer", Params: map[string]string{"realm": "example.com"}, }, @@ -105,7 +105,7 @@ func TestParseSingleChallenge(t *testing.T) { { name: "scheme with one unquoted param", input: `Bearer realm=example.com`, - want: challenge{ + want: Challenge{ Scheme: "bearer", Params: map[string]string{"realm": "example.com"}, }, @@ -114,7 +114,7 @@ func TestParseSingleChallenge(t *testing.T) { { name: "scheme with multiple params", input: `Bearer realm="example", error="invalid_token", error_description="The token expired"`, - want: challenge{ + want: Challenge{ Scheme: "bearer", Params: map[string]string{ "realm": "example", @@ -127,7 +127,7 @@ func TestParseSingleChallenge(t *testing.T) { { name: "scheme with multiple unquoted params", input: `Bearer realm=example, error=invalid_token, error_description=The token expired`, - want: challenge{ + want: Challenge{ Scheme: "bearer", Params: map[string]string{ "realm": "example", @@ -140,7 +140,7 @@ func TestParseSingleChallenge(t *testing.T) { { name: "case-insensitive scheme and keys", input: `BEARER ReAlM="example"`, - want: challenge{ + want: Challenge{ Scheme: "bearer", Params: map[string]string{"realm": "example"}, }, @@ -149,7 +149,7 @@ func TestParseSingleChallenge(t *testing.T) { { name: "param with escaped quote", input: `Bearer realm="example \"foo\" bar"`, - want: challenge{ + want: Challenge{ Scheme: "bearer", Params: map[string]string{"realm": `example "foo" bar`}, }, @@ -158,7 +158,7 @@ func TestParseSingleChallenge(t *testing.T) { { name: "param without quotes (token)", input: "Bearer realm=example.com", - want: challenge{ + want: Challenge{ Scheme: "bearer", Params: map[string]string{"realm": "example.com"}, }, diff --git a/oauthex/oauthex.go b/oauthex/oauthex.go index 34ed55b..151da7e 100644 --- a/oauthex/oauthex.go +++ b/oauthex/oauthex.go @@ -4,89 +4,3 @@ // Package oauthex implements extensions to OAuth2. package oauthex - -// ProtectedResourceMetadata is the metadata for an OAuth 2.0 protected resource, -// as defined in section 2 of https://www.rfc-editor.org/rfc/rfc9728.html. -// -// The following features are not supported: -// - additional keys (§2, last sentence) -// - human-readable metadata (§2.1) -// - signed metadata (§2.2) -type ProtectedResourceMetadata struct { - // GENERATED BY GEMINI 2.5. - - // Resource (resource) is the protected resource's resource identifier. - // Required. - Resource string `json:"resource"` - - // AuthorizationServers (authorization_servers) is an optional slice containing a list of - // OAuth authorization server issuer identifiers (as defined in RFC 8414) that can be - // used with this protected resource. - AuthorizationServers []string `json:"authorization_servers,omitempty"` - - // JWKSURI (jwks_uri) is an optional URL of the protected resource's JSON Web Key (JWK) Set - // document. This contains public keys belonging to the protected resource, such as - // signing key(s) that the resource server uses to sign resource responses. - JWKSURI string `json:"jwks_uri,omitempty"` - - // ScopesSupported (scopes_supported) is a recommended slice containing a list of scope - // values (as defined in RFC 6749) used in authorization requests to request access - // to this protected resource. - ScopesSupported []string `json:"scopes_supported,omitempty"` - - // BearerMethodsSupported (bearer_methods_supported) is an optional slice containing - // a list of the supported methods of sending an OAuth 2.0 bearer token to the - // protected resource. Defined values are "header", "body", and "query". - BearerMethodsSupported []string `json:"bearer_methods_supported,omitempty"` - - // ResourceSigningAlgValuesSupported (resource_signing_alg_values_supported) is an optional - // slice of JWS signing algorithms (alg values) supported by the protected - // resource for signing resource responses. - ResourceSigningAlgValuesSupported []string `json:"resource_signing_alg_values_supported,omitempty"` - - // ResourceName (resource_name) is a human-readable name of the protected resource - // intended for display to the end user. It is RECOMMENDED that this field be included. - // This value may be internationalized. - ResourceName string `json:"resource_name,omitempty"` - - // ResourceDocumentation (resource_documentation) is an optional URL of a page containing - // human-readable information for developers using the protected resource. - // This value may be internationalized. - ResourceDocumentation string `json:"resource_documentation,omitempty"` - - // ResourcePolicyURI (resource_policy_uri) is an optional URL of a page containing - // human-readable policy information on how a client can use the data provided. - // This value may be internationalized. - ResourcePolicyURI string `json:"resource_policy_uri,omitempty"` - - // ResourceTOSURI (resource_tos_uri) is an optional URL of a page containing the protected - // resource's human-readable terms of service. This value may be internationalized. - ResourceTOSURI string `json:"resource_tos_uri,omitempty"` - - // TLSClientCertificateBoundAccessTokens (tls_client_certificate_bound_access_tokens) is an - // optional boolean indicating support for mutual-TLS client certificate-bound - // access tokens (RFC 8705). Defaults to false if omitted. - TLSClientCertificateBoundAccessTokens bool `json:"tls_client_certificate_bound_access_tokens,omitempty"` - - // AuthorizationDetailsTypesSupported (authorization_details_types_supported) is an optional - // slice of 'type' values supported by the resource server for the - // 'authorization_details' parameter (RFC 9396). - AuthorizationDetailsTypesSupported []string `json:"authorization_details_types_supported,omitempty"` - - // DPOPSigningAlgValuesSupported (dpop_signing_alg_values_supported) is an optional - // slice of JWS signing algorithms supported by the resource server for validating - // DPoP proof JWTs (RFC 9449). - DPOPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"` - - // DPOPBoundAccessTokensRequired (dpop_bound_access_tokens_required) is an optional boolean - // specifying whether the protected resource always requires the use of DPoP-bound - // access tokens (RFC 9449). Defaults to false if omitted. - DPOPBoundAccessTokensRequired bool `json:"dpop_bound_access_tokens_required,omitempty"` - - // SignedMetadata (signed_metadata) is an optional JWT containing metadata parameters - // about the protected resource as claims. If present, these values take precedence - // over values conveyed in plain JSON. - // TODO:implement. - // Note that §2.2 says it's okay to ignore this. - // SignedMetadata string `json:"signed_metadata,omitempty"` -} diff --git a/oauthex/resource_meta.go b/oauthex/resource_meta.go index bb61f79..8b911ca 100644 --- a/oauthex/resource_meta.go +++ b/oauthex/resource_meta.go @@ -38,6 +38,8 @@ const defaultProtectedResourceMetadataURI = "/.well-known/oauth-protected-resour // // It then retrieves the metadata at that location using the given client (or the // default client if nil) and validates its resource field against resourceID. +// +// Deprecated: Use [GetProtectedResourceMetadata] instead. This function will be removed in v1.5.0. func GetProtectedResourceMetadataFromID(ctx context.Context, resourceID string, c *http.Client) (_ *ProtectedResourceMetadata, err error) { defer util.Wrapf(&err, "GetProtectedResourceMetadataFromID(%q)", resourceID) @@ -47,7 +49,7 @@ func GetProtectedResourceMetadataFromID(ctx context.Context, resourceID string, } // Insert well-known URI into URL. u.Path = path.Join(defaultProtectedResourceMetadataURI, u.Path) - return getPRM(ctx, u.String(), c, resourceID) + return GetProtectedResourceMetadata(ctx, u.String(), resourceID, c) } // GetProtectedResourceMetadataFromHeader retrieves protected resource metadata @@ -57,8 +59,9 @@ func GetProtectedResourceMetadataFromID(ctx context.Context, resourceID string, // Per RFC 9728 section 3.3, it validates that the resource field of the resulting metadata // matches the serverURL (the URL that the client used to make the original request to the resource server). // If there is no metadata URL in the header, it returns nil, nil. +// +// Deprecated: Use [GetProtectedResourceMetadata] instead. This function will be removed in v1.5.0. func GetProtectedResourceMetadataFromHeader(ctx context.Context, serverURL string, header http.Header, c *http.Client) (_ *ProtectedResourceMetadata, err error) { - defer util.Wrapf(&err, "GetProtectedResourceMetadataFromHeader") headers := header[http.CanonicalHeaderKey("WWW-Authenticate")] if len(headers) == 0 { return nil, nil @@ -67,26 +70,49 @@ func GetProtectedResourceMetadataFromHeader(ctx context.Context, serverURL strin if err != nil { return nil, err } - metadataURL := ResourceMetadataURL(cs) + metadataURL := resourceMetadataURL(cs) if metadataURL == "" { return nil, nil } - return getPRM(ctx, metadataURL, c, serverURL) + return GetProtectedResourceMetadata(ctx, metadataURL, serverURL, c) } -// getPRM makes a GET request to the given URL, and validates the response. -// As part of the validation, it compares the returned resource field to wantResource. -func getPRM(ctx context.Context, purl string, c *http.Client, wantResource string) (*ProtectedResourceMetadata, error) { - if !strings.HasPrefix(strings.ToUpper(purl), "HTTPS://") { - return nil, fmt.Errorf("resource URL %q does not use HTTPS", purl) +// resourceMetadataURL returns a resource metadata URL from the given "WWW-Authenticate" header challenges, +// or the empty string if there is none. +func resourceMetadataURL(cs []Challenge) string { + for _, c := range cs { + if u := c.Params["resource_metadata"]; u != "" { + return u + } } - prm, err := getJSON[ProtectedResourceMetadata](ctx, c, purl, 1<<20) + return "" +} + +// GetProtectedResourceMetadataFromID issues a GET request to retrieve protected resource +// metadata from a resource server. +// The metadataURL is typically a URL with a host:port and possibly a path. +// The resourceURL is the resource URI the metadataURL is for. +// The following checks are performed: +// - The metadataURL must use HTTPS or be a local address. +// - The resource field of the resulting metadata must match the resourceURL. +// - The authorization_servers field of the resulting metadata is checked for dangerous URL schemes. +func GetProtectedResourceMetadata(ctx context.Context, metadataURL, resourceURL string, c *http.Client) (_ *ProtectedResourceMetadata, err error) { + defer util.Wrapf(&err, "GetProtectedResourceMetadata(%q)", metadataURL) + u, err := url.Parse(metadataURL) + if err != nil { + return nil, err + } + // Only allow HTTP for local addresses (testing or development purposes). + if !util.IsLoopback(u.Host) && u.Scheme != "https" { + return nil, fmt.Errorf("metadataURL %q does not use HTTPS", metadataURL) + } + prm, err := getJSON[ProtectedResourceMetadata](ctx, c, metadataURL, 1<<20) if err != nil { return nil, err } // Validate the Resource field (see RFC 9728, section 3.3). - if prm.Resource != wantResource { - return nil, fmt.Errorf("got metadata resource %q, want %q", prm.Resource, wantResource) + if prm.Resource != resourceURL { + return nil, fmt.Errorf("got metadata resource %q, want %q", prm.Resource, resourceURL) } // Validate the authorization server URLs to prevent XSS attacks (see #526). for _, u := range prm.AuthorizationServers { @@ -97,37 +123,12 @@ func getPRM(ctx context.Context, purl string, c *http.Client, wantResource strin return prm, nil } -// challenge represents a single authentication challenge from a WWW-Authenticate header. -// As per RFC 9110, Section 11.6.1, a challenge consists of a scheme and optional parameters. -type challenge struct { - // GENERATED BY GEMINI 2.5. - // - // Scheme is the authentication scheme (e.g., "Bearer", "Basic"). - // It is case-insensitive. A parsed value will always be lower-case. - Scheme string - // Params is a map of authentication parameters. - // Keys are case-insensitive. Parsed keys are always lower-case. - Params map[string]string -} - -// ResourceMetadataURL returns a resource metadata URL from the given challenges, -// or the empty string if there is none. -func ResourceMetadataURL(cs []challenge) string { - for _, c := range cs { - if u := c.Params["resource_metadata"]; u != "" { - return u - } - } - return "" -} - // ParseWWWAuthenticate parses a WWW-Authenticate header string. // The header format is defined in RFC 9110, Section 11.6.1, and can contain // one or more challenges, separated by commas. // It returns a slice of challenges or an error if one of the headers is malformed. -func ParseWWWAuthenticate(headers []string) ([]challenge, error) { - // GENERATED BY GEMINI 2.5 (human-tweaked) - var challenges []challenge +func ParseWWWAuthenticate(headers []string) ([]Challenge, error) { + var challenges []Challenge for _, h := range headers { challengeStrings, err := splitChallenges(h) if err != nil { @@ -151,7 +152,6 @@ func ParseWWWAuthenticate(headers []string) ([]challenge, error) { // It correctly handles commas within quoted strings and distinguishes between // commas separating auth-params and commas separating challenges. func splitChallenges(header string) ([]string, error) { - // GENERATED BY GEMINI 2.5. var challenges []string inQuotes := false start := 0 @@ -195,15 +195,14 @@ func splitChallenges(header string) ([]string, error) { // parseSingleChallenge parses a string containing exactly one challenge. // challenge = auth-scheme [ 1*SP ( token68 / #auth-param ) ] -func parseSingleChallenge(s string) (challenge, error) { - // GENERATED BY GEMINI 2.5, human-tweaked. +func parseSingleChallenge(s string) (Challenge, error) { s = strings.TrimSpace(s) if s == "" { - return challenge{}, errors.New("empty challenge string") + return Challenge{}, errors.New("empty challenge string") } scheme, paramsStr, found := strings.Cut(s, " ") - c := challenge{Scheme: strings.ToLower(scheme)} + c := Challenge{Scheme: strings.ToLower(scheme)} if !found { return c, nil } @@ -215,7 +214,7 @@ func parseSingleChallenge(s string) (challenge, error) { // Find the end of the parameter key. keyEnd := strings.Index(paramsStr, "=") if keyEnd <= 0 { - return challenge{}, fmt.Errorf("malformed auth parameter: expected key=value, but got %q", paramsStr) + return Challenge{}, fmt.Errorf("malformed auth parameter: expected key=value, but got %q", paramsStr) } key := strings.TrimSpace(paramsStr[:keyEnd]) @@ -243,7 +242,7 @@ func parseSingleChallenge(s string) (challenge, error) { // A quoted string must be terminated. if i == len(paramsStr) { - return challenge{}, fmt.Errorf("unterminated quoted string in auth parameter") + return Challenge{}, fmt.Errorf("unterminated quoted string in auth parameter") } value = valBuilder.String() @@ -261,7 +260,7 @@ func parseSingleChallenge(s string) (challenge, error) { } } if value == "" { - return challenge{}, fmt.Errorf("no value for auth param %q", key) + return Challenge{}, fmt.Errorf("no value for auth param %q", key) } // Per RFC 9110, parameter keys are case-insensitive. @@ -272,10 +271,10 @@ func parseSingleChallenge(s string) (challenge, error) { paramsStr = strings.TrimSpace(paramsStr[1:]) } else if paramsStr != "" { // If there's content but it's not a new parameter, the format is wrong. - return challenge{}, fmt.Errorf("malformed auth parameter: expected comma after value, but got %q", paramsStr) + return Challenge{}, fmt.Errorf("malformed auth parameter: expected comma after value, but got %q", paramsStr) } } // Per RFC 9110, the scheme is case-insensitive. - return challenge{Scheme: strings.ToLower(scheme), Params: params}, nil + return Challenge{Scheme: strings.ToLower(scheme), Params: params}, nil } diff --git a/oauthex/resource_meta_public.go b/oauthex/resource_meta_public.go new file mode 100644 index 0000000..3bf7d9a --- /dev/null +++ b/oauthex/resource_meta_public.go @@ -0,0 +1,105 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// This file implements Protected Resource Metadata. +// See https://www.rfc-editor.org/rfc/rfc9728.html. + +// This is a temporary file to expose the required objects to the main package. + +package oauthex + +// ProtectedResourceMetadata is the metadata for an OAuth 2.0 protected resource, +// as defined in section 2 of https://www.rfc-editor.org/rfc/rfc9728.html. +// +// The following features are not supported: +// - additional keys (§2, last sentence) +// - human-readable metadata (§2.1) +// - signed metadata (§2.2) +type ProtectedResourceMetadata struct { + // Resource (resource) is the protected resource's resource identifier. + // Required. + Resource string `json:"resource"` + + // AuthorizationServers (authorization_servers) is an optional slice containing a list of + // OAuth authorization server issuer identifiers (as defined in RFC 8414) that can be + // used with this protected resource. + AuthorizationServers []string `json:"authorization_servers,omitempty"` + + // JWKSURI (jwks_uri) is an optional URL of the protected resource's JSON Web Key (JWK) Set + // document. This contains public keys belonging to the protected resource, such as + // signing key(s) that the resource server uses to sign resource responses. + JWKSURI string `json:"jwks_uri,omitempty"` + + // ScopesSupported (scopes_supported) is a recommended slice containing a list of scope + // values (as defined in RFC 6749) used in authorization requests to request access + // to this protected resource. + ScopesSupported []string `json:"scopes_supported,omitempty"` + + // BearerMethodsSupported (bearer_methods_supported) is an optional slice containing + // a list of the supported methods of sending an OAuth 2.0 bearer token to the + // protected resource. Defined values are "header", "body", and "query". + BearerMethodsSupported []string `json:"bearer_methods_supported,omitempty"` + + // ResourceSigningAlgValuesSupported (resource_signing_alg_values_supported) is an optional + // slice of JWS signing algorithms (alg values) supported by the protected + // resource for signing resource responses. + ResourceSigningAlgValuesSupported []string `json:"resource_signing_alg_values_supported,omitempty"` + + // ResourceName (resource_name) is a human-readable name of the protected resource + // intended for display to the end user. It is RECOMMENDED that this field be included. + // This value may be internationalized. + ResourceName string `json:"resource_name,omitempty"` + + // ResourceDocumentation (resource_documentation) is an optional URL of a page containing + // human-readable information for developers using the protected resource. + // This value may be internationalized. + ResourceDocumentation string `json:"resource_documentation,omitempty"` + + // ResourcePolicyURI (resource_policy_uri) is an optional URL of a page containing + // human-readable policy information on how a client can use the data provided. + // This value may be internationalized. + ResourcePolicyURI string `json:"resource_policy_uri,omitempty"` + + // ResourceTOSURI (resource_tos_uri) is an optional URL of a page containing the protected + // resource's human-readable terms of service. This value may be internationalized. + ResourceTOSURI string `json:"resource_tos_uri,omitempty"` + + // TLSClientCertificateBoundAccessTokens (tls_client_certificate_bound_access_tokens) is an + // optional boolean indicating support for mutual-TLS client certificate-bound + // access tokens (RFC 8705). Defaults to false if omitted. + TLSClientCertificateBoundAccessTokens bool `json:"tls_client_certificate_bound_access_tokens,omitempty"` + + // AuthorizationDetailsTypesSupported (authorization_details_types_supported) is an optional + // slice of 'type' values supported by the resource server for the + // 'authorization_details' parameter (RFC 9396). + AuthorizationDetailsTypesSupported []string `json:"authorization_details_types_supported,omitempty"` + + // DPOPSigningAlgValuesSupported (dpop_signing_alg_values_supported) is an optional + // slice of JWS signing algorithms supported by the resource server for validating + // DPoP proof JWTs (RFC 9449). + DPOPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"` + + // DPOPBoundAccessTokensRequired (dpop_bound_access_tokens_required) is an optional boolean + // specifying whether the protected resource always requires the use of DPoP-bound + // access tokens (RFC 9449). Defaults to false if omitted. + DPOPBoundAccessTokensRequired bool `json:"dpop_bound_access_tokens_required,omitempty"` + + // SignedMetadata (signed_metadata) is an optional JWT containing metadata parameters + // about the protected resource as claims. If present, these values take precedence + // over values conveyed in plain JSON. + // TODO:implement. + // Note that §2.2 says it's okay to ignore this. + // SignedMetadata string `json:"signed_metadata,omitempty"` +} + +// Challenge represents a single authentication challenge from a WWW-Authenticate header. +// As per RFC 9110, Section 11.6.1, a challenge consists of a scheme and optional parameters. +type Challenge struct { + // Scheme is the authentication scheme (e.g., "Bearer", "Basic"). + // It is case-insensitive. A parsed value will always be lower-case. + Scheme string + // Params is a map of authentication parameters. + // Keys are case-insensitive. Parsed keys are always lower-case. + Params map[string]string +} diff --git a/oauthex/url_scheme_test.go b/oauthex/url_scheme_test.go index 531a1f9..83eeb5e 100644 --- a/oauthex/url_scheme_test.go +++ b/oauthex/url_scheme_test.go @@ -226,7 +226,9 @@ func TestGetAuthServerMetaRejectsDangerousURLs(t *testing.T) { defer server.Close() ctx := context.Background() - _, err := GetAuthServerMeta(ctx, server.URL, server.Client()) + issuer := server.URL + metadataURL := issuer + _, err := GetAuthServerMeta(ctx, metadataURL, issuer, server.Client()) if err == nil { t.Fatal("GetAuthServerMeta(): got nil error, want error") } diff --git a/scripts/client-conformance.sh b/scripts/client-conformance.sh index c093c75..1752845 100755 --- a/scripts/client-conformance.sh +++ b/scripts/client-conformance.sh @@ -10,6 +10,7 @@ set -e RESULT_DIR="" WORKDIR="" CONFORMANCE_REPO="" +SUITE="core" FINAL_EXIT_CODE=0 usage() { @@ -21,9 +22,11 @@ usage() { echo " --result_dir Save results to the specified directory" echo " --conformance_repo Run conformance tests from a local checkout" echo " instead of using the latest npm release" + echo " --suite Which suite to run (default: core)" echo " --help Show this help message" } + # Parse arguments. while [[ $# -gt 0 ]]; do case $1 in @@ -35,6 +38,10 @@ while [[ $# -gt 0 ]]; do CONFORMANCE_REPO="$2" shift 2 ;; + --suite) + SUITE="$2" + shift 2 + ;; --help) usage exit 0 @@ -56,7 +63,7 @@ else fi # Build the conformance server. -go build -o "$WORKDIR/conformance-client" ./conformance/everything-client +go build -tags mcp_go_client_oauth -o "$WORKDIR/conformance-client" ./conformance/everything-client # Run conformance tests from the work directory to avoid writing results to the repo. echo "Running conformance tests..." @@ -65,13 +72,13 @@ if [ -n "$CONFORMANCE_REPO" ]; then (cd "$WORKDIR" && \ npm --prefix "$CONFORMANCE_REPO" run start -- \ client --command "$WORKDIR/conformance-client" \ - --suite core \ + --suite "$SUITE" \ ${RESULT_DIR:+--output-dir "$RESULT_DIR"}) || FINAL_EXIT_CODE=$? else (cd "$WORKDIR" && \ npx @modelcontextprotocol/conformance@latest \ client --command "$WORKDIR/conformance-client" \ - --suite core \ + --suite "$SUITE" \ ${RESULT_DIR:+--output-dir "$RESULT_DIR"}) || FINAL_EXIT_CODE=$? fi From 83f7e968f85f2f62035964138f0d9ed5826dc87e Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Fri, 27 Feb 2026 17:04:25 +0100 Subject: [PATCH 212/418] examples: fix OAuth client example after latest changes. (#820) --- examples/auth/client/main.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/examples/auth/client/main.go b/examples/auth/client/main.go index 32de488..b9ddf4d 100644 --- a/examples/auth/client/main.go +++ b/examples/auth/client/main.go @@ -53,6 +53,7 @@ func (r *codeReceiver) serveRedirectHandler(listener net.Listener) { } func (r *codeReceiver) getAuthorizationCode(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { + fmt.Printf("Please open the following URL in your browser: %s\n", args.URL) select { case authRes := <-r.authChan: return authRes, nil @@ -87,8 +88,10 @@ func main() { AuthorizationCodeFetcher: receiver.getAuthorizationCode, // Uncomment the client configuration you want to use. // PreregisteredClientConfig: &auth.PreregisteredClientConfig{ - // ClientID: "", - // ClientSecret: "", + // ClientSecretAuthConfig: &auth.ClientSecretAuthConfig{ + // ClientID: "", + // ClientSecret: "", + // }, // }, // DynamicClientRegistrationConfig: &auth.DynamicClientRegistrationConfig{ // Metadata: &oauthex.ClientRegistrationMetadata{ From 6e06df936947a6b3ad24e836b63c64acf956f214 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 08:38:45 +0100 Subject: [PATCH 213/418] build(deps): bump actions/upload-artifact from 4.6.1 to 7.0.0 (#824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.1 to 7.0.0.
Release notes

Sourced from actions/upload-artifact's releases.

v7.0.0

v7 What's new

Direct Uploads

Adds support for uploading single files directly (unzipped). Callers can set the new archive parameter to false to skip zipping the file during upload. Right now, we only support single files. The action will fail if the glob passed resolves to multiple files. The name parameter is also ignored with this setting. Instead, the name of the artifact will be the name of the uploaded file.

ESM

To support new versions of the @actions/* packages, we've upgraded the package to ESM.

What's Changed

New Contributors

Full Changelog: https://github.com/actions/upload-artifact/compare/v6...v7.0.0

v6.0.0

v6 - What's new

[!IMPORTANT] actions/upload-artifact@v6 now runs on Node.js 24 (runs.using: node24) and requires a minimum Actions Runner version of 2.327.1. If you are using self-hosted runners, ensure they are updated before upgrading.

Node.js 24

This release updates the runtime to Node.js 24. v5 had preliminary support for Node.js 24, however this action was by default still running on Node.js 20. Now this action by default will run on Node.js 24.

What's Changed

Full Changelog: https://github.com/actions/upload-artifact/compare/v5.0.0...v6.0.0

v5.0.0

What's Changed

BREAKING CHANGE: this update supports Node v24.x. This is not a breaking change per-se but we're treating it as such.

... (truncated)

Commits
  • bbbca2d Support direct file uploads (#764)
  • 589182c Upgrade the module to ESM and bump dependencies (#762)
  • 47309c9 Merge pull request #754 from actions/Link-/add-proxy-integration-tests
  • 02a8460 Add proxy integration test
  • b7c566a Merge pull request #745 from actions/upload-artifact-v6-release
  • e516bc8 docs: correct description of Node.js 24 support in README
  • ddc45ed docs: update README to correct action name for Node.js 24 support
  • 615b319 chore: release v6.0.0 for Node.js 24 support
  • 017748b Merge pull request #744 from actions/fix-storage-blob
  • 38d4c79 chore: rebuild dist
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/upload-artifact&package-manager=github_actions&previous-version=4.6.1&new-version=7.0.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/scorecard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 43bf223..9a6ef48 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -64,7 +64,7 @@ jobs: # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: SARIF file path: results.sarif From e3a5a49ea8576e190afc50f8c4fd3c2a8588cc17 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 08:41:08 +0100 Subject: [PATCH 214/418] build(deps): bump ossf/scorecard-action from 2.4.1 to 2.4.3 (#825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [ossf/scorecard-action](https://github.com/ossf/scorecard-action) from 2.4.1 to 2.4.3.
Release notes

Sourced from ossf/scorecard-action's releases.

v2.4.3

What's Changed

This update bumps the Scorecard version to the v5.3.0 release. For a complete list of changes, please refer to the Scorecard v5.3.0 release notes.

Documentation

Other

New Contributors

Full Changelog: https://github.com/ossf/scorecard-action/compare/v2.4.2...v2.4.3

v2.4.2

What's Changed

This update bumps the Scorecard version to the v5.2.1 release. For a complete list of changes, please refer to the Scorecard v5.2.0 and v5.2.1 release notes.

Full Changelog: https://github.com/ossf/scorecard-action/compare/v2.4.1...v2.4.2

Commits
  • 4eaacf0 bump docker to ghcr v2.4.3 (#1587)
  • 42e3a01 :seedling: Bump the github-actions group with 3 updates (#1585)
  • 88c07ac :seedling: Bump github.com/sigstore/cosign/v2 from 2.5.2 to 2.6.0 (#1579)
  • 6c690f2 Bump github.com/ossf/scorecard/v5 from v5.2.1 to v5.3.0 (#1586)
  • 92083b5 :book: Fix recommended command to test the image in development (#1583)
  • 7975ea6 :seedling: Bump the docker-images group across 1 directory with 2 updates (#1...
  • 0d1a743 :seedling: Bump github.com/spf13/cobra from 1.9.1 to 1.10.1 (#1575)
  • 46e6e0c :seedling: Bump the github-actions group with 2 updates (#1580)
  • c3f1350 :seedling: Improve printing options (#1584)
  • 43e475b :seedling: Bump golang.org/x/net from 0.42.0 to 0.44.0 (#1578)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ossf/scorecard-action&package-manager=github_actions&previous-version=2.4.1&new-version=2.4.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Maciej Kisiel --- .github/workflows/scorecard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 9a6ef48..7e28d39 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -39,7 +39,7 @@ jobs: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1 + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: results_file: results.sarif results_format: sarif From e10805dbaa3b5a20d4d1ce505336b67feea39bdd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 07:43:04 +0000 Subject: [PATCH 215/418] build(deps): bump actions/setup-go from 6.2.0 to 6.3.0 (#827) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6.2.0 to 6.3.0.
Release notes

Sourced from actions/setup-go's releases.

v6.3.0

What's Changed

Full Changelog: https://github.com/actions/setup-go/compare/v6...v6.3.0

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/setup-go&package-manager=github_actions&previous-version=6.2.0&new-version=6.3.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/conformance.yml | 4 ++-- .github/workflows/docs-check.yml | 2 +- .github/workflows/nightly.yml | 2 +- .github/workflows/test.yml | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 184a31c..17bdf7e 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -20,7 +20,7 @@ jobs: - name: Check out code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: "^1.25" - name: Start everything-server @@ -43,7 +43,7 @@ jobs: - name: Check out code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: "^1.25" - name: "Run conformance tests" diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index 4ed070b..9b9e93f 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Set up Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 - name: Check out code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Check docs are up-to-date diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 150a9f0..e10d2aa 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -25,7 +25,7 @@ jobs: - name: Check out code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: "^1.25" - name: Set up Node.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b544f44..0344d35 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: - name: Check out code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: "^1.25" - name: Check formatting @@ -45,7 +45,7 @@ jobs: - name: Check out code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: ${{ matrix.go }} - name: Test @@ -57,7 +57,7 @@ jobs: - name: Check out code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: "1.24" - name: Test with -race From 0bbe9369a9b9e6fae74d48c2d01382aa1374be1a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 07:44:50 +0000 Subject: [PATCH 216/418] build(deps): bump actions/checkout from 4.2.2 to 6.0.2 (#826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/checkout](https://github.com/actions/checkout) from 4.2.2 to 6.0.2.
Release notes

Sourced from actions/checkout's releases.

v6.0.2

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v6.0.1...v6.0.2

v6.0.1

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v6...v6.0.1

v6.0.0

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v5.0.0...v6.0.0

v6-beta

What's Changed

Updated persist-credentials to store the credentials under $RUNNER_TEMP instead of directly in the local git config.

This requires a minimum Actions Runner version of v2.329.0 to access the persisted credentials for Docker container action scenarios.

v5.0.1

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v5...v5.0.1

v5.0.0

What's Changed

⚠️ Minimum Compatible Runner Version

v2.327.1
Release Notes

... (truncated)

Changelog

Sourced from actions/checkout's changelog.

Changelog

v6.0.2

v6.0.1

v6.0.0

v5.0.1

v5.0.0

v4.3.1

v4.3.0

v4.2.2

v4.2.1

v4.2.0

v4.1.7

v4.1.6

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/checkout&package-manager=github_actions&previous-version=4.2.2&new-version=6.0.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/scorecard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 7e28d39..3548543 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -34,7 +34,7 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false From 455fd93d97a10ae8d481bc6f033c09f29995f344 Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Mon, 2 Mar 2026 10:55:35 +0100 Subject: [PATCH 217/418] mcp: simplify and unify unit tests introduced for sampling with tools. (#799) --- mcp/protocol_test.go | 581 ++++++++++++++++++++++++------------------- mcp/sampling_test.go | 216 +++++----------- 2 files changed, 394 insertions(+), 403 deletions(-) diff --git a/mcp/protocol_test.go b/mcp/protocol_test.go index b33e5f5..20f2bb8 100644 --- a/mcp/protocol_test.go +++ b/mcp/protocol_test.go @@ -7,7 +7,6 @@ package mcp import ( "encoding/json" "maps" - "reflect" "testing" "github.com/google/go-cmp/cmp" @@ -489,11 +488,12 @@ func TestCompleteResult(t *testing.T) { } } +// TODO: merge the following 4 tests into content_test.go. func TestToolUseContent_MarshalJSON(t *testing.T) { tests := []struct { name string content *ToolUseContent - want *ToolUseContent + want string }{ { name: "basic tool use", @@ -506,15 +506,7 @@ func TestToolUseContent_MarshalJSON(t *testing.T) { "y": 2.0, }, }, - want: &ToolUseContent{ - ID: "tool_123", - Name: "calculator", - Input: map[string]any{ - "operation": "add", - "x": 1.0, - "y": 2.0, - }, - }, + want: `{"type":"tool_use","id":"tool_123","name":"calculator","input":{"operation":"add","x":1,"y":2}}`, }, { name: "nil input marshals as empty object", @@ -523,29 +515,17 @@ func TestToolUseContent_MarshalJSON(t *testing.T) { Name: "no_args_tool", Input: nil, }, - want: &ToolUseContent{ - ID: "tool_456", - Name: "no_args_tool", - Input: map[string]any{}, - }, + want: `{"type":"tool_use","id":"tool_456","name":"no_args_tool","input":{}}`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - data, err := tt.content.MarshalJSON() + got, err := tt.content.MarshalJSON() if err != nil { t.Fatalf("MarshalJSON() error = %v", err) } - wire := &wireContent{} - if err := json.Unmarshal(data, wire); err != nil { - t.Fatalf("Unmarshal wire error = %v", err) - } - got, err := contentFromWire(wire, map[string]bool{"tool_use": true}) - if err != nil { - t.Fatalf("contentFromWire() error = %v", err) - } - if diff := cmp.Diff(tt.want, got); diff != "" { + if diff := cmp.Diff(tt.want, string(got)); diff != "" { t.Errorf("mismatch (-want +got):\n%s", diff) } }) @@ -635,11 +615,11 @@ func TestToolResultContent_MarshalJSON(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - data, err := tt.content.MarshalJSON() + got, err := tt.content.MarshalJSON() if err != nil { t.Fatalf("MarshalJSON() error = %v", err) } - if diff := cmp.Diff(tt.want, string(data)); diff != "" { + if diff := cmp.Diff(tt.want, string(got)); diff != "" { t.Errorf("mismatch (-want +got):\n%s", diff) } }) @@ -648,44 +628,25 @@ func TestToolResultContent_MarshalJSON(t *testing.T) { func TestToolResultContent_UnmarshalJSON(t *testing.T) { tests := []struct { - name string - json string - wantID string - wantErr bool - checkFn func(t *testing.T, got *ToolResultContent) + name string + json string + want *ToolResultContent }{ { - name: "basic tool result", - json: `{"type":"tool_result","toolUseId":"tool_123","content":[{"type":"text","text":"42"}],"isError":false}`, - wantID: "tool_123", - checkFn: func(t *testing.T, got *ToolResultContent) { - if len(got.Content) != 1 { - t.Fatalf("len(Content) = %d, want 1", len(got.Content)) - } - tc, ok := got.Content[0].(*TextContent) - if !ok { - t.Fatalf("Content[0] type = %T, want *TextContent", got.Content[0]) - } - if tc.Text != "42" { - t.Errorf("Text = %v, want 42", tc.Text) - } + name: "basic tool result", + json: `{"type":"tool_result","toolUseId":"tool_123","content":[{"type":"text","text":"42"}],"isError":false}`, + want: &ToolResultContent{ + ToolUseID: "tool_123", + Content: []Content{&TextContent{Text: "42"}}, + IsError: false, }, }, { - name: "image nested content", - json: `{"type":"tool_result","toolUseId":"t1","content":[{"type":"image","mimeType":"image/png","data":"YWJj"}]}`, - wantID: "t1", - checkFn: func(t *testing.T, got *ToolResultContent) { - if len(got.Content) != 1 { - t.Fatalf("len(Content) = %d, want 1", len(got.Content)) - } - img, ok := got.Content[0].(*ImageContent) - if !ok { - t.Fatalf("Content[0] type = %T, want *ImageContent", got.Content[0]) - } - if img.MIMEType != "image/png" { - t.Errorf("MIMEType = %v, want image/png", img.MIMEType) - } + name: "image nested content", + json: `{"type":"tool_result","toolUseId":"t1","content":[{"type":"image","mimeType":"image/png","data":"YWJj"}]}`, + want: &ToolResultContent{ + ToolUseID: "t1", + Content: []Content{&ImageContent{MIMEType: "image/png", Data: []byte("abc")}}, }, }, } @@ -697,21 +658,11 @@ func TestToolResultContent_UnmarshalJSON(t *testing.T) { t.Fatalf("Unmarshal() error = %v", err) } got, err := contentFromWire(wire, map[string]bool{"tool_result": true}) - if (err != nil) != tt.wantErr { - t.Fatalf("contentFromWire() error = %v, wantErr %v", err, tt.wantErr) + if err != nil { + t.Fatalf("contentFromWire() error = %v", err) } - if tt.wantErr { - return - } - result, ok := got.(*ToolResultContent) - if !ok { - t.Fatalf("type = %T, want *ToolResultContent", got) - } - if result.ToolUseID != tt.wantID { - t.Errorf("ToolUseID = %v, want %v", result.ToolUseID, tt.wantID) - } - if tt.checkFn != nil { - tt.checkFn(t, result) + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) } }) } @@ -719,79 +670,86 @@ func TestToolResultContent_UnmarshalJSON(t *testing.T) { func TestSamplingMessage_UnmarshalJSON(t *testing.T) { tests := []struct { - name string - json string - wantRole Role - wantType string // expected Content type name + name string + json string + want *SamplingMessage }{ { - name: "tool_use content", - json: `{"content":{"type":"tool_use","id":"tool_1","name":"calc","input":{}},"role":"assistant"}`, - wantRole: "assistant", - wantType: "*mcp.ToolUseContent", + name: "tool_use content", + json: `{"content":{"type":"tool_use","id":"tool_1","name":"calc","input":{}},"role":"assistant"}`, + want: &SamplingMessage{ + Role: "assistant", + Content: &ToolUseContent{ + ID: "tool_1", + Name: "calc", + Input: map[string]any{}, + }, + }, }, { - name: "tool_result content", - json: `{"content":{"type":"tool_result","toolUseId":"tool_1","content":[{"type":"text","text":"42"}]},"role":"user"}`, - wantRole: "user", - wantType: "*mcp.ToolResultContent", + name: "tool_result content", + json: `{"content":{"type":"tool_result","toolUseId":"tool_1","content":[{"type":"text","text":"42"}]},"role":"user"}`, + want: &SamplingMessage{ + Role: "user", + Content: &ToolResultContent{ + ToolUseID: "tool_1", + Content: []Content{&TextContent{Text: "42"}}, + }, + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var msg SamplingMessage - if err := json.Unmarshal([]byte(tt.json), &msg); err != nil { + var got SamplingMessage + if err := json.Unmarshal([]byte(tt.json), &got); err != nil { t.Fatalf("Unmarshal() error = %v", err) } - if msg.Role != tt.wantRole { - t.Errorf("Role = %v, want %v", msg.Role, tt.wantRole) - } - gotType := reflect.TypeOf(msg.Content).String() - if gotType != tt.wantType { - t.Errorf("Content type = %v, want %v", gotType, tt.wantType) + if diff := cmp.Diff(tt.want, &got); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) } }) } } -func TestSamplingCapabilities_MarshalJSON(t *testing.T) { - caps := &SamplingCapabilities{ - Tools: &SamplingToolsCapabilities{}, - Context: &SamplingContextCapabilities{}, +func TestSamplingCapabilities_MarshalUnmarshalJSON(t *testing.T) { + tests := []struct { + name string + caps *SamplingCapabilities + json string + }{ + { + name: "WithCapabilities", + caps: &SamplingCapabilities{ + Tools: &SamplingToolsCapabilities{}, + Context: &SamplingContextCapabilities{}, + }, + json: `{"context":{},"tools":{}}`, + }, + { + name: "EmptyCapabilities", + caps: &SamplingCapabilities{}, + json: `{}`, + }, } - data, err := json.Marshal(caps) - if err != nil { - t.Fatalf("Marshal() error = %v", err) - } - var got SamplingCapabilities - if err := json.Unmarshal(data, &got); err != nil { - t.Fatalf("Unmarshal() error = %v", err) - } - if got.Tools == nil { - t.Error("Tools capability should not be nil") - } - if got.Context == nil { - t.Error("Context capability should not be nil") - } -} -func TestSamplingCapabilities_UnmarshalJSON(t *testing.T) { - // Empty struct should marshal/unmarshal correctly (backward compatibility). - caps := &SamplingCapabilities{} - data, err := json.Marshal(caps) - if err != nil { - t.Fatalf("Marshal() error = %v", err) - } - var got SamplingCapabilities - if err := json.Unmarshal(data, &got); err != nil { - t.Fatalf("Unmarshal() error = %v", err) - } - if got.Tools != nil { - t.Error("Tools capability should be nil for empty capabilities") - } - if got.Context != nil { - t.Error("Context capability should be nil for empty capabilities") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotJson, err := json.Marshal(tt.caps) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + if diff := cmp.Diff(tt.json, string(gotJson)); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + var gotCaps SamplingCapabilities + if err := json.Unmarshal([]byte(tt.json), &gotCaps); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if diff := cmp.Diff(tt.caps, &gotCaps); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + }) } } @@ -830,19 +788,13 @@ func TestCreateMessageWithToolsParams_MarshalUnmarshalJSON(t *testing.T) { t.Fatalf("Unmarshal() error = %v", err) } - if len(got.Tools) != 1 { - t.Fatalf("len(Tools) = %v, want 1", len(got.Tools)) - } - if got.Tools[0].Name != "calculator" { - t.Errorf("Tools[0].Name = %v, want calculator", got.Tools[0].Name) - } - if got.ToolChoice == nil || got.ToolChoice.Mode != "auto" { - t.Errorf("ToolChoice = %v, want {Mode: auto}", got.ToolChoice) + if diff := cmp.Diff(params, &got); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) } } func TestToolChoice_MarshalUnmarshalJSON(t *testing.T) { - choices := []*ToolChoice{ + choices := []ToolChoice{ {Mode: "auto"}, {Mode: "required"}, {Mode: "none"}, @@ -857,39 +809,60 @@ func TestToolChoice_MarshalUnmarshalJSON(t *testing.T) { if err := json.Unmarshal(data, &got); err != nil { t.Fatalf("Unmarshal() error = %v", err) } - if diff := cmp.Diff(*tc, got); diff != "" { + if diff := cmp.Diff(tc, got); diff != "" { t.Errorf("mismatch (-want +got):\n%s", diff) } } } func TestCreateMessageWithToolsResult_MarshalJSON(t *testing.T) { - // Single-element Content marshals as object (not array) for backward compat. - result := &CreateMessageWithToolsResult{ - Model: "test", - Role: "assistant", - Content: []Content{&TextContent{Text: "hello"}}, + tests := []struct { + name string + result *CreateMessageWithToolsResult + want string + }{ + { + name: "single element content", + result: &CreateMessageWithToolsResult{ + Model: "test", + Role: "assistant", + Content: []Content{&TextContent{Text: "hello"}}, + }, + // Single-element Content marshals as object (not array) for backward compat. + want: `{"content":{"type":"text","text":"hello"},"model":"test","role":"assistant"}`, + }, + { + name: "multiple elements content", + result: &CreateMessageWithToolsResult{ + Model: "test", + Role: "assistant", + Content: []Content{ + &TextContent{Text: "thinking..."}, + &ToolUseContent{ + ID: "call_1", + Name: "calculator", + Input: map[string]any{ + "a": 1.0, + "b": 2.0, + }, + }, + }, + }, + // Multiple elements marshal as array. + want: `{"content":[{"type":"text","text":"thinking..."},{"type":"tool_use","id":"call_1","name":"calculator","input":{"a":1,"b":2}}],"model":"test","role":"assistant"}`, + }, } - data, err := json.Marshal(result) - if err != nil { - t.Fatalf("Marshal() error = %v", err) - } - - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - t.Fatalf("Unmarshal raw error = %v", err) - } - - content := raw["content"] - for _, b := range content { - if b == ' ' || b == '\t' || b == '\n' || b == '\r' { - continue - } - if b == '[' { - t.Errorf("single-element Content marshaled as array, want object") - } - break + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := json.Marshal(tt.result) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + if diff := cmp.Diff(tt.want, string(got)); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + }) } } @@ -897,23 +870,56 @@ func TestCreateMessageWithToolsResult_UnmarshalJSON(t *testing.T) { tests := []struct { name string json string - wantLen int + want *CreateMessageWithToolsResult wantErr bool }{ { - name: "single tool_use content", - json: `{"content":{"type":"tool_use","id":"tool_1","name":"calculator","input":{"x":1}},"model":"test-model","role":"assistant","stopReason":"toolUse"}`, - wantLen: 1, + name: "single tool_use content", + json: `{"content":{"type":"tool_use","id":"tool_1","name":"calculator","input":{"x":1}},"model":"test-model","role":"assistant","stopReason":"toolUse"}`, + want: &CreateMessageWithToolsResult{ + Model: "test-model", + Role: "assistant", + StopReason: "toolUse", + Content: []Content{ + &ToolUseContent{ + ID: "tool_1", + Name: "calculator", + Input: map[string]any{ + "x": 1.0, + }, + }, + }, + }, }, { - name: "array of tool_use content", - json: `{"content":[{"type":"tool_use","id":"t1","name":"calc","input":{"x":1}},{"type":"tool_use","id":"t2","name":"search","input":{"q":"hi"}}],"model":"test","role":"assistant","stopReason":"toolUse"}`, - wantLen: 2, + name: "array of tool_use content", + json: `{"content":[{"type":"tool_use","id":"t1","name":"calc","input":{"x":1}},{"type":"tool_use","id":"t2","name":"search","input":{"q":"hi"}}],"model":"test","role":"assistant","stopReason":"toolUse"}`, + want: &CreateMessageWithToolsResult{ + Model: "test", + Role: "assistant", + StopReason: "toolUse", + Content: []Content{ + &ToolUseContent{ + ID: "t1", + Name: "calc", + Input: map[string]any{"x": 1.0}, + }, + &ToolUseContent{ + ID: "t2", + Name: "search", + Input: map[string]any{"q": "hi"}, + }, + }, + }, }, { - name: "empty array", - json: `{"content":[],"model":"m","role":"assistant"}`, - wantLen: 0, + name: "empty array", + json: `{"content":[],"model":"m","role":"assistant"}`, + want: &CreateMessageWithToolsResult{ + Model: "m", + Role: "assistant", + Content: []Content{}, + }, }, { name: "null content", @@ -937,100 +943,165 @@ func TestCreateMessageWithToolsResult_UnmarshalJSON(t *testing.T) { if tt.wantErr { return } - if len(got.Content) != tt.wantLen { - t.Errorf("len(Content) = %d, want %d", len(got.Content), tt.wantLen) + if diff := cmp.Diff(tt.want, &got); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) } }) } } func TestSamplingMessageV2_MarshalJSON(t *testing.T) { - msg := &SamplingMessageV2{ - Role: "user", - Content: []Content{}, + tests := []struct { + name string + msg *SamplingMessageV2 + want string + }{ + { + name: "empty content", + msg: &SamplingMessageV2{ + Role: "user", + Content: []Content{}, + }, + want: `{"content":[],"role":"user"}`, + }, + { + name: "single content", + msg: &SamplingMessageV2{ + Role: "assistant", + Content: []Content{&TextContent{Text: "hello"}}, + }, + want: `{"content":{"type":"text","text":"hello"},"role":"assistant"}`, + }, + { + name: "multiple content", + msg: &SamplingMessageV2{ + Role: "assistant", + // Text + tool_use in the same message (valid per spec for assistant). + Content: []Content{ + &TextContent{Text: "checking weather"}, + &ToolUseContent{ID: "c1", Name: "weather", Input: map[string]any{"city": "SF"}}, + }, + }, + want: `{"content":[{"type":"text","text":"checking weather"},{"type":"tool_use","id":"c1","name":"weather","input":{"city":"SF"}}],"role":"assistant"}`, + }, } - data, err := json.Marshal(msg) - if err != nil { - t.Fatalf("Marshal() error = %v", err) - } - var got SamplingMessageV2 - if err := json.Unmarshal(data, &got); err != nil { - t.Fatalf("Unmarshal() error = %v", err) - } - if len(got.Content) != 0 { - t.Errorf("len(Content) = %d, want 0", len(got.Content)) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := json.Marshal(tt.msg) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + if diff := cmp.Diff(tt.want, string(got)); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + }) } } func TestSamplingMessageV2_UnmarshalJSON(t *testing.T) { - // Text + tool_use in the same message (valid per spec for assistant). - msg := &SamplingMessageV2{ - Role: "assistant", - Content: []Content{ - &TextContent{Text: "Let me check the weather."}, - &ToolUseContent{ID: "c1", Name: "weather", Input: map[string]any{"city": "SF"}}, + tests := []struct { + name string + json string + want *SamplingMessageV2 + }{ + { + name: "single content object", + json: `{"role":"user","content":{"type":"text","text":"hello"}}`, + want: &SamplingMessageV2{ + Role: "user", + Content: []Content{ + &TextContent{Text: "hello"}, + }, + }, + }, + { + name: "multiple content", + json: `{"role":"assistant","content":[{"type":"text","text":"Let me check the weather."},{"type":"tool_use","id":"c1","name":"weather","input":{"city":"SF"}}]}`, + want: &SamplingMessageV2{ + Role: "assistant", + // Text + tool_use in the same message (valid per spec for assistant). + Content: []Content{ + &TextContent{Text: "Let me check the weather."}, + &ToolUseContent{ID: "c1", Name: "weather", Input: map[string]any{"city": "SF"}}, + }, + }, }, } - data, err := json.Marshal(msg) - if err != nil { - t.Fatalf("Marshal() error = %v", err) - } - var got SamplingMessageV2 - if err := json.Unmarshal(data, &got); err != nil { - t.Fatalf("Unmarshal() error = %v", err) - } - if len(got.Content) != 2 { - t.Fatalf("len(Content) = %d, want 2", len(got.Content)) - } - if _, ok := got.Content[0].(*TextContent); !ok { - t.Errorf("Content[0] type = %T, want *TextContent", got.Content[0]) - } - if _, ok := got.Content[1].(*ToolUseContent); !ok { - t.Errorf("Content[1] type = %T, want *ToolUseContent", got.Content[1]) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got SamplingMessageV2 + if err := json.Unmarshal([]byte(tt.json), &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if diff := cmp.Diff(tt.want, &got); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + }) } } func TestToBase_Conversion(t *testing.T) { - // Single-content messages convert successfully. - params := &CreateMessageWithToolsParams{ - MaxTokens: 1000, - Messages: []*SamplingMessageV2{ - {Role: "user", Content: []Content{&TextContent{Text: "hello"}}}, + tests := []struct { + name string + params *CreateMessageWithToolsParams + want *CreateMessageParams + wantErr bool + }{ + { + name: "Single content", + params: &CreateMessageWithToolsParams{ + MaxTokens: 1000, + Messages: []*SamplingMessageV2{ + {Role: "user", Content: []Content{&TextContent{Text: "hello"}}}, + }, + Tools: []*Tool{{Name: "calc"}}, + ToolChoice: &ToolChoice{Mode: "auto"}, + }, + want: &CreateMessageParams{ + MaxTokens: 1000, + Messages: []*SamplingMessage{ + {Role: "user", Content: &TextContent{Text: "hello"}}, + }, + }, + }, + { + name: "Multi content", + params: &CreateMessageWithToolsParams{ + MaxTokens: 1000, + Messages: []*SamplingMessageV2{ + {Role: "assistant", Content: []Content{ + &ToolUseContent{ID: "c1", Name: "calc", Input: map[string]any{}}, + &ToolUseContent{ID: "c2", Name: "search", Input: map[string]any{}}, + }}, + }, + }, + wantErr: true, }, - Tools: []*Tool{{Name: "calc"}}, - ToolChoice: &ToolChoice{Mode: "auto"}, - } - base, err := params.toBase() - if err != nil { - t.Fatalf("toBase() error = %v", err) - } - if base.MaxTokens != 1000 { - t.Errorf("MaxTokens = %d, want 1000", base.MaxTokens) - } - if tc, ok := base.Messages[0].Content.(*TextContent); !ok || tc.Text != "hello" { - t.Errorf("Messages[0].Content = %v, want TextContent{hello}", base.Messages[0].Content) } - // Multi-content messages return an error. - params2 := &CreateMessageWithToolsParams{ - MaxTokens: 1000, - Messages: []*SamplingMessageV2{ - {Role: "assistant", Content: []Content{ - &ToolUseContent{ID: "c1", Name: "calc", Input: map[string]any{}}, - &ToolUseContent{ID: "c2", Name: "search", Input: map[string]any{}}, - }}, - }, - } - if _, err := params2.toBase(); err == nil { - t.Error("toBase() should return error for multi-content message") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.params.toBase() + if (err != nil) != tt.wantErr { + t.Fatalf("toBase() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + }) } } func TestToWithTools_Conversion(t *testing.T) { tests := []struct { - name string - result *CreateMessageResult - wantLen int + name string + result *CreateMessageResult + want *CreateMessageWithToolsResult }{ { name: "with content", @@ -1040,7 +1111,12 @@ func TestToWithTools_Conversion(t *testing.T) { Content: &TextContent{Text: "hello"}, StopReason: "endTurn", }, - wantLen: 1, + want: &CreateMessageWithToolsResult{ + Model: "test", + Role: "assistant", + Content: []Content{&TextContent{Text: "hello"}}, + StopReason: "endTurn", + }, }, { name: "nil content", @@ -1048,23 +1124,18 @@ func TestToWithTools_Conversion(t *testing.T) { Model: "test", Role: "assistant", }, - wantLen: 0, + want: &CreateMessageWithToolsResult{ + Model: "test", + Role: "assistant", + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { wt := tt.result.toWithTools() - if wt.Model != tt.result.Model { - t.Errorf("Model = %v, want %v", wt.Model, tt.result.Model) - } - if len(wt.Content) != tt.wantLen { - t.Fatalf("len(Content) = %d, want %d", len(wt.Content), tt.wantLen) - } - if tt.wantLen > 0 { - if tc, ok := wt.Content[0].(*TextContent); !ok || tc.Text != "hello" { - t.Errorf("Content[0] = %v, want TextContent{hello}", wt.Content[0]) - } + if diff := cmp.Diff(tt.want, wt); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) } }) } diff --git a/mcp/sampling_test.go b/mcp/sampling_test.go index 61afce4..bdffd45 100644 --- a/mcp/sampling_test.go +++ b/mcp/sampling_test.go @@ -10,30 +10,34 @@ import ( "context" "strings" "testing" + + "github.com/google/go-cmp/cmp" ) -func TestSamplingWithTools_Integration(t *testing.T) { +func TestSamplingWithTools_ToolUse(t *testing.T) { ctx := context.Background() ct, st := NewInMemoryTransports() // Track what the client received - var receivedParams *CreateMessageWithToolsParams + var gotParams *CreateMessageWithToolsParams + result := &CreateMessageWithToolsResult{ + Model: "test-model", + Role: "assistant", + Content: []Content{ + &ToolUseContent{ + ID: "tool_call_1", + Name: "calculator", + Input: map[string]any{"x": 1.0, "y": 2.0}, + }, + }, + StopReason: "toolUse", + } // Client with tools capability, using CreateMessageWithToolsHandler client := NewClient(testImpl, &ClientOptions{ CreateMessageWithToolsHandler: func(_ context.Context, req *CreateMessageWithToolsRequest) (*CreateMessageWithToolsResult, error) { - receivedParams = req.Params - // Return a tool use response - return &CreateMessageWithToolsResult{ - Model: "test-model", - Role: "assistant", - Content: []Content{&ToolUseContent{ - ID: "tool_call_1", - Name: "calculator", - Input: map[string]any{"x": 1.0, "y": 2.0}, - }}, - StopReason: "toolUse", - }, nil + gotParams = req.Params + return result, nil }, Capabilities: &ClientCapabilities{ Sampling: &SamplingCapabilities{Tools: &SamplingToolsCapabilities{}}, @@ -54,7 +58,7 @@ func TestSamplingWithTools_Integration(t *testing.T) { defer cs.Close() // Server sends CreateMessageWithTools - result, err := ss.CreateMessageWithTools(ctx, &CreateMessageWithToolsParams{ + params := &CreateMessageWithToolsParams{ MaxTokens: 1000, Messages: []*SamplingMessageV2{ {Role: "user", Content: []Content{&TextContent{Text: "Calculate 1+2"}}}, @@ -73,54 +77,33 @@ func TestSamplingWithTools_Integration(t *testing.T) { }, }, ToolChoice: &ToolChoice{Mode: "auto"}, - }) + } + gotResult, err := ss.CreateMessageWithTools(ctx, params) if err != nil { t.Fatalf("CreateMessageWithTools() error = %v", err) } - // Verify client received the tools - if receivedParams == nil { - t.Fatal("client did not receive params") - } - if len(receivedParams.Tools) != 1 { - t.Errorf("client received %d tools, want 1", len(receivedParams.Tools)) - } - if receivedParams.Tools[0].Name != "calculator" { - t.Errorf("tool name = %v, want calculator", receivedParams.Tools[0].Name) - } - if receivedParams.ToolChoice == nil || receivedParams.ToolChoice.Mode != "auto" { - t.Errorf("tool choice mode = %v, want auto", receivedParams.ToolChoice) + // Verify client received the params + if diff := cmp.Diff(params, gotParams); diff != "" { + t.Errorf("CreateMessageWithToolsParams mismatch (-want +got):\n%s", diff) } // Verify server received the tool use response - if result.StopReason != "toolUse" { - t.Errorf("StopReason = %v, want toolUse", result.StopReason) - } - if len(result.Content) != 1 { - t.Fatalf("len(Content) = %d, want 1", len(result.Content)) - } - toolUse, ok := result.Content[0].(*ToolUseContent) - if !ok { - t.Fatalf("Content[0] type = %T, want *ToolUseContent", result.Content[0]) - } - if toolUse.ID != "tool_call_1" { - t.Errorf("ToolUse.ID = %v, want tool_call_1", toolUse.ID) - } - if toolUse.Name != "calculator" { - t.Errorf("ToolUse.Name = %v, want calculator", toolUse.Name) + if diff := cmp.Diff(result, gotResult); diff != "" { + t.Errorf("CreateMessageWithToolsResult mismatch (-want +got):\n%s", diff) } } -func TestSamplingWithToolResult_Integration(t *testing.T) { +func TestSamplingWithTools_ToolResult(t *testing.T) { ctx := context.Background() ct, st := NewInMemoryTransports() // Track messages received by client - var receivedMessages []*SamplingMessage + var gotParams *CreateMessageParams client := NewClient(testImpl, &ClientOptions{ CreateMessageHandler: func(_ context.Context, req *CreateMessageRequest) (*CreateMessageResult, error) { - receivedMessages = req.Params.Messages + gotParams = req.Params return &CreateMessageResult{ Model: "test-model", Role: "assistant", @@ -145,8 +128,7 @@ func TestSamplingWithToolResult_Integration(t *testing.T) { } defer cs.Close() - // Server sends CreateMessage with tool result in messages - _, err = ss.CreateMessage(ctx, &CreateMessageParams{ + params := &CreateMessageParams{ MaxTokens: 1000, Messages: []*SamplingMessage{ {Role: "user", Content: &TextContent{Text: "Calculate 1+2"}}, @@ -160,43 +142,14 @@ func TestSamplingWithToolResult_Integration(t *testing.T) { Content: []Content{&TextContent{Text: "3"}}, }}, }, - }) + } + _, err = ss.CreateMessage(ctx, params) if err != nil { t.Fatalf("CreateMessage() error = %v", err) } - // Verify client received all messages including tool content - if len(receivedMessages) != 3 { - t.Fatalf("received %d messages, want 3", len(receivedMessages)) - } - - // Check first message is text - if _, ok := receivedMessages[0].Content.(*TextContent); !ok { - t.Errorf("message[0] content type = %T, want *TextContent", receivedMessages[0].Content) - } - - // Check second message is tool use - toolUse, ok := receivedMessages[1].Content.(*ToolUseContent) - if !ok { - t.Fatalf("message[1] content type = %T, want *ToolUseContent", receivedMessages[1].Content) - } - if toolUse.ID != "tool_1" { - t.Errorf("toolUse.ID = %v, want tool_1", toolUse.ID) - } - - // Check third message is tool result - toolResult, ok := receivedMessages[2].Content.(*ToolResultContent) - if !ok { - t.Fatalf("message[2] content type = %T, want *ToolResultContent", receivedMessages[2].Content) - } - if toolResult.ToolUseID != "tool_1" { - t.Errorf("toolResult.ToolUseID = %v, want tool_1", toolResult.ToolUseID) - } - if len(toolResult.Content) != 1 { - t.Fatalf("toolResult.Content len = %d, want 1", len(toolResult.Content)) - } - if tc, ok := toolResult.Content[0].(*TextContent); !ok || tc.Text != "3" { - t.Errorf("toolResult.Content[0] = %v, want TextContent with '3'", toolResult.Content[0]) + if diff := cmp.Diff(params, gotParams); diff != "" { + t.Errorf("CreateMessageParams mismatch (-want +got):\n%s", diff) } } @@ -233,14 +186,12 @@ func TestSamplingToolsCapabilities(t *testing.T) { // Check server sees client capabilities caps := ss.InitializeParams().Capabilities - if caps.Sampling == nil { - t.Fatal("client should advertise sampling capability") + want := &SamplingCapabilities{ + Tools: &SamplingToolsCapabilities{}, + Context: &SamplingContextCapabilities{}, } - if caps.Sampling.Tools == nil { - t.Error("client should advertise sampling.tools capability") - } - if caps.Sampling.Context == nil { - t.Error("client should advertise sampling.context capability") + if diff := cmp.Diff(want, caps.Sampling); diff != "" { + t.Errorf("SamplingCapabilities mismatch (-want +got):\n%s", diff) } }) @@ -269,14 +220,9 @@ func TestSamplingToolsCapabilities(t *testing.T) { // Check server sees client capabilities caps := ss.InitializeParams().Capabilities - if caps.Sampling == nil { - t.Fatal("client should advertise sampling capability") - } - if caps.Sampling.Tools != nil { - t.Error("client should NOT advertise sampling.tools capability") - } - if caps.Sampling.Context != nil { - t.Error("client should NOT advertise sampling.context capability") + want := &SamplingCapabilities{} + if diff := cmp.Diff(want, caps.Sampling); diff != "" { + t.Errorf("SamplingCapabilities mismatch (-want +got):\n%s", diff) } }) @@ -304,24 +250,24 @@ func TestSamplingToolsCapabilities(t *testing.T) { defer cs.Close() caps := ss.InitializeParams().Capabilities - if caps.Sampling == nil { - t.Fatal("client should advertise sampling capability") + want := &SamplingCapabilities{ + Tools: &SamplingToolsCapabilities{}, } - if caps.Sampling.Tools == nil { - t.Error("client should infer sampling.tools capability from CreateMessageWithToolsHandler") + if diff := cmp.Diff(want, caps.Sampling); diff != "" { + t.Errorf("SamplingCapabilities mismatch (-want +got):\n%s", diff) } }) } -func TestSamplingToolResultWithError_Integration(t *testing.T) { +func TestSamplingWithTools_ToolResultWithError(t *testing.T) { ctx := context.Background() ct, st := NewInMemoryTransports() - var receivedMessages []*SamplingMessage + var gotParams *CreateMessageParams client := NewClient(testImpl, &ClientOptions{ CreateMessageHandler: func(_ context.Context, req *CreateMessageRequest) (*CreateMessageResult, error) { - receivedMessages = req.Params.Messages + gotParams = req.Params return &CreateMessageResult{ Model: "test-model", Role: "assistant", @@ -346,9 +292,7 @@ func TestSamplingToolResultWithError_Integration(t *testing.T) { } defer cs.Close() - // Server sends CreateMessage with error tool result, preceded by - // the original request and tool use for a more realistic scenario. - _, err = ss.CreateMessage(ctx, &CreateMessageParams{ + params := &CreateMessageParams{ MaxTokens: 1000, Messages: []*SamplingMessage{ {Role: "user", Content: &TextContent{Text: "Divide 1 by 0"}}, @@ -363,43 +307,34 @@ func TestSamplingToolResultWithError_Integration(t *testing.T) { IsError: true, }}, }, - }) + } + _, err = ss.CreateMessage(ctx, params) if err != nil { t.Fatalf("CreateMessage() error = %v", err) } - if len(receivedMessages) != 3 { - t.Fatalf("received %d messages, want 3", len(receivedMessages)) - } - - toolResult, ok := receivedMessages[2].Content.(*ToolResultContent) - if !ok { - t.Fatalf("content type = %T, want *ToolResultContent", receivedMessages[2].Content) - } - if !toolResult.IsError { - t.Error("IsError should be true") - } - if toolResult.ToolUseID != "tool_1" { - t.Errorf("ToolUseID = %v, want tool_1", toolResult.ToolUseID) + if diff := cmp.Diff(params, gotParams); diff != "" { + t.Errorf("CreateMessageParams mismatch (-want +got):\n%s", diff) } } -func TestParallelToolCalls_Integration(t *testing.T) { +func TestSamplingWithTools_ParallelToolCalls(t *testing.T) { ctx := context.Background() ct, st := NewInMemoryTransports() + result := &CreateMessageWithToolsResult{ + Model: "test-model", + Role: "assistant", + Content: []Content{ + &ToolUseContent{ID: "call_1", Name: "weather", Input: map[string]any{"city": "SF"}}, + &ToolUseContent{ID: "call_2", Name: "weather", Input: map[string]any{"city": "NY"}}, + }, + StopReason: "toolUse", + } // Client returns parallel tool use results client := NewClient(testImpl, &ClientOptions{ CreateMessageWithToolsHandler: func(_ context.Context, req *CreateMessageWithToolsRequest) (*CreateMessageWithToolsResult, error) { - return &CreateMessageWithToolsResult{ - Model: "test-model", - Role: "assistant", - Content: []Content{ - &ToolUseContent{ID: "call_1", Name: "weather", Input: map[string]any{"city": "SF"}}, - &ToolUseContent{ID: "call_2", Name: "weather", Input: map[string]any{"city": "NY"}}, - }, - StopReason: "toolUse", - }, nil + return result, nil }, Capabilities: &ClientCapabilities{ Sampling: &SamplingCapabilities{Tools: &SamplingToolsCapabilities{}}, @@ -419,7 +354,7 @@ func TestParallelToolCalls_Integration(t *testing.T) { } defer cs.Close() - result, err := ss.CreateMessageWithTools(ctx, &CreateMessageWithToolsParams{ + gotResult, err := ss.CreateMessageWithTools(ctx, &CreateMessageWithToolsParams{ MaxTokens: 1000, Messages: []*SamplingMessageV2{ {Role: "user", Content: []Content{&TextContent{Text: "Weather in SF and NY"}}}, @@ -432,23 +367,8 @@ func TestParallelToolCalls_Integration(t *testing.T) { t.Fatalf("CreateMessageWithTools() error = %v", err) } - if len(result.Content) != 2 { - t.Fatalf("len(Content) = %d, want 2", len(result.Content)) - } - for i, c := range result.Content { - tu, ok := c.(*ToolUseContent) - if !ok { - t.Fatalf("Content[%d] type = %T, want *ToolUseContent", i, c) - } - if tu.Name != "weather" { - t.Errorf("Content[%d].Name = %v, want weather", i, tu.Name) - } - } - if result.Content[0].(*ToolUseContent).ID != "call_1" { - t.Errorf("Content[0].ID = %v, want call_1", result.Content[0].(*ToolUseContent).ID) - } - if result.Content[1].(*ToolUseContent).ID != "call_2" { - t.Errorf("Content[1].ID = %v, want call_2", result.Content[1].(*ToolUseContent).ID) + if diff := cmp.Diff(result, gotResult); diff != "" { + t.Errorf("CreateMessageWithToolsResult mismatch (-want +got):\n%s", diff) } } From f4e769f6747d5e62c5e044c081cb7012cefa9294 Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Mon, 2 Mar 2026 11:20:57 +0100 Subject: [PATCH 218/418] auth: fix 2025-03-26 backcompat (#821) --- auth/authorization_code.go | 37 +++++----- auth/authorization_code_test.go | 120 ++++++++++++++++++-------------- 2 files changed, 88 insertions(+), 69 deletions(-) diff --git a/auth/authorization_code.go b/auth/authorization_code.go index 2a6ed32..9a81358 100644 --- a/auth/authorization_code.go +++ b/auth/authorization_code.go @@ -230,7 +230,7 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ Scopes: scps, } - authRes, err := h.getAuthorizationCode(ctx, cfg, req.URL.String()) + authRes, err := h.getAuthorizationCode(ctx, cfg, prm.Resource) if err != nil { // Purposefully leaving the error unwrappable so it can be handled by the caller. return err @@ -289,9 +289,24 @@ func (h *AuthorizationCodeHandler) getProtectedResourceMetadata(ctx context.Cont errs = append(errs, fmt.Errorf("protected resource metadata is nil")) continue } + if len(prm.AuthorizationServers) == 0 { + // If we found PRM, we enforce the 2025-11-25 spec and not search further. + return nil, fmt.Errorf("protected resource metadata has no authorization servers specified") + } return prm, nil } - return nil, fmt.Errorf("failed to get protected resource metadata: %v", errors.Join(errs...)) + // Fallback to 2025-03-26 spec MCP server root is the Authorization Server: + // https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#server-metadata-discovery + u, err := url.Parse(mcpServerURL) + if err != nil { + return nil, fmt.Errorf("failed to parse MCP server URL: %v", err) + } + u.Path = "" + prm := &oauthex.ProtectedResourceMetadata{ + AuthorizationServers: []string{u.String()}, + Resource: mcpServerURL, + } + return prm, nil } type prmURL struct { @@ -335,26 +350,14 @@ func protectedResourceMetadataURLs(metadataURL, resourceURL string) []prmURL { } // getAuthServerMetadata returns the authorization server metadata. -// The provided Protected Resource Metadata must not be nil. +// The provided Protected Resource Metadata must not be nil and must contain +// at least one authorization server. // It returns an error if the metadata request fails with non-4xx HTTP status code // or the fetched metadata fails security checks. // If no metadata was found, it returns a minimal set of endpoints // as a fallback to 2025-03-26 spec. func (h *AuthorizationCodeHandler) getAuthServerMetadata(ctx context.Context, prm *oauthex.ProtectedResourceMetadata) (*oauthex.AuthServerMeta, error) { - var authServerURL string - if len(prm.AuthorizationServers) > 0 { - // Use the first authorization server, similarly to other SDKs. - authServerURL = prm.AuthorizationServers[0] - } else { - // Fallback to 2025-03-26 spec: MCP server base URL acts as Authorization Server. - authURL, err := url.Parse(prm.Resource) - if err != nil { - return nil, fmt.Errorf("failed to parse resource URL: %v", err) - } - authURL.Path = "" - authServerURL = authURL.String() - } - + authServerURL := prm.AuthorizationServers[0] for _, u := range authorizationServerMetadataURLs(authServerURL) { asm, err := oauthex.GetAuthServerMeta(ctx, u, authServerURL, http.DefaultClient) if err != nil { diff --git a/auth/authorization_code_test.go b/auth/authorization_code_test.go index 77214cd..c00e764 100644 --- a/auth/authorization_code_test.go +++ b/auth/authorization_code_test.go @@ -12,6 +12,7 @@ import ( "net/http" "net/http/httptest" "net/http/httputil" + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -322,43 +323,40 @@ func TestNewAuthorizationCodeHandler_Error(t *testing.T) { } } -func TestGetProtectedResourceMetadata(t *testing.T) { +func TestGetProtectedResourceMetadata_Success(t *testing.T) { handler := &AuthorizationCodeHandler{} // No config needed for this method pathForChallenge := "/protected-resource" tests := []struct { name string challengesProvided bool - prmPath string - resourcePath string - wantError bool + // Path of the PRM endpoint. + prmPath string + // Path of the MCP server that is accessed. + mcpServerPath string + // Path for the Resource expected in the returned PRM. + resourcePath string }{ { name: "FromChallenges", challengesProvided: true, prmPath: pathForChallenge, + mcpServerPath: "/resource", resourcePath: "/resource", - wantError: false, }, { name: "FallbackToEndpoint", challengesProvided: false, prmPath: "/.well-known/oauth-protected-resource/resource", + mcpServerPath: "/resource", resourcePath: "/resource", - wantError: false, }, { name: "FallbackToRoot", challengesProvided: false, prmPath: "/.well-known/oauth-protected-resource", + mcpServerPath: "/resource", resourcePath: "", - wantError: false, - }, - { - name: "NoMetadata", - challengesProvided: false, - prmPath: "/incorrect", - wantError: true, }, } @@ -367,10 +365,10 @@ func TestGetProtectedResourceMetadata(t *testing.T) { mux := http.NewServeMux() server := httptest.NewServer(mux) t.Cleanup(server.Close) - resourceURL := server.URL + tt.resourcePath metadata := &oauthex.ProtectedResourceMetadata{ - Resource: resourceURL, - ScopesSupported: []string{"read", "write"}, + Resource: server.URL + tt.resourcePath, + AuthorizationServers: []string{"https://oauth.example.com"}, + ScopesSupported: []string{"read", "write"}, } mux.Handle(tt.prmPath, ProtectedResourceMetadataHandler(metadata)) var challenges []oauthex.Challenge @@ -385,12 +383,9 @@ func TestGetProtectedResourceMetadata(t *testing.T) { } } - got, err := handler.getProtectedResourceMetadata(t.Context(), challenges, resourceURL) + got, err := handler.getProtectedResourceMetadata(t.Context(), challenges, server.URL+tt.mcpServerPath) if err != nil { - if !tt.wantError { - t.Fatalf("getProtectedResourceMetadata() error = %v, want nil", err) - } - return + t.Fatalf("getProtectedResourceMetadata() error = %v", err) } if got == nil { t.Fatal("getProtectedResourceMetadata() got nil, want metadata") @@ -402,59 +397,86 @@ func TestGetProtectedResourceMetadata(t *testing.T) { } } +func TestGetProtectedResourceMetadata_Backcompat(t *testing.T) { + var challenges []oauthex.Challenge + handler := &AuthorizationCodeHandler{} // No config needed for this method + got, err := handler.getProtectedResourceMetadata(t.Context(), challenges, "http://localhost:1234/resource") + if err != nil { + t.Fatalf("getProtectedResourceMetadata() error = %v", err) + } + wantPRM := &oauthex.ProtectedResourceMetadata{ + Resource: "http://localhost:1234/resource", + AuthorizationServers: []string{"http://localhost:1234"}, + } + if diff := cmp.Diff(wantPRM, got); diff != "" { + t.Errorf("getProtectedResourceMetadata() metadata mismatch (-want +got):\n%s", diff) + } +} + +func TestGetProtectedResourceMetadata_Error(t *testing.T) { + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + metadata := &oauthex.ProtectedResourceMetadata{ + Resource: server.URL + "/resource", + AuthorizationServers: nil, // Empty list is invalid + ScopesSupported: []string{"read", "write"}, + } + mux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(metadata)) + var challenges []oauthex.Challenge + handler := &AuthorizationCodeHandler{} // No config needed for this method + got, err := handler.getProtectedResourceMetadata(t.Context(), challenges, server.URL+"/resource") + if err == nil || !strings.Contains(err.Error(), "authorization servers") { + t.Errorf("getProtectedResourceMetadata() = %v, want error containing \"authorization servers\"", err) + } + if got != nil { + t.Errorf("getProtectedResourceMetadata() = %+v, want nil", got) + } +} + func TestGetAuthServerMetadata(t *testing.T) { handler := &AuthorizationCodeHandler{} // No config needed for this method tests := []struct { - name string - authorizationAtMCPServer bool - issuerPath string - endpointConfig *oauthtest.MetadataEndpointConfig + name string + issuerPath string + endpointConfig *oauthtest.MetadataEndpointConfig }{ { - name: "OAuthEndpoint_Root", - authorizationAtMCPServer: false, - issuerPath: "", + name: "OAuthEndpoint_Root", + issuerPath: "", endpointConfig: &oauthtest.MetadataEndpointConfig{ ServeOAuthInsertedEndpoint: true, }, }, { - name: "OpenIDEndpoint_Root", - authorizationAtMCPServer: false, - issuerPath: "", + name: "OpenIDEndpoint_Root", + issuerPath: "", endpointConfig: &oauthtest.MetadataEndpointConfig{ ServeOpenIDInsertedEndpoint: true, }, }, { - name: "OAuthEndpoint_Path", - authorizationAtMCPServer: false, - issuerPath: "/oauth", + name: "OAuthEndpoint_Path", + issuerPath: "/oauth", endpointConfig: &oauthtest.MetadataEndpointConfig{ ServeOAuthInsertedEndpoint: true, }, }, { - name: "OpenIDEndpoint_Path", - authorizationAtMCPServer: false, - issuerPath: "/openid", + name: "OpenIDEndpoint_Path", + issuerPath: "/openid", endpointConfig: &oauthtest.MetadataEndpointConfig{ ServeOpenIDInsertedEndpoint: true, }, }, { - name: "OpenIDAppendedEndpoint_Path", - authorizationAtMCPServer: false, - issuerPath: "/openid", + name: "OpenIDAppendedEndpoint_Path", + issuerPath: "/openid", endpointConfig: &oauthtest.MetadataEndpointConfig{ ServeOpenIDAppendedEndpoint: true, }, }, - { - name: "FallbackToMCPServer", - authorizationAtMCPServer: true, - }, { name: "NoMetadata", issuerPath: "", @@ -474,15 +496,9 @@ func TestGetAuthServerMetadata(t *testing.T) { }) s.Start(t) issuerURL := s.URL() + tt.issuerPath - resourceURL := "https://example.com/resource" - authServers := []string{issuerURL} - if tt.authorizationAtMCPServer { - resourceURL = issuerURL - authServers = nil - } prm := &oauthex.ProtectedResourceMetadata{ - Resource: resourceURL, - AuthorizationServers: authServers, + Resource: "https://example.com/resource", + AuthorizationServers: []string{issuerURL}, } got, err := handler.getAuthServerMetadata(t.Context(), prm) From f52a48dc9b51594e912329ba8fcd7d8d444585d5 Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Mon, 2 Mar 2026 12:50:13 +0100 Subject: [PATCH 219/418] chore: update deps after v1.4.0 release (#829) --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index a9aea54..9e99d46 100644 --- a/go.mod +++ b/go.mod @@ -3,16 +3,16 @@ module github.com/modelcontextprotocol/go-sdk go 1.24.0 require ( - github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-cmp v0.7.0 github.com/google/jsonschema-go v0.4.2 github.com/segmentio/encoding v0.5.3 github.com/yosida95/uritemplate/v3 v3.0.2 - golang.org/x/oauth2 v0.34.0 - golang.org/x/tools v0.41.0 + golang.org/x/oauth2 v0.35.0 + golang.org/x/tools v0.42.0 ) require ( github.com/segmentio/asm v1.1.3 // indirect - golang.org/x/sys v0.40.0 // indirect + golang.org/x/sys v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index 53778f7..998e07a 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= @@ -10,9 +10,9 @@ github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQcc github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= From 41ac43d2eda8df1cdb4d7c7b8a7a477467e6527b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:03:07 +0100 Subject: [PATCH 220/418] build(deps): bump github/codeql-action from 3 to 4 (#823) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4.
Release notes

Sourced from github/codeql-action's releases.

v3.32.4

  • Update default CodeQL bundle version to 2.24.2. #3493
  • Added an experimental change which improves how certificates are generated for the authentication proxy that is used by the CodeQL Action in Default Setup when private package registries are configured. This is expected to generate more widely compatible certificates and should have no impact on analyses which are working correctly already. We expect to roll this change out to everyone in February. #3473
  • When the CodeQL Action is run with debugging enabled in Default Setup and private package registries are configured, the "Setup proxy for registries" step will output additional diagnostic information that can be used for troubleshooting. #3486
  • Added a setting which allows the CodeQL Action to enable network debugging for Java programs. This will help GitHub staff support customers with troubleshooting issues in GitHub-managed CodeQL workflows, such as Default Setup. This setting can only be enabled by GitHub staff. #3485
  • Added a setting which enables GitHub-managed workflows, such as Default Setup, to use a nightly CodeQL CLI release instead of the latest, stable release that is used by default. This will help GitHub staff support customers whose analyses for a given repository or organization require early access to a change in an upcoming CodeQL CLI release. This setting can only be enabled by GitHub staff. #3484

v3.32.3

  • Added experimental support for testing connections to private package registries. This feature is not currently enabled for any analysis. In the future, it may be enabled by default for Default Setup. #3466

v3.32.2

  • Update default CodeQL bundle version to 2.24.1. #3460

v3.32.1

  • A warning is now shown in Default Setup workflow logs if a private package registry is configured using a GitHub Personal Access Token (PAT), but no username is configured. #3422
  • Fixed a bug which caused the CodeQL Action to fail when repository properties cannot successfully be retrieved. #3421

v3.32.0

  • Update default CodeQL bundle version to 2.24.0. #3425

v3.31.11

  • When running a Default Setup workflow with Actions debugging enabled, the CodeQL Action will now use more unique names when uploading logs from the Dependabot authentication proxy as workflow artifacts. This ensures that the artifact names do not clash between multiple jobs in a build matrix. #3409
  • Improved error handling throughout the CodeQL Action. #3415
  • Added experimental support for automatically excluding generated files from the analysis. This feature is not currently enabled for any analysis. In the future, it may be enabled by default for some GitHub-managed analyses. #3318
  • The changelog extracts that are included with releases of the CodeQL Action are now shorter to avoid duplicated information from appearing in Dependabot PRs. #3403

v3.31.10

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

3.31.10 - 12 Jan 2026

  • Update default CodeQL bundle version to 2.23.9. #3393

See the full CHANGELOG.md for more information.

v3.31.9

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

3.31.9 - 16 Dec 2025

No user facing changes.

See the full CHANGELOG.md for more information.

v3.31.8

CodeQL Action Changelog

... (truncated)

Commits
  • 5c96b6e Add JSDoc comments to upload-lib types
  • 44a4bea Fixup: add missing .env
  • 11c6c18 Only run when debugging or test mode is enabled
  • 99fcc7b Check whether value is a URL in checkEnvVar and clear credentials
  • c1d6ee5 Fix typos
  • ef9cfd9 Clear GHA JAVA_HOME_* env vars for discoverActionsJdks test
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github/codeql-action&package-manager=github_actions&previous-version=3&new-version=4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Maciek Kisiel --- .github/workflows/scorecard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 3548543..67694c9 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -73,6 +73,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 with: sarif_file: results.sarif From 4e1c3a34b59c6a0abdacae9934661b6da564c699 Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Tue, 3 Mar 2026 04:30:11 -0500 Subject: [PATCH 221/418] mcp: update latestProtocolVersion to 2025-11-25 (#724) Update the SDK to use protocol version 2025-11-25 as the latest supported version. Fix tests and examples that had hardcoded expectations for the previous version (2025-06-18). --------- Co-authored-by: Maciej Kisiel --- docs/protocol.md | 2 +- docs/troubleshooting.md | 4 ++-- mcp/protocol.go | 6 ------ mcp/server_test.go | 4 ++-- mcp/shared.go | 4 ++-- mcp/streamable_example_test.go | 2 +- mcp/streamable_test.go | 2 +- mcp/testdata/conformance/server/version-latest.txtar | 2 +- mcp/transport_example_test.go | 4 ++-- 9 files changed, 12 insertions(+), 18 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index abdf50f..c4f1cef 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -165,7 +165,7 @@ func ExampleStreamableHTTPHandler() { resp := mustPostMessage(`{"jsonrpc": "2.0", "id": 1, "method":"initialize", "params": {}}`, httpServer.URL) fmt.Println(resp) // Output: - // {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-06-18","serverInfo":{"name":"server","version":"v0.1.0"}}} + // {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-11-25","serverInfo":{"name":"server","version":"v0.1.0"}}} } ``` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b8cc776..1a0c367 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -50,8 +50,8 @@ func ExampleLoggingTransport() { } // Output: - // read: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-06-18","serverInfo":{"name":"server","version":"v0.0.1"}}} - // write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-06-18","capabilities":{"roots":{"listChanged":true}}}} + // read: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-11-25","serverInfo":{"name":"server","version":"v0.0.1"}}} + // write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-11-25","capabilities":{"roots":{"listChanged":true}}}} // write: {"jsonrpc":"2.0","method":"notifications/initialized","params":{}} } ``` diff --git a/mcp/protocol.go b/mcp/protocol.go index 837ce78..d071740 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -4,12 +4,6 @@ package mcp -// Protocol types for version 2025-06-18. -// To see the schema changes from the previous version, run: -// -// prefix=https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/refs/heads/main/schema -// sdiff -l <(curl $prefix/2025-03-26/schema.ts) <(curl $prefix/2025/06-18/schema.ts) - import ( "encoding/json" "fmt" diff --git a/mcp/server_test.go b/mcp/server_test.go index 227e7be..e57af1e 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -763,13 +763,13 @@ func TestClientRootCapabilities(t *testing.T) { var initParams json.RawMessage if tc.capabilities != nil { initParams = json.RawMessage(`{ - "protocolVersion": "2025-06-18", + "protocolVersion": "2025-11-25", "capabilities": ` + *tc.capabilities + `, "clientInfo": {"name": "TestClient", "version": "1.0.0"} }`) } else { initParams = json.RawMessage(`{ - "protocolVersion": "2025-06-18", + "protocolVersion": "2025-11-25", "clientInfo": {"name": "TestClient", "version": "1.0.0"} }`) } diff --git a/mcp/shared.go b/mcp/shared.go index bda00c2..f8c9250 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -34,8 +34,8 @@ const ( // // It is the version that the client sends in the initialization request, and // the default version used by the server. - latestProtocolVersion = protocolVersion20250618 - protocolVersion20251125 = "2025-11-25" // not yet released + latestProtocolVersion = protocolVersion20251125 + protocolVersion20251125 = "2025-11-25" protocolVersion20250618 = "2025-06-18" protocolVersion20250326 = "2025-03-26" protocolVersion20241105 = "2024-11-05" diff --git a/mcp/streamable_example_test.go b/mcp/streamable_example_test.go index f1cdf90..7ff1612 100644 --- a/mcp/streamable_example_test.go +++ b/mcp/streamable_example_test.go @@ -35,7 +35,7 @@ func ExampleStreamableHTTPHandler() { resp := mustPostMessage(`{"jsonrpc": "2.0", "id": 1, "method":"initialize", "params": {}}`, httpServer.URL) fmt.Println(resp) // Output: - // {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-06-18","serverInfo":{"name":"server","version":"v0.1.0"}}} + // {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-11-25","serverInfo":{"name":"server","version":"v0.1.0"}}} } // !-streamablehandler diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 22d0d1c..7a504a8 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -758,7 +758,7 @@ func TestStreamableServerTransport(t *testing.T) { Logging: &LoggingCapabilities{}, Tools: &ToolCapabilities{ListChanged: true}, }, - ProtocolVersion: latestProtocolVersion, + ProtocolVersion: protocolVersion20250618, ServerInfo: &Implementation{Name: "testServer", Version: "v1.0.0"}, }, nil) initializedMsg := req(0, notificationInitialized, &InitializedParams{}) diff --git a/mcp/testdata/conformance/server/version-latest.txtar b/mcp/testdata/conformance/server/version-latest.txtar index 7531767..77d1de2 100644 --- a/mcp/testdata/conformance/server/version-latest.txtar +++ b/mcp/testdata/conformance/server/version-latest.txtar @@ -20,7 +20,7 @@ response with its latest supported version. "capabilities": { "logging": {} }, - "protocolVersion": "2025-06-18", + "protocolVersion": "2025-11-25", "serverInfo": { "name": "testServer", "version": "v1.0.0" diff --git a/mcp/transport_example_test.go b/mcp/transport_example_test.go index bb032f1..62fc031 100644 --- a/mcp/transport_example_test.go +++ b/mcp/transport_example_test.go @@ -45,8 +45,8 @@ func ExampleLoggingTransport() { } // Output: - // read: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-06-18","serverInfo":{"name":"server","version":"v0.0.1"}}} - // write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-06-18","capabilities":{"roots":{"listChanged":true}}}} + // read: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-11-25","serverInfo":{"name":"server","version":"v0.0.1"}}} + // write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-11-25","capabilities":{"roots":{"listChanged":true}}}} // write: {"jsonrpc":"2.0","method":"notifications/initialized","params":{}} } From 16d990b0416f63ca4948a5d8ba8f54ac6114b5a9 Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Tue, 3 Mar 2026 15:13:10 +0100 Subject: [PATCH 222/418] mcp: protect ioConn.protocolVersion with a mutex (#832) This is done to prevent concurrent reads and writes, which violate Go's memory model. The concurrent write may be triggered e.g. by setting the log level on the client side. Fixes #828 --- mcp/transport.go | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/mcp/transport.go b/mcp/transport.go index 5f2a500..23dccf8 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -344,7 +344,11 @@ func (r rwc) Close() error { // // See [msgBatch] for more discussion of message batching. type ioConn struct { - protocolVersion string // negotiated version, set during session initialization. + // protocolVersion is the negotiated version of the protocol, + // set during session initialization. + // Since writes may be concurrent to reads, we need to guard this with a mutex. + sessionMu sync.Mutex + protocolVersion string writeMu sync.Mutex // guards Write, which must be concurrency safe. rwc io.ReadWriteCloser // the underlying stream @@ -430,9 +434,15 @@ func (c *ioConn) sessionUpdated(state ServerSessionState) { protocolVersion = state.InitializeParams.ProtocolVersion } if protocolVersion == "" { + // 2025-03-26 is used, because it's the last spec version + // where specifying the protocol version in the HTTP header + // was not required. protocolVersion = protocolVersion20250326 } - c.protocolVersion = negotiatedVersion(protocolVersion) + protocolVersion = negotiatedVersion(protocolVersion) + c.sessionMu.Lock() + c.protocolVersion = protocolVersion + c.sessionMu.Unlock() } // addBatch records a msgBatch for an incoming batch payload. @@ -533,8 +543,12 @@ func (t *ioConn) Read(ctx context.Context) (jsonrpc.Message, error) { if err != nil { return nil, err } - if batch && t.protocolVersion >= protocolVersion20250618 { - return nil, fmt.Errorf("JSON-RPC batching is not supported in %s and later (request version: %s)", protocolVersion20250618, t.protocolVersion) + var protocolVersion string + t.sessionMu.Lock() + protocolVersion = t.protocolVersion + t.sessionMu.Unlock() + if batch && protocolVersion >= protocolVersion20250618 { + return nil, fmt.Errorf("JSON-RPC batching is not supported in %s and later (request version: %s)", protocolVersion20250618, protocolVersion) } t.queue = msgs[1:] From 4cdbaaf27132e5356ba13973ae50da4edfa876bb Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Tue, 10 Mar 2026 10:57:55 +0100 Subject: [PATCH 223/418] examples: add an example that display header forwarding. (#836) Created to document the way it can be done discussed in #373. Fixes #373 --- examples/server/proxy/README.md | 52 ++++++++++++ examples/server/proxy/main.go | 141 ++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 examples/server/proxy/README.md create mode 100644 examples/server/proxy/main.go diff --git a/examples/server/proxy/README.md b/examples/server/proxy/README.md new file mode 100644 index 0000000..5aa37c1 --- /dev/null +++ b/examples/server/proxy/README.md @@ -0,0 +1,52 @@ +# Proxy Header Propagation Example + +This example demonstrates how to propagate HTTP headers from an incoming MCP server request to an outgoing MCP client request. + +This is particularly useful when building a gateway or proxy server that needs to forward authentication tokens, trace IDs, or other context-sensitive headers to downstream services. + +## Architecture + +This example runs three components in a single process: + +1. **Backend Server** (Port 8082): + - Exposes a tool `echo_headers` that returns the headers it received. + +2. **Proxy Server** (Port 8081): + - Exposes a tool `forward_headers`. + - Acts as a client to the Backend Server. + - Uses a custom `http.RoundTripper` (`HeaderForwardingTransport`) to inject headers from the context into outgoing requests. + +3. **Client**: + - Connects to the Proxy Server. + - Calls `forward_headers`. + +## How it works + +1. The Client calls `forward_headers` on the Proxy Server. +2. The Proxy Server receives the request. The request context contains the HTTP headers in `req.Extra.Header`. +3. The Proxy's tool handler extracts these headers and places them into a new `context.Context` using a specific key (`headerContextKey`). +4. The Proxy uses an `mcp.Client` configured with a custom `HTTPClient` that uses `HeaderForwardingTransport`. +5. `HeaderForwardingTransport.RoundTrip` inspects the context of outgoing requests. If it finds headers under `headerContextKey`, it adds them to the HTTP request. +6. The Backend Server receives the request with the propagated headers. + +## Running the Example + +Run the example with: + +```bash +go run main.go +``` + +You should see output indicating: +1. Backend and Proxy servers starting. +2. Gateway receiving headers. +3. Client receiving the result, which contains the echoed headers. + +Example output: + +``` +2025/08/29 10:00:00 Starting Backend Server on :8082 +2025/08/29 10:00:00 Starting Gateway Server on :8081 +2025/08/29 10:00:00 Gateway received headers: map[...] +2025/08/29 10:00:00 Client received result: ... +``` diff --git a/examples/server/proxy/main.go b/examples/server/proxy/main.go new file mode 100644 index 0000000..3a4469b --- /dev/null +++ b/examples/server/proxy/main.go @@ -0,0 +1,141 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Define a context key for passing headers. +type contextKey string + +const headerContextKey contextKey = "ctx-headers" + +// HeaderForwardingTransport is an http.RoundTripper that injects headers +// from the context into the outgoing request. +type HeaderForwardingTransport struct{} + +// RoundTrip executes a single HTTP transaction, adding headers from the context. +func (h *HeaderForwardingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // Clone the request to avoid modifying the original one. + newReq := req.Clone(req.Context()) + + // Retrieve headers from the context. + if headers, ok := req.Context().Value(headerContextKey).(http.Header); ok { + for key, values := range headers { + // Copy all values for each header. + for _, value := range values { + newReq.Header.Add(key, value) + } + } + } + + return http.DefaultTransport.RoundTrip(newReq) +} + +// NewHeaderForwardingClient creates a new http.Client that uses HeaderForwardingTransport. +func NewHeaderForwardingClient() *http.Client { + return &http.Client{ + Transport: new(HeaderForwardingTransport), + } +} + +// Backend Server: Echoes received headers to verify propagation. +func runBackendServer() { + server := mcp.NewServer(&mcp.Implementation{Name: "backend-server", Version: "1.0.0"}, nil) + + mcp.AddTool(server, &mcp.Tool{ + Name: "echo_headers", + Description: "Returns the headers received by the server", + }, func(ctx context.Context, req *mcp.CallToolRequest, args struct{}) (*mcp.CallToolResult, any, error) { + return nil, req.Extra.Header, nil + }) + + // Start the backend server on port 8082. + log.Println("Starting Backend Server on :8082") + if err := http.ListenAndServe(":8082", mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { + return server + }, nil)); err != nil { + log.Fatal(err) + } +} + +// Proxy Server: Forwards requests to the backend with headers. +func runProxyServer(ctx context.Context) { + // Connect to the backend server (acting as a client). + client := mcp.NewClient(&mcp.Implementation{Name: "proxy-client", Version: "1.0.0"}, nil) + clientSession, err := client.Connect(ctx, &mcp.StreamableClientTransport{ + Endpoint: "http://localhost:8082/mcp", + HTTPClient: NewHeaderForwardingClient(), + }, nil) + if err != nil { + log.Fatalf("Failed to connect to backend: %v", err) + } + defer clientSession.Close() + + server := mcp.NewServer(&mcp.Implementation{Name: "proxy-server", Version: "1.0.0"}, nil) + // Add a tool that forwards the call to the backend. + mcp.AddTool(server, &mcp.Tool{ + Name: "forward_headers", + Description: "Calls the backend server, ensuring headers are propagated", + }, func(ctx context.Context, req *mcp.CallToolRequest, args struct{}) (*mcp.CallToolResult, any, error) { + incomingHeaders := req.Extra.Header + log.Printf("Gateway received headers: %v", incomingHeaders) + propagateCtx := context.WithValue(ctx, headerContextKey, incomingHeaders) + result, err := clientSession.CallTool(propagateCtx, &mcp.CallToolParams{ + Name: "echo_headers", + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to call backend: %w", err) + } + + return result, nil, nil + }) + + // Start the gateway server on port 8081. + log.Println("Starting Gateway Server on :8081") + if err := http.ListenAndServe(":8081", mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { + return server + }, nil)); err != nil { + log.Fatal(err) + } +} + +func runClient(ctx context.Context) { + // Connect to the proxy server (acting as a client). + client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "1.0.0"}, nil) + clientSession, err := client.Connect(ctx, &mcp.StreamableClientTransport{ + Endpoint: "http://localhost:8081/mcp", + }, nil) + if err != nil { + log.Fatalf("Failed to connect to proxy: %v", err) + } + defer clientSession.Close() + + result, err := clientSession.CallTool(ctx, &mcp.CallToolParams{ + Name: "forward_headers", + }) + if err != nil { + log.Fatalf("Failed to call proxy: %v", err) + } + log.Printf("Client received result: %v", result.StructuredContent) +} + +func main() { + ctx := context.Background() + go runBackendServer() + // Give the backend a moment to start. + time.Sleep(100 * time.Millisecond) + go runProxyServer(ctx) + // Give the proxy a moment to start. + time.Sleep(100 * time.Millisecond) + runClient(ctx) +} From 724dd47aa3431b9d4cf9ac2eebbf7b38a629afca Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Thu, 12 Mar 2026 21:54:23 +0100 Subject: [PATCH 224/418] internal: fix Unicode zero character handling (#841) A fix was submitted to our dependency, tests added to validate the problematic scenario. We also added some fuzz tests to check for additional issues, none were found. --- go.mod | 2 +- go.sum | 4 +- internal/json/json_test.go | 111 +++++++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 9e99d46..f3f327b 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-cmp v0.7.0 github.com/google/jsonschema-go v0.4.2 - github.com/segmentio/encoding v0.5.3 + github.com/segmentio/encoding v0.5.4 github.com/yosida95/uritemplate/v3 v3.0.2 golang.org/x/oauth2 v0.35.0 golang.org/x/tools v0.42.0 diff --git a/go.sum b/go.sum index 998e07a..a60fd62 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbc github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= -github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= -github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= diff --git a/internal/json/json_test.go b/internal/json/json_test.go index feeba70..34d0226 100644 --- a/internal/json/json_test.go +++ b/internal/json/json_test.go @@ -5,6 +5,9 @@ package json import ( + "bytes" + "encoding/json" + "fmt" "testing" "github.com/google/go-cmp/cmp" @@ -61,3 +64,111 @@ func TestUnmarshalCaseSensitivity(t *testing.T) { }) } } + +func TestUnmarshalNullCharacter(t *testing.T) { + type Target struct { + Field string `json:"field"` + } + + tests := []struct { + name string + json string + want string + }{ + { + name: "null char in middle", + json: `{"fi\u0000eld": "value"}`, + }, + { + name: "null char at end", + json: `{"field\u0000": "value"}`, + }, + { + name: "null char at start", + json: `{"\u0000field": "value"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got Target + if err := Unmarshal([]byte(tt.json), &got); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if got.Field != "" { + t.Errorf("Field was set: %s", tt.json) + } + }) + } +} + +func FuzzUnmarshalKeyComparison(f *testing.F) { + // Add seed corpus for expected valid key and the known problematic cases. + f.Add("field") + f.Add("FIELD") + f.Add("field\x00") + + f.Fuzz(func(t *testing.T, key string) { + // Generate valid JSON with the fuzzed key. + // json.Encoder ensures the key is properly escaped (e.g. \x00 becomes \u0000). + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(key); err != nil { + return + } + encodedKey := buf.Bytes() + jsonData := fmt.Sprintf(`{%s: "value"}`, encodedKey) + + type Target struct { + Field string `json:"field"` + } + + var got Target + if err := Unmarshal([]byte(jsonData), &got); err != nil { + return + } + + if got.Field == "value" && key != "field" { + t.Errorf("Unmarshal matched key %q (JSON: %s) to field 'field'", key, encodedKey) + } + }) +} + +func FuzzUnmarshalFieldIsolation(f *testing.F) { + // Add seed corpus. + f.Add("other", "value") + f.Add("safe", "overwrite") + f.Add("safe\x00", "attack") + + f.Fuzz(func(t *testing.T, key, val string) { + // Generate valid JSON with the fuzzed key and value. + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(key); err != nil { + return + } + encodedKey := buf.Bytes() + buf.Reset() + if err := enc.Encode(val); err != nil { + return + } + encodedVal := buf.Bytes() + + jsonData := fmt.Sprintf(`{"safe": "value", %s: %s}`, encodedKey, encodedVal) + + type Target struct { + Safe string `json:"safe"` + } + + var got Target + if err := Unmarshal([]byte(jsonData), &got); err != nil { + return + } + + if got.Safe != "value" && key != "safe" { + t.Errorf("Field 'safe' improperly modified by key %q (JSON: %s). Got %q, want %q", key, encodedKey, got.Safe, "value") + } + }) +} From d836ceb87d0424dd64bd08061b3f88dfc31bbe59 Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Fri, 13 Mar 2026 10:06:58 +0100 Subject: [PATCH 225/418] auth: allow passing custom http.Client to AuthorizationCodeHandler (#840) --- auth/authorization_code.go | 27 +++++++++-- auth/authorization_code_test.go | 81 +++++++++++++++++++++++---------- docs/protocol.md | 28 ++++++++++-- internal/docs/protocol.src.md | 27 +++++++++-- oauthex/auth_meta.go | 30 ++++++++---- oauthex/oauth2.go | 16 +++++++ oauthex/resource_meta.go | 15 +++--- 7 files changed, 173 insertions(+), 51 deletions(-) diff --git a/auth/authorization_code.go b/auth/authorization_code.go index 9a81358..ac51ea1 100644 --- a/auth/authorization_code.go +++ b/auth/authorization_code.go @@ -101,6 +101,19 @@ type AuthorizationCodeHandlerConfig struct { // It should return the authorization code and state once the Authorization Server // redirects back to the RedirectURL. AuthorizationCodeFetcher func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) + + // Client is an optional HTTP client to use for HTTP requests. + // It is used for the following requests: + // - Fetching Protected Resource Metadata + // - Fetching Authorization Server Metadata + // - Registering a client dynamically + // - Exchanging an authorization code for an access token + // - Refreshing an access token + // Custom clients can include additional security configurations, + // such as SSRF protections, see + // https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#server-side-request-forgery-ssrf + // If not provided, http.DefaultClient will be used. + Client *http.Client } // AuthorizationCodeHandler is an implementation of [OAuthHandler] that uses @@ -166,6 +179,9 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho // it should have been set by now. Otherwise, it is required. return nil, errors.New("RedirectURL is required") } + if config.Client == nil { + config.Client = http.DefaultClient + } return &AuthorizationCodeHandler{config: config}, nil } @@ -280,7 +296,7 @@ func (h *AuthorizationCodeHandler) getProtectedResourceMetadata(ctx context.Cont // Use MCP server URL as the resource URI per // https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#canonical-server-uri. for _, url := range protectedResourceMetadataURLs(resourceMetadataURLFromChallenges(wwwChallenges), mcpServerURL) { - prm, err := oauthex.GetProtectedResourceMetadata(ctx, url.URL, url.Resource, http.DefaultClient) + prm, err := oauthex.GetProtectedResourceMetadata(ctx, url.URL, url.Resource, h.config.Client) if err != nil { errs = append(errs, err) continue @@ -359,7 +375,7 @@ func protectedResourceMetadataURLs(metadataURL, resourceURL string) []prmURL { func (h *AuthorizationCodeHandler) getAuthServerMetadata(ctx context.Context, prm *oauthex.ProtectedResourceMetadata) (*oauthex.AuthServerMeta, error) { authServerURL := prm.AuthorizationServers[0] for _, u := range authorizationServerMetadataURLs(authServerURL) { - asm, err := oauthex.GetAuthServerMeta(ctx, u, authServerURL, http.DefaultClient) + asm, err := oauthex.GetAuthServerMeta(ctx, u, authServerURL, h.config.Client) if err != nil { return nil, fmt.Errorf("failed to get authorization server metadata: %w", err) } @@ -488,7 +504,7 @@ func (h *AuthorizationCodeHandler) handleRegistration(ctx context.Context, asm * // 3. Attempt to use dynamic client registration. dcrCfg := h.config.DynamicClientRegistrationConfig if dcrCfg != nil && asm.RegistrationEndpoint != "" { - regResp, err := oauthex.RegisterClient(ctx, asm.RegistrationEndpoint, dcrCfg.Metadata, http.DefaultClient) + regResp, err := oauthex.RegisterClient(ctx, asm.RegistrationEndpoint, dcrCfg.Metadata, h.config.Client) if err != nil { return nil, fmt.Errorf("failed to register client: %w", err) } @@ -542,10 +558,11 @@ func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context oauth2.VerifierOption(authResult.usedCodeVerifier), oauth2.SetAuthURLParam("resource", resourceURL), } - token, err := cfg.Exchange(ctx, authResult.Code, opts...) + clientCtx := context.WithValue(ctx, oauth2.HTTPClient, h.config.Client) + token, err := cfg.Exchange(clientCtx, authResult.Code, opts...) if err != nil { return fmt.Errorf("token exchange failed: %w", err) } - h.tokenSource = cfg.TokenSource(ctx, token) + h.tokenSource = cfg.TokenSource(clientCtx, token) return nil } diff --git a/auth/authorization_code_test.go b/auth/authorization_code_test.go index c00e764..d371cba 100644 --- a/auth/authorization_code_test.go +++ b/auth/authorization_code_test.go @@ -125,8 +125,11 @@ func TestAuthorize_ForbiddenUnhandledError(t *testing.T) { "WWW-Authenticate", "Bearer error=invalid_token", ) - handler := &AuthorizationCodeHandler{} // No config needed for this test. - err := handler.Authorize(t.Context(), req, resp) + handler, err := NewAuthorizationCodeHandler(validConfig()) + if err != nil { + t.Fatalf("NewAuthorizationCodeHandler failed: %v", err) + } + err = handler.Authorize(t.Context(), req, resp) if err != nil { t.Fatalf("Authorize() failed: %v", err) } @@ -200,16 +203,7 @@ func TestNewAuthorizationCodeHandler_Success(t *testing.T) { } func TestNewAuthorizationCodeHandler_Error(t *testing.T) { - validConfig := func() *AuthorizationCodeHandlerConfig { - return &AuthorizationCodeHandlerConfig{ - ClientIDMetadataDocumentConfig: &ClientIDMetadataDocumentConfig{URL: "https://example.com/client"}, - RedirectURL: "https://example.com/callback", - AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { - return nil, nil - }, - } - } - // Ensure the base config is valid + // Ensure the base config is valid. if _, err := NewAuthorizationCodeHandler(validConfig()); err != nil { t.Fatalf("NewAuthorizationCodeHandler failed: %v", err) } @@ -324,7 +318,11 @@ func TestNewAuthorizationCodeHandler_Error(t *testing.T) { } func TestGetProtectedResourceMetadata_Success(t *testing.T) { - handler := &AuthorizationCodeHandler{} // No config needed for this method + handler, err := NewAuthorizationCodeHandler(validConfig()) + if err != nil { + t.Fatalf("NewAuthorizationCodeHandler() error = %v", err) + } + pathForChallenge := "/protected-resource" tests := []struct { @@ -398,8 +396,11 @@ func TestGetProtectedResourceMetadata_Success(t *testing.T) { } func TestGetProtectedResourceMetadata_Backcompat(t *testing.T) { + handler, err := NewAuthorizationCodeHandler(validConfig()) + if err != nil { + t.Fatalf("NewAuthorizationCodeHandler() error = %v", err) + } var challenges []oauthex.Challenge - handler := &AuthorizationCodeHandler{} // No config needed for this method got, err := handler.getProtectedResourceMetadata(t.Context(), challenges, "http://localhost:1234/resource") if err != nil { t.Fatalf("getProtectedResourceMetadata() error = %v", err) @@ -423,8 +424,11 @@ func TestGetProtectedResourceMetadata_Error(t *testing.T) { ScopesSupported: []string{"read", "write"}, } mux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(metadata)) + handler, err := NewAuthorizationCodeHandler(validConfig()) + if err != nil { + t.Fatalf("NewAuthorizationCodeHandler() error = %v", err) + } var challenges []oauthex.Challenge - handler := &AuthorizationCodeHandler{} // No config needed for this method got, err := handler.getProtectedResourceMetadata(t.Context(), challenges, server.URL+"/resource") if err == nil || !strings.Contains(err.Error(), "authorization servers") { t.Errorf("getProtectedResourceMetadata() = %v, want error containing \"authorization servers\"", err) @@ -435,7 +439,10 @@ func TestGetProtectedResourceMetadata_Error(t *testing.T) { } func TestGetAuthServerMetadata(t *testing.T) { - handler := &AuthorizationCodeHandler{} // No config needed for this method + handler, err := NewAuthorizationCodeHandler(validConfig()) + if err != nil { + t.Fatalf("NewAuthorizationCodeHandler() error = %v", err) + } tests := []struct { name string @@ -563,11 +570,11 @@ func TestHandleRegistration(t *testing.T) { ClientIDMetadataDocumentSupported: true, }, handlerConfig: &AuthorizationCodeHandlerConfig{ - ClientIDMetadataDocumentConfig: &ClientIDMetadataDocumentConfig{URL: "https://client.example.com"}, + ClientIDMetadataDocumentConfig: &ClientIDMetadataDocumentConfig{URL: "https://client.example.com/metadata.json"}, }, want: &resolvedClientConfig{ registrationType: registrationTypeClientIDMetadataDocument, - clientID: "https://client.example.com", + clientID: "https://client.example.com/metadata.json", }, }, { @@ -597,7 +604,7 @@ func TestHandleRegistration(t *testing.T) { { name: "NoneSupported", handlerConfig: &AuthorizationCodeHandlerConfig{ - ClientIDMetadataDocumentConfig: &ClientIDMetadataDocumentConfig{URL: "https://client.example.com"}, + ClientIDMetadataDocumentConfig: &ClientIDMetadataDocumentConfig{URL: "https://client.example.com/metadata.json"}, }, wantError: true, }, @@ -607,7 +614,14 @@ func TestHandleRegistration(t *testing.T) { t.Run(tt.name, func(t *testing.T) { s := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{RegistrationConfig: tt.serverConfig}) s.Start(t) - handler := &AuthorizationCodeHandler{config: tt.handlerConfig} + tt.handlerConfig.AuthorizationCodeFetcher = func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + return nil, nil + } + tt.handlerConfig.RedirectURL = "https://example.com/callback" + handler, err := NewAuthorizationCodeHandler(tt.handlerConfig) + if err != nil { + t.Fatalf("NewAuthorizationCodeHandler() error = %v, want nil", err) + } asm, err := handler.getAuthServerMetadata(t.Context(), &oauthex.ProtectedResourceMetadata{ AuthorizationServers: []string{s.URL()}, }) @@ -644,11 +658,20 @@ func TestDynamicRegistration(t *testing.T) { }, }) s.Start(t) - handler := &AuthorizationCodeHandler{config: &AuthorizationCodeHandlerConfig{ + handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{ DynamicClientRegistrationConfig: &DynamicClientRegistrationConfig{ - Metadata: &oauthex.ClientRegistrationMetadata{}, + Metadata: &oauthex.ClientRegistrationMetadata{ + RedirectURIs: []string{"https://example.com/callback"}, + }, }, - }} + RedirectURL: "https://example.com/callback", + AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + return nil, nil + }, + }) + if err != nil { + t.Fatalf("NewAuthorizationCodeHandler() error = %v", err) + } asm, err := handler.getAuthServerMetadata(t.Context(), &oauthex.ProtectedResourceMetadata{ AuthorizationServers: []string{s.URL()}, }) @@ -672,3 +695,15 @@ func TestDynamicRegistration(t *testing.T) { t.Errorf("handleRegistration() authStyle = %v, want %v", got.authStyle, oauth2.AuthStyleInHeader) } } + +// validConfig for test to create an AuthorizationCodeHandler using its constructor. +// Values that are relevant to the test should be set explicitly. +func validConfig() *AuthorizationCodeHandlerConfig { + return &AuthorizationCodeHandlerConfig{ + ClientIDMetadataDocumentConfig: &ClientIDMetadataDocumentConfig{URL: "https://example.com/client"}, + RedirectURL: "https://example.com/callback", + AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + return nil, nil + }, + } +} diff --git a/docs/protocol.md b/docs/protocol.md index c4f1cef..aeff5e9 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -13,6 +13,7 @@ 1. [Security](#security) 1. [Confused Deputy](#confused-deputy) 1. [Token Passthrough](#token-passthrough) + 1. [Server-Side Request Forgery (SSRF)](#server-side-request-forgery-(ssrf)) 1. [Session Hijacking](#session-hijacking) 1. [Utilities](#utilities) 1. [Cancellation](#cancellation) @@ -363,7 +364,7 @@ the MCP spec's [Security Best Practices](https://modelcontextprotocol.io/specifi ### Confused Deputy -The [mitigation](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#mitigation), +The [mitigation](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation), obtaining user consent for dynamically registered clients, is mostly the responsibility of the MCP Proxy server implementation. The SDK client does generate cryptographically secure random `state` values for each authorization @@ -372,15 +373,36 @@ Mismatched state values will result in an error. ### Token Passthrough -The [mitigation](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#mitigation-2), accepting only tokens that were issued for the server, depends on the structure +The [mitigation](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-2), accepting only tokens that were issued for the server, depends on the structure of tokens and is the responsibility of the [`TokenVerifier`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenVerifier) provided to [`RequireBearerToken`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#RequireBearerToken). +### Server-Side Request Forgery (SSRF) + +The [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) are as follows: + +- _Enforce HTTPS_. The OAuth helpers provided by the SDK reject the `http://` URLs +except loopback addresses (`localhost`, `127.0.0.1`, `::1`). + +- _Block Private IP Ranges_. The OAuth helpers provided by the SDK allow passing +a custom `http.Client`. Developers are advised to customize the client it with +appropriate network protections, including IP range blocking. The SDK does not provide +this capability out of the box. + +- _Validate Redirect Targets_. Similarly to previous point, customized `http.Client` +can be used to validate network hops. The SDK does not provide this capability out +of the box. + +- _Use Egress Proxies_. This is out of scope for the SDK and can be configured separately. + +- _DNS Resolution Considerations_. The SDK has DNS rebinding protection on the server side which is enabled by default. For the client side, consider providing +a custom `http.Client` that would implement DNS pinning. + ### Session Hijacking -The [mitigations](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#mitigation-3) are as follows: +The [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-4) are as follows: - _Verify all inbound requests_. The [`RequireBearerToken`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#RequireBearerToken) middleware function will verify all HTTP requests that it receives. It is the diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index 3771b58..5134a18 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -289,7 +289,7 @@ the MCP spec's [Security Best Practices](https://modelcontextprotocol.io/specifi ### Confused Deputy -The [mitigation](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#mitigation), +The [mitigation](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation), obtaining user consent for dynamically registered clients, is mostly the responsibility of the MCP Proxy server implementation. The SDK client does generate cryptographically secure random `state` values for each authorization @@ -298,15 +298,36 @@ Mismatched state values will result in an error. ### Token Passthrough -The [mitigation](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#mitigation-2), accepting only tokens that were issued for the server, depends on the structure +The [mitigation](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-2), accepting only tokens that were issued for the server, depends on the structure of tokens and is the responsibility of the [`TokenVerifier`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenVerifier) provided to [`RequireBearerToken`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#RequireBearerToken). +### Server-Side Request Forgery (SSRF) + +The [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) are as follows: + +- _Enforce HTTPS_. The OAuth helpers provided by the SDK reject the `http://` URLs +except loopback addresses (`localhost`, `127.0.0.1`, `::1`). + +- _Block Private IP Ranges_. The OAuth helpers provided by the SDK allow passing +a custom `http.Client`. Developers are advised to customize the client it with +appropriate network protections, including IP range blocking. The SDK does not provide +this capability out of the box. + +- _Validate Redirect Targets_. Similarly to previous point, customized `http.Client` +can be used to validate network hops. The SDK does not provide this capability out +of the box. + +- _Use Egress Proxies_. This is out of scope for the SDK and can be configured separately. + +- _DNS Resolution Considerations_. The SDK has DNS rebinding protection on the server side which is enabled by default. For the client side, consider providing +a custom `http.Client` that would implement DNS pinning. + ### Session Hijacking -The [mitigations](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#mitigation-3) are as follows: +The [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-4) are as follows: - _Verify all inbound requests_. The [`RequireBearerToken`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#RequireBearerToken) middleware function will verify all HTTP requests that it receives. It is the diff --git a/oauthex/auth_meta.go b/oauthex/auth_meta.go index b05d80b..3621057 100644 --- a/oauthex/auth_meta.go +++ b/oauthex/auth_meta.go @@ -14,9 +14,6 @@ import ( "errors" "fmt" "net/http" - "net/url" - - "github.com/modelcontextprotocol/go-sdk/internal/util" ) // AuthServerMeta represents the metadata for an OAuth 2.0 authorization server, @@ -136,13 +133,9 @@ type AuthServerMeta struct { // // [RFC 8414]: https://tools.ietf.org/html/rfc8414 func GetAuthServerMeta(ctx context.Context, metadataURL, issuer string, c *http.Client) (*AuthServerMeta, error) { - u, err := url.Parse(metadataURL) - if err != nil { - return nil, err - } // Only allow HTTP for local addresses (testing or development purposes). - if !util.IsLoopback(u.Host) && u.Scheme != "https" { - return nil, fmt.Errorf("metadataURL %q does not use HTTPS", metadataURL) + if err := checkHTTPSOrLoopback(metadataURL); err != nil { + return nil, fmt.Errorf("metadataURL: %v", err) } asm, err := getJSON[AuthServerMeta](ctx, c, metadataURL, 1<<20) if err != nil { @@ -173,6 +166,8 @@ func GetAuthServerMeta(ctx context.Context, metadataURL, issuer string, c *http. // validateAuthServerMetaURLs validates all URL fields in AuthServerMeta // to ensure they don't use dangerous schemes that could enable XSS attacks. +// It also validates that URLs likely to be called by the client use +// HTTPS or are loopback addresses. func validateAuthServerMetaURLs(asm *AuthServerMeta) error { urls := []struct { name string @@ -194,5 +189,22 @@ func validateAuthServerMetaURLs(asm *AuthServerMeta) error { return fmt.Errorf("%s: %w", u.name, err) } } + + urls = []struct { + name string + value string + }{ + {"authorization_endpoint", asm.AuthorizationEndpoint}, + {"token_endpoint", asm.TokenEndpoint}, + {"registration_endpoint", asm.RegistrationEndpoint}, + {"introspection_endpoint", asm.IntrospectionEndpoint}, + } + + for _, u := range urls { + if err := checkHTTPSOrLoopback(u.value); err != nil { + return fmt.Errorf("%s: %w", u.name, err) + } + } + return nil } diff --git a/oauthex/oauth2.go b/oauthex/oauth2.go index 836a420..d8aeb3c 100644 --- a/oauthex/oauth2.go +++ b/oauthex/oauth2.go @@ -17,6 +17,8 @@ import ( "net/http" "net/url" "strings" + + "github.com/modelcontextprotocol/go-sdk/internal/util" ) type httpStatusError struct { @@ -78,3 +80,17 @@ func checkURLScheme(u string) error { } return nil } + +func checkHTTPSOrLoopback(addr string) error { + if addr == "" { + return nil + } + u, err := url.Parse(addr) + if err != nil { + return err + } + if !util.IsLoopback(u.Host) && u.Scheme != "https" { + return fmt.Errorf("URL %q does not use HTTPS or is not a loopback address", addr) + } + return nil +} diff --git a/oauthex/resource_meta.go b/oauthex/resource_meta.go index 8b911ca..4680c15 100644 --- a/oauthex/resource_meta.go +++ b/oauthex/resource_meta.go @@ -98,13 +98,9 @@ func resourceMetadataURL(cs []Challenge) string { // - The authorization_servers field of the resulting metadata is checked for dangerous URL schemes. func GetProtectedResourceMetadata(ctx context.Context, metadataURL, resourceURL string, c *http.Client) (_ *ProtectedResourceMetadata, err error) { defer util.Wrapf(&err, "GetProtectedResourceMetadata(%q)", metadataURL) - u, err := url.Parse(metadataURL) - if err != nil { - return nil, err - } // Only allow HTTP for local addresses (testing or development purposes). - if !util.IsLoopback(u.Host) && u.Scheme != "https" { - return nil, fmt.Errorf("metadataURL %q does not use HTTPS", metadataURL) + if err := checkHTTPSOrLoopback(metadataURL); err != nil { + return nil, fmt.Errorf("metadataURL: %v", err) } prm, err := getJSON[ProtectedResourceMetadata](ctx, c, metadataURL, 1<<20) if err != nil { @@ -115,9 +111,12 @@ func GetProtectedResourceMetadata(ctx context.Context, metadataURL, resourceURL return nil, fmt.Errorf("got metadata resource %q, want %q", prm.Resource, resourceURL) } // Validate the authorization server URLs to prevent XSS attacks (see #526). - for _, u := range prm.AuthorizationServers { + for i, u := range prm.AuthorizationServers { if err := checkURLScheme(u); err != nil { - return nil, err + return nil, fmt.Errorf("authorization_servers[%d]: %v", i, err) + } + if err := checkHTTPSOrLoopback(u); err != nil { + return nil, fmt.Errorf("authorization_servers[%d]: %v", i, err) } } return prm, nil From a433a831d6e5d5ac3b9e625a8095aa8eaa040dfc Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Fri, 13 Mar 2026 11:36:41 +0100 Subject: [PATCH 226/418] mcp: verify 'Origin' and 'Content-Type' headers (#842) This PR introduces protection against cross-origin requests by installing `http.CrossOriginProtection`. By default, the zero value is used which does not contain any trusted origins or bypass patterns. The protection can be customized by providing a custom `http.CrossOriginProtection` in `mcp.StreamableHTTPOptions`. Currently, the deny handler set on the protection is ignored. This default has an MCPGODEBUG option (`disableoriginverification`) to disable the default protection. It will be available until `v1.6.0` of the SDK. We also increase the Go version required by the SDK to 1.25, since this is the version that introduced `http.CrossOriginProtection`. This is in line with the SDK's policy of supporting two newest Go versions (1.26 was released in February 2026). Additionally, we start validating if the `Content-Type` header for `POST` requests is set to `application/json` to avoid accepting CORS-safelisted requests. --- .github/workflows/codeql.yml | 3 + .github/workflows/conformance.yml | 4 +- .github/workflows/docs-check.yml | 2 + .github/workflows/nightly.yml | 2 +- .github/workflows/test.yml | 6 +- docs/README.md | 5 ++ docs/mcpgodebug.md | 41 +++++++++++ docs/protocol.md | 2 +- go.mod | 2 +- internal/docs/README.src.md | 5 ++ internal/docs/doc.go | 1 + internal/docs/mcpgodebug.src.md | 40 +++++++++++ internal/docs/protocol.src.md | 2 +- mcp/streamable.go | 37 +++++++++- mcp/streamable_test.go | 111 +++++++++++++++++++++++++++++- 15 files changed, 252 insertions(+), 11 deletions(-) create mode 100644 docs/mcpgodebug.md create mode 100644 internal/docs/mcpgodebug.src.md diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 30b4c57..8d8dfb3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -8,6 +8,9 @@ on: schedule: - cron: '31 9 * * 4' +# Declare default permissions as read only. +permissions: read-all + jobs: analyze: name: Analyze (${{ matrix.language }}) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 17bdf7e..c33a0f9 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -22,7 +22,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: - go-version: "^1.25" + go-version: "^1.26" - name: Start everything-server run: | go run ./conformance/everything-server/main.go -http=":3001" & @@ -45,7 +45,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: - go-version: "^1.25" + go-version: "^1.26" - name: "Run conformance tests" uses: modelcontextprotocol/conformance@a2855b03582a6c0b31065ad4d9af248316ce61a3 # v0.1.15 with: diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index 9b9e93f..ee6f596 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -17,6 +17,8 @@ jobs: steps: - name: Set up Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version: "^1.26" - name: Check out code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Check docs are up-to-date diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index e10d2aa..098a9a9 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -27,7 +27,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: - go-version: "^1.25" + go-version: "^1.26" - name: Set up Node.js uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0344d35..a6824b8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,7 +40,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - go: ["1.24", "1.25"] + go: ["1.25", "1.26"] steps: - name: Check out code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -52,6 +52,8 @@ jobs: run: go test -v ./... race-test: + # Temporarily disable until fixes are prepared. + if: false runs-on: ubuntu-latest steps: - name: Check out code @@ -59,6 +61,6 @@ jobs: - name: Set up Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: - go-version: "1.24" + go-version: "1.25" - name: Test with -race run: go test -v -race ./... diff --git a/docs/README.md b/docs/README.md index 0e8259c..e155e49 100644 --- a/docs/README.md +++ b/docs/README.md @@ -39,6 +39,11 @@ protocol. See [troubleshooting.md](troubleshooting.md) for a troubleshooting guide. +# Backwards compatibility + +See [mcpgodebug.md](mcpgodebug.md) for a list of backwards incompatible behavior changes +and description how they can be temporarily undone. + # Rough edges See [rough_edges.md](rough_edges.md) for a list of rough edges or API diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md new file mode 100644 index 0000000..f53ef6d --- /dev/null +++ b/docs/mcpgodebug.md @@ -0,0 +1,41 @@ + + # Backwards compatibility and MCPGODEBUG + + According to our compatibility promise, we can't break backward compatibility + of the SDK API. However, sometimes we need to change the behavior of the SDK + in a backward-incompatible way in order to fix bugs or security issues. + In those cases we introduce temporary compatibility parameters, that can be + used to opt-out of the new behavior. They are usually maintained for two + minor release cycles and then removed. + + The compatibility parameters are provided via the `MCPGODEBUG` environment + variable. The value of the variable is a comma-separated list of parameter + value assignments, e.g.: + + ``` + MCPGODEBUG=parameter1=value1,parameter2=value2 + ``` + +## `MCPGODEBUG` history + +### 1.4.1 + +Options listed below will be removed in the 1.6.0 version of the SDK. + +- `disablecrossoriginprotection` added. If set to `1`, newly added cross-origin + protection will be disabled. The default behavior was changed to enable + cross-origin protection. + +### 1.4.0 + +Options listed below will be removed in the 1.6.0 version of the SDK. + +- `jsonescaping` added. If set to `1`, JSON marshaling will preserve the previous + behavior of escaping HTML characters in JSON strings. The default behavior + was changed to not escape HTML characters, to be consistent with other SDKs. + +- `disablelocalhostprotection` added. If set to `1`, newly added DNS rebinding + protection will be disabled. The default behavior was changed to enable DNS rebinding + protection. The protection can also be disabled by setting the + `DisableLocalhostProtection` field in the `StreamableHTTPOptions` struct to + `true`, which is the recommended way to disable the protection long term. diff --git a/docs/protocol.md b/docs/protocol.md index aeff5e9..f316e3f 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -360,7 +360,7 @@ and step-up authentication (when the server returns `insufficient_scope` error). ## Security Here we discuss the mitigations described under -the MCP spec's [Security Best Practices](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices) section, and how we handle them. +the MCP's [Security Best Practices](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices) section, and how we handle them. ### Confused Deputy diff --git a/go.mod b/go.mod index f3f327b..a6aae2b 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/modelcontextprotocol/go-sdk -go 1.24.0 +go 1.25.0 require ( github.com/golang-jwt/jwt/v5 v5.3.1 diff --git a/internal/docs/README.src.md b/internal/docs/README.src.md index e70596b..16d93db 100644 --- a/internal/docs/README.src.md +++ b/internal/docs/README.src.md @@ -38,6 +38,11 @@ protocol. See [troubleshooting.md](troubleshooting.md) for a troubleshooting guide. +# Backwards compatibility + +See [mcpgodebug.md](mcpgodebug.md) for a list of backwards incompatible behavior changes +and description how they can be temporarily undone. + # Rough edges See [rough_edges.md](rough_edges.md) for a list of rough edges or API diff --git a/internal/docs/doc.go b/internal/docs/doc.go index 3d87686..ede0d42 100644 --- a/internal/docs/doc.go +++ b/internal/docs/doc.go @@ -9,6 +9,7 @@ //go:generate weave -o ../../docs/server.md ./server.src.md //go:generate weave -o ../../docs/troubleshooting.md ./troubleshooting.src.md //go:generate weave -o ../../docs/rough_edges.md ./rough_edges.src.md +//go:generate weave -o ../../docs/mcpgodebug.md ./mcpgodebug.src.md // The doc package generates the documentation at /doc, via go:generate. // diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md new file mode 100644 index 0000000..60b321b --- /dev/null +++ b/internal/docs/mcpgodebug.src.md @@ -0,0 +1,40 @@ + # Backwards compatibility and MCPGODEBUG + + According to our compatibility promise, we can't break backward compatibility + of the SDK API. However, sometimes we need to change the behavior of the SDK + in a backward-incompatible way in order to fix bugs or security issues. + In those cases we introduce temporary compatibility parameters, that can be + used to opt-out of the new behavior. They are usually maintained for two + minor release cycles and then removed. + + The compatibility parameters are provided via the `MCPGODEBUG` environment + variable. The value of the variable is a comma-separated list of parameter + value assignments, e.g.: + + ``` + MCPGODEBUG=parameter1=value1,parameter2=value2 + ``` + +## `MCPGODEBUG` history + +### 1.4.1 + +Options listed below will be removed in the 1.6.0 version of the SDK. + +- `disablecrossoriginprotection` added. If set to `1`, newly added cross-origin + protection will be disabled. The default behavior was changed to enable + cross-origin protection. + +### 1.4.0 + +Options listed below will be removed in the 1.6.0 version of the SDK. + +- `jsonescaping` added. If set to `1`, JSON marshaling will preserve the previous + behavior of escaping HTML characters in JSON strings. The default behavior + was changed to not escape HTML characters, to be consistent with other SDKs. + +- `disablelocalhostprotection` added. If set to `1`, newly added DNS rebinding + protection will be disabled. The default behavior was changed to enable DNS rebinding + protection. The protection can also be disabled by setting the + `DisableLocalhostProtection` field in the `StreamableHTTPOptions` struct to + `true`, which is the recommended way to disable the protection long term. \ No newline at end of file diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index 5134a18..9807861 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -285,7 +285,7 @@ and step-up authentication (when the server returns `insufficient_scope` error). ## Security Here we discuss the mitigations described under -the MCP spec's [Security Best Practices](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices) section, and how we handle them. +the MCP's [Security Best Practices](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices) section, and how we handle them. ### Confused Deputy diff --git a/mcp/streamable.go b/mcp/streamable.go index 0b11eff..16bca07 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -174,6 +174,14 @@ type StreamableHTTPOptions struct { // Only disable this if you understand the security implications. // See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise DisableLocalhostProtection bool + + // CrossOriginProtection allows to customize cross-origin protection. + // The deny handler set in the CrossOriginProtection through SetDenyHandler + // is ignored. + // If nil, default (zero-value) cross-origin protection will be used. + // Use `disablecrossoriginprotection` MCPGODEBUG compatibility parameter + // to disable the default protection until v1.6.0. + CrossOriginProtection *http.CrossOriginProtection } // NewStreamableHTTPHandler returns a new [StreamableHTTPHandler]. @@ -190,8 +198,10 @@ func NewStreamableHTTPHandler(getServer func(*http.Request) *Server, opts *Strea h.opts = *opts } - if h.opts.Logger == nil { // ensure we have a logger - h.opts.Logger = ensureLogger(nil) + h.opts.Logger = ensureLogger(h.opts.Logger) + + if h.opts.CrossOriginProtection == nil { + h.opts.CrossOriginProtection = &http.CrossOriginProtection{} } return h @@ -226,6 +236,13 @@ func (h *StreamableHTTPHandler) closeAll() { // The option will be removed in the 1.6.0 version of the SDK. var disablelocalhostprotection = mcpgodebug.Value("disablelocalhostprotection") +// disablecrossoriginprotection is a compatibility parameter that allows to disable +// the verification of the 'Origin' and 'Content-Type' headers, which was added in +// the 1.4.1 version of the SDK. See the documentation for the mcpgodebug package +// for instructions how to enable it. +// The option will be removed in the 1.6.0 version of the SDK. +var disablecrossoriginprotection = mcpgodebug.Value("disablecrossoriginprotection") + func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { // DNS rebinding protection: auto-enabled for localhost servers. // See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise @@ -238,6 +255,22 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque } } + if disablecrossoriginprotection != "1" { + // Verify the 'Origin' header to protect against CSRF attacks. + if err := h.opts.CrossOriginProtection.Check(req); err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + // Validate 'Content-Type' header. + if req.Method == http.MethodPost { + contentType := req.Header.Get("Content-Type") + if contentType != "application/json" { + http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) + return + } + } + } + // Allow multiple 'Accept' headers. // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept#syntax accept := strings.Split(strings.Join(req.Header.Values("Accept"), ","), ",") diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 7a504a8..f688a90 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -832,6 +832,36 @@ func TestStreamableServerTransport(t *testing.T) { }, wantSessions: 0, }, + { + name: "content type headers", + requests: []streamableRequest{ + initialize, + initialized, + { + // Request with incorrect Content-Type should be rejected. + method: "POST", + headers: http.Header{"Content-Type": {"text/plain"}}, + messages: []jsonrpc.Message{req(3, "tools/call", &CallToolParams{Name: "tool"})}, + wantStatusCode: http.StatusUnsupportedMediaType, + }, + { + // Request with empty Content-Type should be rejected. + method: "POST", + headers: http.Header{"Content-Type": {""}}, + messages: []jsonrpc.Message{req(4, "tools/call", &CallToolParams{Name: "tool"})}, + wantStatusCode: http.StatusUnsupportedMediaType, + }, + { + // Correct Content-Type should pass. + method: "POST", + headers: http.Header{"Content-Type": {"application/json"}}, + messages: []jsonrpc.Message{req(5, "tools/call", &CallToolParams{Name: "tool"})}, + wantStatusCode: http.StatusOK, + wantMessages: []jsonrpc.Message{resp(5, &CallToolResult{Content: []Content{}}, nil)}, + }, + }, + wantSessions: 1, + }, { name: "accept headers", requests: []streamableRequest{ @@ -1409,10 +1439,16 @@ func (s streamableRequest) do(ctx context.Context, serverURL, sessionID string, if sessionID != "" { req.Header.Set(sessionIDHeader, sessionID) } - req.Header.Set("Content-Type", "application/json") + if s.method == http.MethodPost { + req.Header.Set("Content-Type", "application/json") + } req.Header.Set("Accept", "application/json, text/event-stream") maps.Copy(req.Header, s.headers) + if req.Header.Get("Content-Type") == "" { + req.Header.Del("Content-Type") + } + resp, err := http.DefaultClient.Do(req) if err != nil { return "", 0, nil, fmt.Errorf("request failed: %v", err) @@ -2436,3 +2472,76 @@ func TestStreamableLocalhostProtection(t *testing.T) { }) } } + +func TestStreamableOriginProtection(t *testing.T) { + server := NewServer(testImpl, nil) + + tests := []struct { + name string + protection *http.CrossOriginProtection + requestOrigin string + wantStatusCode int + }{ + { + name: "default protection with Origin header", + protection: nil, + requestOrigin: "https://example.com", + wantStatusCode: http.StatusForbidden, + }, + { + name: "custom protection with trusted origin and same Origin", + protection: func() *http.CrossOriginProtection { + p := http.NewCrossOriginProtection() + if err := p.AddTrustedOrigin("https://example.com"); err != nil { + t.Fatal(err) + } + return p + }(), + requestOrigin: "https://example.com", + wantStatusCode: http.StatusOK, + }, + { + name: "custom protection with trusted origin and different Origin", + protection: func() *http.CrossOriginProtection { + p := http.NewCrossOriginProtection() + if err := p.AddTrustedOrigin("https://example.com"); err != nil { + t.Fatal(err) + } + return p + }(), + requestOrigin: "https://malicious.com", + wantStatusCode: http.StatusForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &StreamableHTTPOptions{ + Stateless: true, // avoid session ID requirement + CrossOriginProtection: tt.protection, + } + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, opts) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + reqReader := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`) + req, err := http.NewRequest(http.MethodPost, httpServer.URL, reqReader) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", tt.requestOrigin) + req.Header.Set("Accept", "application/json, text/event-stream") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if got := resp.StatusCode; got != tt.wantStatusCode { + t.Errorf("Status code: got %d, want %d", got, tt.wantStatusCode) + } + }) + } +} From 6af642cc79991f2d084fac8012c81091ef36b141 Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Tue, 17 Mar 2026 16:34:10 +0100 Subject: [PATCH 227/418] auth: return scope in WWW-Authenticate header. (#834) Also, emit the resource metadata URL in quotes to ensure proper parsing. https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements --- auth/auth.go | 14 ++++++- auth/auth_test.go | 96 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/auth/auth.go b/auth/auth.go index 36ff259..40fa259 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -8,6 +8,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "slices" "strings" @@ -73,8 +74,17 @@ func RequireBearerToken(verifier TokenVerifier, opts *RequireBearerTokenOptions) tokenInfo, errmsg, code := verify(r, verifier, opts) if code != 0 { if code == http.StatusUnauthorized || code == http.StatusForbidden { - if opts != nil && opts.ResourceMetadataURL != "" { - w.Header().Add("WWW-Authenticate", "Bearer resource_metadata="+opts.ResourceMetadataURL) + if opts != nil { + var params []string + if opts.ResourceMetadataURL != "" { + params = append(params, fmt.Sprintf("resource_metadata=%q", opts.ResourceMetadataURL)) + } + if len(opts.Scopes) > 0 { + params = append(params, fmt.Sprintf("scope=%q", strings.Join(opts.Scopes, " "))) + } + if len(params) > 0 { + w.Header().Add("WWW-Authenticate", "Bearer "+strings.Join(params, ", ")) + } } } http.Error(w, errmsg, code) diff --git a/auth/auth_test.go b/auth/auth_test.go index a943404..4028c90 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -188,3 +188,99 @@ func TestProtectedResourceMetadataHandler(t *testing.T) { }) } } + +func TestRequireBearerToken(t *testing.T) { + verifier := func(_ context.Context, token string, _ *http.Request) (*TokenInfo, error) { + if token == "valid" { + return &TokenInfo{Expiration: time.Now().Add(time.Hour), Scopes: []string{"read"}}, nil + } + return nil, ErrInvalidToken + } + + tests := []struct { + name string + opts *RequireBearerTokenOptions + authHeader string + wantHeader string + wantStatus int + }{ + { + name: "no middleware options", + opts: nil, + authHeader: "Bearer invalid", + wantHeader: "", + wantStatus: http.StatusUnauthorized, + }, + { + name: "metadata only", + opts: &RequireBearerTokenOptions{ + ResourceMetadataURL: "https://example.com/resource-metadata", + }, + authHeader: "Bearer invalid", + wantHeader: "Bearer resource_metadata=\"https://example.com/resource-metadata\"", + wantStatus: http.StatusUnauthorized, + }, + { + name: "scopes only", + opts: &RequireBearerTokenOptions{ + Scopes: []string{"read", "write"}, + }, + authHeader: "Bearer invalid", + wantHeader: "Bearer scope=\"read write\"", + wantStatus: http.StatusUnauthorized, + }, + { + name: "metadata and scopes", + opts: &RequireBearerTokenOptions{ + ResourceMetadataURL: "https://example.com/resource-metadata", + Scopes: []string{"read", "write"}, + }, + authHeader: "Bearer invalid", + wantHeader: "Bearer resource_metadata=\"https://example.com/resource-metadata\", scope=\"read write\"", + wantStatus: http.StatusUnauthorized, + }, + { + name: "insufficient scope", + opts: &RequireBearerTokenOptions{ + Scopes: []string{"admin"}, + }, + authHeader: "Bearer valid", // Has "read", needs "admin" -> 403 + wantHeader: "Bearer scope=\"admin\"", + wantStatus: http.StatusForbidden, + }, + { + name: "success", + opts: &RequireBearerTokenOptions{ + Scopes: []string{"read"}, + }, + authHeader: "Bearer valid", + wantHeader: "", + wantStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := RequireBearerToken(verifier, tt.opts)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("GET", "/", nil) + if tt.authHeader != "" { + req.Header.Set("Authorization", tt.authHeader) + } + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus) + } + + got := rec.Header().Get("WWW-Authenticate") + if got != tt.wantHeader { + t.Errorf("WWW-Authenticate = %q, want %q", got, tt.wantHeader) + } + }) + } +} From 48879f01c9ca2195ce64b062ab95bc5770f6b9ec Mon Sep 17 00:00:00 2001 From: Steven Chen Date: Wed, 18 Mar 2026 09:57:45 +0000 Subject: [PATCH 228/418] mcp: fix setProgressToken when Meta is nil (#846) When `SetProgressToken` is called on a `Params` struct with a nil `Meta` field, the newly created map is never assigned back to the struct via `SetMeta`, causing the progress token to be silently lost. Call `p.SetMeta(m)` after creating the new map so the struct retains the reference. Fixes #845 Co-authored-by: Maciej Kisiel --- mcp/protocol_test.go | 10 ++++++++++ mcp/shared.go | 1 + 2 files changed, 11 insertions(+) diff --git a/mcp/protocol_test.go b/mcp/protocol_test.go index 20f2bb8..751d081 100644 --- a/mcp/protocol_test.go +++ b/mcp/protocol_test.go @@ -64,6 +64,16 @@ func TestParamsMeta(t *testing.T) { t.Errorf("got %v, want `t`", g) } + // The GetProgressToken and SetProgressToken methods work on a params struct that doesn't have a Meta field. + if g := p2.GetProgressToken(); g != nil { + t.Errorf("got %v, want nil", g) + } + + p2.SetProgressToken("t") + if g := p2.GetProgressToken(); g != "t" { + t.Errorf("got %v, want `t`", g) + } + // You can set a progress token to an int, int32 or int64. p.SetProgressToken(int(1)) p.SetProgressToken(int32(1)) diff --git a/mcp/shared.go b/mcp/shared.go index f8c9250..5662b12 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -447,6 +447,7 @@ func setProgressToken(p Params, pt any) { m := p.GetMeta() if m == nil { m = map[string]any{} + p.SetMeta(m) } m[progressTokenKey] = pt } From ea161959d7c87bd3d4bbd63cfa8df01d3ab3b304 Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Fri, 20 Mar 2026 12:28:56 +0100 Subject: [PATCH 229/418] all: clean up Go 1.24 specific code. (#850) --- docs/protocol.md | 3 +- internal/docs/protocol.src.md | 3 +- ...ies_go125_test.go => capabilities_test.go} | 2 - mcp/cmd_go125_test.go | 56 - mcp/cmd_test.go | 58 +- mcp/conformance_go124_test.go | 16 - mcp/conformance_go125_test.go | 16 - mcp/conformance_test.go | 11 +- mcp/elicitation_go125_test.go | 69 - mcp/elicitation_test.go | 92 +- mcp/mcp_go125_test.go | 756 --------- mcp/mcp_test.go | 1346 ++++++++--------- mcp/server.go | 2 +- mcp/transport_example_test.go | 3 - 14 files changed, 744 insertions(+), 1689 deletions(-) rename mcp/{capabilities_go125_test.go => capabilities_test.go} (99%) delete mode 100644 mcp/cmd_go125_test.go delete mode 100644 mcp/conformance_go124_test.go delete mode 100644 mcp/conformance_go125_test.go delete mode 100644 mcp/elicitation_go125_test.go delete mode 100644 mcp/mcp_go125_test.go diff --git a/docs/protocol.md b/docs/protocol.md index f316e3f..af92f93 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -411,8 +411,7 @@ user's responsibility to wrap that function around all handlers in their server. - _Secure session IDs_. This SDK generates cryptographically secure session IDs by default. If you create your own with [`ServerOptions.GetSessionID`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.GetSessionID), it is your responsibility to ensure they are secure. -If you are using Go 1.24 or above, -we recommend using [`crypto/rand.Text`](https://pkg.go.dev/crypto/rand#Text) +We recommend using [`crypto/rand.Text`](https://pkg.go.dev/crypto/rand#Text). - _Binding session IDs to user information_. The SDK supports this mitigation through [`TokenInfo.UserID`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenInfo.UserID). diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index 9807861..5758e03 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -336,8 +336,7 @@ user's responsibility to wrap that function around all handlers in their server. - _Secure session IDs_. This SDK generates cryptographically secure session IDs by default. If you create your own with [`ServerOptions.GetSessionID`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.GetSessionID), it is your responsibility to ensure they are secure. -If you are using Go 1.24 or above, -we recommend using [`crypto/rand.Text`](https://pkg.go.dev/crypto/rand#Text) +We recommend using [`crypto/rand.Text`](https://pkg.go.dev/crypto/rand#Text). - _Binding session IDs to user information_. The SDK supports this mitigation through [`TokenInfo.UserID`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#TokenInfo.UserID). diff --git a/mcp/capabilities_go125_test.go b/mcp/capabilities_test.go similarity index 99% rename from mcp/capabilities_go125_test.go rename to mcp/capabilities_test.go index 4f959df..01ff45f 100644 --- a/mcp/capabilities_go125_test.go +++ b/mcp/capabilities_test.go @@ -2,8 +2,6 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. -//go:build go1.25 - package mcp import ( diff --git a/mcp/cmd_go125_test.go b/mcp/cmd_go125_test.go deleted file mode 100644 index ea36e2b..0000000 --- a/mcp/cmd_go125_test.go +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright 2025 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -//go:build go1.25 - -package mcp_test - -import ( - "context" - "errors" - "testing" - "testing/synctest" - - "github.com/modelcontextprotocol/go-sdk/mcp" -) - -func TestServerRunContextCancel_Synctest(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - server := mcp.NewServer(&mcp.Implementation{Name: "greeter", Version: "v0.0.1"}, nil) - mcp.AddTool(server, &mcp.Tool{Name: "greet", Description: "say hi"}, SayHi) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - serverTransport, clientTransport := mcp.NewInMemoryTransports() - - // run the server and capture the exit error - onServerExit := make(chan error) - go func() { - onServerExit <- server.Run(ctx, serverTransport) - }() - - // send a ping to the server to ensure it's running - client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) - session, err := client.Connect(ctx, clientTransport, nil) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { session.Close() }) - - if err := session.Ping(context.Background(), nil); err != nil { - t.Fatal(err) - } - - // cancel the context to stop the server - cancel() - - // wait for the server to exit - - err = <-onServerExit - if !errors.Is(err, context.Canceled) { - t.Fatalf("server did not exit after context cancellation, got error: %v", err) - } - }) -} diff --git a/mcp/cmd_test.go b/mcp/cmd_test.go index 094814f..1bbff30 100644 --- a/mcp/cmd_test.go +++ b/mcp/cmd_test.go @@ -14,6 +14,7 @@ import ( "runtime" "syscall" "testing" + "testing/synctest" "time" "github.com/google/go-cmp/cmp" @@ -76,47 +77,44 @@ func runCancelContextServer() { } } -// TODO: remove this test when Go 1.24 support is dropped (use go1.25 synctest version). func TestServerRunContextCancel(t *testing.T) { - server := mcp.NewServer(&mcp.Implementation{Name: "greeter", Version: "v0.0.1"}, nil) - mcp.AddTool(server, &mcp.Tool{Name: "greet", Description: "say hi"}, SayHi) + synctest.Test(t, func(t *testing.T) { + server := mcp.NewServer(&mcp.Implementation{Name: "greeter", Version: "v0.0.1"}, nil) + mcp.AddTool(server, &mcp.Tool{Name: "greet", Description: "say hi"}, SayHi) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() - serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverTransport, clientTransport := mcp.NewInMemoryTransports() - // run the server and capture the exit error - onServerExit := make(chan error) - go func() { - onServerExit <- server.Run(ctx, serverTransport) - }() + // run the server and capture the exit error + onServerExit := make(chan error) + go func() { + onServerExit <- server.Run(ctx, serverTransport) + }() - // send a ping to the server to ensure it's running - client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) - session, err := client.Connect(ctx, clientTransport, nil) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { session.Close() }) + // send a ping to the server to ensure it's running + client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + session, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { session.Close() }) - if err := session.Ping(context.Background(), nil); err != nil { - t.Fatal(err) - } + if err := session.Ping(context.Background(), nil); err != nil { + t.Fatal(err) + } - // cancel the context to stop the server - cancel() + // cancel the context to stop the server + cancel() - // wait for the server to exit - // TODO: use synctest when available - select { - case <-time.After(5 * time.Second): - t.Fatal("server did not exit after context cancellation") - case err := <-onServerExit: + // wait for the server to exit + + err = <-onServerExit if !errors.Is(err, context.Canceled) { t.Fatalf("server did not exit after context cancellation, got error: %v", err) } - } + }) } func TestServerInterrupt(t *testing.T) { diff --git a/mcp/conformance_go124_test.go b/mcp/conformance_go124_test.go deleted file mode 100644 index 3135970..0000000 --- a/mcp/conformance_go124_test.go +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright 2025 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -//go:build go1.24 && goexperiment.synctest - -package mcp - -import ( - "testing" - "testing/synctest" -) - -func runSyncTest(t *testing.T, f func(t *testing.T)) { - synctest.Run(func() { f(t) }) -} diff --git a/mcp/conformance_go125_test.go b/mcp/conformance_go125_test.go deleted file mode 100644 index c1bb0e1..0000000 --- a/mcp/conformance_go125_test.go +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright 2025 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -//go:build go1.25 - -package mcp - -import ( - "testing" - "testing/synctest" -) - -func runSyncTest(t *testing.T, f func(t *testing.T)) { - synctest.Test(t, f) -} diff --git a/mcp/conformance_test.go b/mcp/conformance_test.go index 8b557ae..3695ad5 100644 --- a/mcp/conformance_test.go +++ b/mcp/conformance_test.go @@ -2,8 +2,6 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. -//go:build (go1.24 && goexperiment.synctest) || go1.25 - package mcp import ( @@ -89,12 +87,9 @@ func TestServerConformance(t *testing.T) { // By comparison, gopls has a complicated framework based on progress // reporting and careful accounting to detect when all 'expected' work // on the server is complete. - runSyncTest(t, func(t *testing.T) { runServerTest(t, test) }) - - // TODO: in 1.25, use the following instead: - // synctest.Test(t, func(t *testing.T) { - // runServerTest(t, test) - // }) + synctest.Test(t, func(t *testing.T) { + runServerTest(t, test) + }) }) } } diff --git a/mcp/elicitation_go125_test.go b/mcp/elicitation_go125_test.go deleted file mode 100644 index e1a3007..0000000 --- a/mcp/elicitation_go125_test.go +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright 2025 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -//go:build go1.25 - -package mcp - -import ( - "context" - "testing" - "testing/synctest" -) - -func TestElicitationCompleteNotification_Synctest(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - ctx := context.Background() - - var elicitationCompleteCh = make(chan *ElicitationCompleteParams, 1) - - c := NewClient(testImpl, &ClientOptions{ - Capabilities: &ClientCapabilities{ - Roots: RootCapabilities{ListChanged: true}, - RootsV2: &RootCapabilities{ListChanged: true}, - Elicitation: &ElicitationCapabilities{ - URL: &URLElicitationCapabilities{}, - }, - }, - ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) { - return &ElicitResult{Action: "accept"}, nil - }, - ElicitationCompleteHandler: func(_ context.Context, req *ElicitationCompleteNotificationRequest) { - elicitationCompleteCh <- req.Params - }, - }) - - _, ss, cleanup := basicClientServerConnection(t, c, nil, nil) - defer cleanup() - - // 1. Server initiates a URL elicitation - elicitID := "testElicitationID-123" - resp, err := ss.Elicit(ctx, &ElicitParams{ - Mode: "url", - Message: "Please complete this form: ", - URL: "https://example.com/form?id=" + elicitID, - ElicitationID: elicitID, - }) - if err != nil { - t.Fatalf("Elicit failed: %v", err) - } - if resp.Action != "accept" { - t.Fatalf("Elicit action is %q, want %q", resp.Action, "accept") - } - - // 2. Server sends elicitation complete notification (simulating out-of-band completion) - err = handleNotify(ctx, notificationElicitationComplete, newServerRequest(ss, &ElicitationCompleteParams{ - ElicitationID: elicitID, - })) - if err != nil { - t.Fatalf("failed to send elicitation complete notification: %v", err) - } - - // 3. Client should receive the notification - gotParams := <-elicitationCompleteCh - if gotParams.ElicitationID != elicitID { - t.Errorf("elicitationComplete notification ID mismatch: got %q, want %q", gotParams.ElicitationID, elicitID) - } - }) -} diff --git a/mcp/elicitation_test.go b/mcp/elicitation_test.go index bb560b6..cc08820 100644 --- a/mcp/elicitation_test.go +++ b/mcp/elicitation_test.go @@ -9,7 +9,7 @@ import ( "errors" "strings" "testing" - "time" + "testing/synctest" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/jsonrpc" @@ -143,64 +143,60 @@ func TestElicitationURLMode(t *testing.T) { } } -// TODO: remove this test when Go 1.24 support is dropped (use go1.25 synctest version). func TestElicitationCompleteNotification(t *testing.T) { - ctx := context.Background() + synctest.Test(t, func(t *testing.T) { + ctx := context.Background() - var elicitationCompleteCh = make(chan *ElicitationCompleteParams, 1) + var elicitationCompleteCh = make(chan *ElicitationCompleteParams, 1) - c := NewClient(testImpl, &ClientOptions{ - Capabilities: &ClientCapabilities{ - Roots: RootCapabilities{ListChanged: true}, - RootsV2: &RootCapabilities{ListChanged: true}, - Elicitation: &ElicitationCapabilities{ - URL: &URLElicitationCapabilities{}, + c := NewClient(testImpl, &ClientOptions{ + Capabilities: &ClientCapabilities{ + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, + Elicitation: &ElicitationCapabilities{ + URL: &URLElicitationCapabilities{}, + }, }, - }, - ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) { - return &ElicitResult{Action: "accept"}, nil - }, - ElicitationCompleteHandler: func(_ context.Context, req *ElicitationCompleteNotificationRequest) { - elicitationCompleteCh <- req.Params - }, - }) + ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + ElicitationCompleteHandler: func(_ context.Context, req *ElicitationCompleteNotificationRequest) { + elicitationCompleteCh <- req.Params + }, + }) - cs, ss, cleanup := basicClientServerConnection(t, c, nil, nil) - _ = cs // Dummy usage to avoid "declared and not used" error. - defer cleanup() + _, ss, cleanup := basicClientServerConnection(t, c, nil, nil) + defer cleanup() - // 1. Server initiates a URL elicitation - elicitID := "testElicitationID-123" - resp, err := ss.Elicit(ctx, &ElicitParams{ - Mode: "url", - Message: "Please complete this form: ", - URL: "https://example.com/form?id=" + elicitID, - ElicitationID: elicitID, - }) - if err != nil { - t.Fatalf("Elicit failed: %v", err) - } - if resp.Action != "accept" { - t.Fatalf("Elicit action is %q, want %q", resp.Action, "accept") - } + // 1. Server initiates a URL elicitation + elicitID := "testElicitationID-123" + resp, err := ss.Elicit(ctx, &ElicitParams{ + Mode: "url", + Message: "Please complete this form: ", + URL: "https://example.com/form?id=" + elicitID, + ElicitationID: elicitID, + }) + if err != nil { + t.Fatalf("Elicit failed: %v", err) + } + if resp.Action != "accept" { + t.Fatalf("Elicit action is %q, want %q", resp.Action, "accept") + } - // 2. Server sends elicitation complete notification (simulating out-of-band completion) - err = handleNotify(ctx, notificationElicitationComplete, newServerRequest(ss, &ElicitationCompleteParams{ - ElicitationID: elicitID, - })) - if err != nil { - t.Fatalf("failed to send elicitation complete notification: %v", err) - } + // 2. Server sends elicitation complete notification (simulating out-of-band completion) + err = handleNotify(ctx, notificationElicitationComplete, newServerRequest(ss, &ElicitationCompleteParams{ + ElicitationID: elicitID, + })) + if err != nil { + t.Fatalf("failed to send elicitation complete notification: %v", err) + } - // 3. Client should receive the notification - select { - case gotParams := <-elicitationCompleteCh: + // 3. Client should receive the notification + gotParams := <-elicitationCompleteCh if gotParams.ElicitationID != elicitID { t.Errorf("elicitationComplete notification ID mismatch: got %q, want %q", gotParams.ElicitationID, elicitID) } - case <-time.After(5 * time.Second): - t.Fatal("timed out waiting for elicitation complete notification") - } + }) } func TestElicitationNoValidationWithoutAccept(t *testing.T) { diff --git a/mcp/mcp_go125_test.go b/mcp/mcp_go125_test.go deleted file mode 100644 index 5c52db1..0000000 --- a/mcp/mcp_go125_test.go +++ /dev/null @@ -1,756 +0,0 @@ -// Copyright 2025 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -//go:build go1.25 - -package mcp - -import ( - "context" - "errors" - "fmt" - "log/slog" - "path/filepath" - "runtime" - "slices" - "strings" - "sync/atomic" - "testing" - "testing/synctest" - "time" - - "github.com/google/go-cmp/cmp" - "github.com/google/jsonschema-go/jsonschema" -) - -// TODO(maciej-kisiel): split this test into multiple test cases that target specific functionality. -func TestEndToEnd_Synctest(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - ctx := context.Background() - var ct, st Transport = NewInMemoryTransports() - - // Channels to check if notification callbacks happened. - // These test asynchronous sending of notifications after a small delay (see - // Server.sendNotification). - notificationChans := map[string]chan int{} - for _, name := range []string{"initialized", "roots", "tools", "prompts", "resources", "progress_server", "progress_client", "resource_updated", "subscribe", "unsubscribe", "elicitation_complete"} { - notificationChans[name] = make(chan int, 1) - } - - waitForNotification := func(t *testing.T, name string) { - t.Helper() - time.Sleep(notificationDelay * 2) - <-notificationChans[name] - } - - sopts := &ServerOptions{ - InitializedHandler: func(context.Context, *InitializedRequest) { - notificationChans["initialized"] <- 0 - }, - RootsListChangedHandler: func(context.Context, *RootsListChangedRequest) { - notificationChans["roots"] <- 0 - }, - ProgressNotificationHandler: func(context.Context, *ProgressNotificationServerRequest) { - notificationChans["progress_server"] <- 0 - }, - SubscribeHandler: func(context.Context, *SubscribeRequest) error { - notificationChans["subscribe"] <- 0 - return nil - }, - UnsubscribeHandler: func(context.Context, *UnsubscribeRequest) error { - notificationChans["unsubscribe"] <- 0 - return nil - }, - } - s := NewServer(testImpl, sopts) - AddTool(s, &Tool{ - Name: "greet", - Description: "say hi", - }, sayHi) - AddTool(s, &Tool{Name: "fail", InputSchema: &jsonschema.Schema{Type: "object"}}, - func(context.Context, *CallToolRequest, map[string]any) (*CallToolResult, any, error) { - return nil, nil, errTestFailure - }) - s.AddPrompt(codeReviewPrompt, codReviewPromptHandler) - s.AddPrompt(&Prompt{Name: "fail"}, func(_ context.Context, _ *GetPromptRequest) (*GetPromptResult, error) { - return nil, errTestFailure - }) - s.AddResource(resource1, readHandler) - s.AddResource(resource2, readHandler) - - // Connect the server. - ss, err := s.Connect(ctx, st, nil) - if err != nil { - t.Fatal(err) - } - if got := slices.Collect(s.Sessions()); len(got) != 1 { - t.Errorf("after connection, Clients() has length %d, want 1", len(got)) - } - - loggingMessages := make(chan *LoggingMessageParams, 100) // big enough for all logging - opts := &ClientOptions{ - CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) { - return &CreateMessageResult{Model: "aModel", Content: &TextContent{}}, nil - }, - ElicitationHandler: func(ctx context.Context, req *ElicitRequest) (*ElicitResult, error) { - return &ElicitResult{ - Action: "accept", - Content: map[string]any{ - "name": "Test User", - "email": "test@example.com", - }, - }, nil - }, - ToolListChangedHandler: func(context.Context, *ToolListChangedRequest) { - notificationChans["tools"] <- 0 - }, - PromptListChangedHandler: func(context.Context, *PromptListChangedRequest) { - notificationChans["prompts"] <- 0 - }, - ResourceListChangedHandler: func(context.Context, *ResourceListChangedRequest) { - notificationChans["resources"] <- 0 - }, - LoggingMessageHandler: func(_ context.Context, req *LoggingMessageRequest) { - loggingMessages <- req.Params - }, - ProgressNotificationHandler: func(context.Context, *ProgressNotificationClientRequest) { - notificationChans["progress_client"] <- 0 - }, - ResourceUpdatedHandler: func(context.Context, *ResourceUpdatedNotificationRequest) { - notificationChans["resource_updated"] <- 0 - }, - ElicitationCompleteHandler: func(_ context.Context, req *ElicitationCompleteNotificationRequest) { - notificationChans["elicitation_complete"] <- 0 - }, - } - c := NewClient(testImpl, opts) - rootAbs, err := filepath.Abs(filepath.FromSlash("testdata/files")) - if err != nil { - t.Fatal(err) - } - c.AddRoots(&Root{URI: "file://" + rootAbs}) - - // Connect the client. - cs, err := c.Connect(ctx, ct, nil) - if err != nil { - t.Fatal(err) - } - - waitForNotification(t, "initialized") - if err := cs.Ping(ctx, nil); err != nil { - t.Fatalf("ping failed: %v", err) - } - - // ===== prompts ===== - t.Log("Testing prompts") - { - res, err := cs.ListPrompts(ctx, nil) - if err != nil { - t.Fatalf("prompts/list failed: %v", err) - } - wantPrompts := []*Prompt{ - { - Name: "code_review", - Description: "do a code review", - Arguments: []*PromptArgument{{Name: "Code", Required: true}}, - }, - {Name: "fail"}, - } - if diff := cmp.Diff(wantPrompts, res.Prompts); diff != "" { - t.Fatalf("prompts/list mismatch (-want +got):\n%s", diff) - } - - gotReview, err := cs.GetPrompt(ctx, &GetPromptParams{Name: "code_review", Arguments: map[string]string{"Code": "1+1"}}) - if err != nil { - t.Fatal(err) - } - wantReview := &GetPromptResult{ - Description: "Code review prompt", - Messages: []*PromptMessage{{ - Content: &TextContent{Text: "Please review the following code: 1+1"}, - Role: "user", - }}, - } - if diff := cmp.Diff(wantReview, gotReview); diff != "" { - t.Errorf("prompts/get 'code_review' mismatch (-want +got):\n%s", diff) - } - - if _, err := cs.GetPrompt(ctx, &GetPromptParams{Name: "fail"}); err == nil || !strings.Contains(err.Error(), errTestFailure.Error()) { - t.Errorf("fail returned unexpected error: got %v, want containing %v", err, errTestFailure) - } - - s.AddPrompt(&Prompt{Name: "T"}, nil) - waitForNotification(t, "prompts") - s.RemovePrompts("T") - waitForNotification(t, "prompts") - } - - // ===== tools ===== - t.Log("Testing tools") - { - // ListTools is tested in client_list_test.go. - gotHi, err := cs.CallTool(ctx, &CallToolParams{ - Name: "greet", - Arguments: map[string]any{"Name": "user"}, - }) - if err != nil { - t.Fatal(err) - } - wantHi := &CallToolResult{ - Content: []Content{ - &TextContent{Text: "hi user"}, - }, - } - if diff := cmp.Diff(wantHi, gotHi, ctrCmpOpts...); diff != "" { - t.Errorf("tools/call 'greet' mismatch (-want +got):\n%s", diff) - } - - gotFail, err := cs.CallTool(ctx, &CallToolParams{ - Name: "fail", - Arguments: map[string]any{}, - }) - // Counter-intuitively, when a tool fails, we don't expect an RPC error for - // call tool: instead, the failure is embedded in the result. - if err != nil { - t.Fatal(err) - } - wantFail := &CallToolResult{ - IsError: true, - Content: []Content{ - &TextContent{Text: errTestFailure.Error()}, - }, - } - if diff := cmp.Diff(wantFail, gotFail, ctrCmpOpts...); diff != "" { - t.Errorf("tools/call 'fail' mismatch (-want +got):\n%s", diff) - } - - // Check output schema validation. - badout := &Tool{ - Name: "badout", - OutputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "x": {Type: "string"}, - }, - }, - } - AddTool(s, badout, func(_ context.Context, _ *CallToolRequest, arg map[string]any) (*CallToolResult, map[string]any, error) { - return nil, map[string]any{"x": 1}, nil - }) - _, err = cs.CallTool(ctx, &CallToolParams{Name: "badout"}) - wantMsg := `has type "integer", want "string"` - if err == nil || !strings.Contains(err.Error(), wantMsg) { - t.Errorf("\ngot %q\nwant error message containing %q", err, wantMsg) - } - - // Check tools-changed notifications. - s.AddTool(&Tool{Name: "T", InputSchema: &jsonschema.Schema{Type: "object"}}, nopHandler) - waitForNotification(t, "tools") - s.RemoveTools("T") - waitForNotification(t, "tools") - } - - // ===== resources ===== - t.Log("Testing resources") - //TODO: fix for Windows - if runtime.GOOS != "windows" { - wantResources := []*Resource{resource2, resource1} - lrres, err := cs.ListResources(ctx, nil) - if err != nil { - t.Fatal(err) - } - if diff := cmp.Diff(wantResources, lrres.Resources); diff != "" { - t.Errorf("resources/list mismatch (-want, +got):\n%s", diff) - } - - template := &ResourceTemplate{ - Name: "rt", - MIMEType: "text/template", - URITemplate: "file:///{+filename}", // the '+' means that filename can contain '/' - } - s.AddResourceTemplate(template, readHandler) - tres, err := cs.ListResourceTemplates(ctx, nil) - if err != nil { - t.Fatal(err) - } - if diff := cmp.Diff([]*ResourceTemplate{template}, tres.ResourceTemplates); diff != "" { - t.Errorf("resources/list mismatch (-want, +got):\n%s", diff) - } - - for _, tt := range []struct { - uri string - mimeType string // "": not found; "text/plain": resource; "text/template": template - fail bool // non-nil error returned - }{ - {"file:///info.txt", "text/plain", false}, - {"file:///fail.txt", "", false}, - {"file:///template.txt", "text/template", false}, - {"file:///../private.txt", "", true}, // not found: escaping disallowed - } { - rres, err := cs.ReadResource(ctx, &ReadResourceParams{URI: tt.uri}) - if err != nil { - if code := errorCode(err); code == CodeResourceNotFound { - if tt.mimeType != "" { - t.Errorf("%s: not found but expected it to be", tt.uri) - } - } else if !tt.fail { - t.Errorf("%s: unexpected error %v", tt.uri, err) - } - } else { - if tt.fail { - t.Errorf("%s: unexpected success", tt.uri) - } else if g, w := len(rres.Contents), 1; g != w { - t.Errorf("got %d contents, wanted %d", g, w) - } else { - c := rres.Contents[0] - if got := c.URI; got != tt.uri { - t.Errorf("got uri %q, want %q", got, tt.uri) - } - if got := c.MIMEType; got != tt.mimeType { - t.Errorf("%s: got MIME type %q, want %q", tt.uri, got, tt.mimeType) - } - } - } - } - - s.AddResource(&Resource{URI: "http://U"}, nil) - waitForNotification(t, "resources") - s.RemoveResources("http://U") - waitForNotification(t, "resources") - } - - // ===== roots ===== - t.Log("Testing roots") - { - rootRes, err := ss.ListRoots(ctx, &ListRootsParams{}) - if err != nil { - t.Fatal(err) - } - gotRoots := rootRes.Roots - wantRoots := slices.Collect(c.roots.all()) - if diff := cmp.Diff(wantRoots, gotRoots); diff != "" { - t.Errorf("roots/list mismatch (-want +got):\n%s", diff) - } - - c.AddRoots(&Root{URI: "U"}) - waitForNotification(t, "roots") - c.RemoveRoots("U") - waitForNotification(t, "roots") - } - - // ===== sampling ===== - t.Log("Testing sampling") - { - // TODO: test that a client that doesn't have the handler returns CodeUnsupportedMethod. - res, err := ss.CreateMessage(ctx, &CreateMessageParams{}) - if err != nil { - t.Fatal(err) - } - if g, w := res.Model, "aModel"; g != w { - t.Errorf("got %q, want %q", g, w) - } - } - - // ===== logging ===== - t.Log("Testing logging") - { - want := []*LoggingMessageParams{ - { - Logger: "test", - Level: "warning", - Data: map[string]any{ - "msg": "first", - "name": "Pat", - "logtest": true, - }, - }, - { - Logger: "test", - Level: "alert", - Data: map[string]any{ - "msg": "second", - "count": 2.0, - "logtest": true, - }, - }, - } - - check := func(t *testing.T) { - t.Helper() - var got []*LoggingMessageParams - // Read messages from this test until we've seen all we expect. - for len(got) < len(want) { - p := <-loggingMessages - // Ignore logging from other tests. - if m, ok := p.Data.(map[string]any); ok && m["logtest"] != nil { - delete(m, "time") - got = append(got, p) - } - } - if diff := cmp.Diff(want, got); diff != "" { - t.Errorf("mismatch (-want, +got):\n%s", diff) - } - } - - // Use the LoggingMessage method directly. - t.Log("Testing logging (direct)") - { - mustLog := func(level LoggingLevel, data any) { - t.Helper() - if err := ss.Log(ctx, &LoggingMessageParams{ - Logger: "test", - Level: level, - Data: data, - }); err != nil { - t.Fatal(err) - } - } - - // Nothing should be logged until the client sets a level. - mustLog("info", "before") - if err := cs.SetLoggingLevel(ctx, &SetLoggingLevelParams{Level: "warning"}); err != nil { - t.Fatal(err) - } - mustLog("warning", want[0].Data) - mustLog("debug", "nope") // below the level - mustLog("info", "negative") // below the level - mustLog("alert", want[1].Data) - check(t) - } - - // Use the slog handler. - t.Log("Testing logging (handler)") - { - // We can't check the "before SetLevel" behavior because it's already been set. - // Not a big deal: that check is in LoggingMessage anyway. - logger := slog.New(NewLoggingHandler(ss, &LoggingHandlerOptions{LoggerName: "test"})) - logger.Warn("first", "name", "Pat", "logtest", true) - logger.Debug("nope") // below the level - logger.Info("negative") // below the level - logger.Log(ctx, LevelAlert, "second", "count", 2, "logtest", true) - check(t) - } - } - - // ===== progress ===== - t.Log("Testing progress") - { - ss.NotifyProgress(ctx, &ProgressNotificationParams{ - ProgressToken: "token-xyz", - Message: "progress update", - Progress: 0.5, - Total: 2, - }) - waitForNotification(t, "progress_client") - - cs.NotifyProgress(ctx, &ProgressNotificationParams{ - ProgressToken: "token-abc", - Message: "progress update", - Progress: 1, - Total: 2, - }) - waitForNotification(t, "progress_server") - } - - // ===== resource_subscriptions ===== - t.Log("Testing resource_subscriptions") - { - err := cs.Subscribe(ctx, &SubscribeParams{ - URI: "test", - }) - if err != nil { - t.Fatal(err) - } - synctest.Wait() - <-notificationChans["subscribe"] - - s.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{ - URI: "test", - }) - waitForNotification(t, "resource_updated") - - err = cs.Unsubscribe(ctx, &UnsubscribeParams{ - URI: "test", - }) - if err != nil { - t.Fatal(err) - } - waitForNotification(t, "unsubscribe") - - // Verify the client does not receive the update after unsubscribing. - s.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{ - URI: "test", - }) - synctest.Wait() - select { - case <-notificationChans["resource_updated"]: - t.Fatalf("resource updated after unsubscription") - default: - // Expected: no notification received - } - } - - // ===== elicitation ===== - t.Log("Testing elicitation") - { - result, err := ss.Elicit(ctx, &ElicitParams{ - Message: "Please provide information", - }) - if err != nil { - t.Fatal(err) - } - if result.Action != "accept" { - t.Errorf("got action %q, want %q", result.Action, "accept") - } - } - - // Disconnect. - cs.Close() - if err := ss.Wait(); err != nil { - t.Errorf("server failed: %v", err) - } - - // After disconnecting, neither client nor server should have any - // connections. - for range s.Sessions() { - t.Errorf("unexpected client after disconnection") - } - }) -} - -func TestKeepAlive_Synctest(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - ctx := context.Background() - - ct, st := NewInMemoryTransports() - - serverOpts := &ServerOptions{ - KeepAlive: 100 * time.Millisecond, - } - s := NewServer(testImpl, serverOpts) - AddTool(s, greetTool(), sayHi) - - ss, err := s.Connect(ctx, st, nil) - if err != nil { - t.Fatal(err) - } - defer ss.Close() - - clientOpts := &ClientOptions{ - KeepAlive: 100 * time.Millisecond, - } - c := NewClient(testImpl, clientOpts) - cs, err := c.Connect(ctx, ct, nil) - if err != nil { - t.Fatal(err) - } - defer cs.Close() - - // Wait for a few keepalive cycles to ensure pings are working. - // With synctest, this advances simulated time instantly. - time.Sleep(300 * time.Millisecond) - - // Test that the connection is still alive by making a call - result, err := cs.CallTool(ctx, &CallToolParams{ - Name: "greet", - Arguments: map[string]any{"Name": "user"}, - }) - if err != nil { - t.Fatalf("call failed after keepalive: %v", err) - } - if len(result.Content) == 0 { - t.Fatal("expected content in result") - } - if textContent, ok := result.Content[0].(*TextContent); !ok || textContent.Text != "hi user" { - t.Fatalf("unexpected result: %v", result.Content[0]) - } - }) -} - -func TestCancellation_Synctest(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - var ( - start = make(chan struct{}) - cancelled = make(chan struct{}, 1) // don't block the request - ) - slowTool := func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { - start <- struct{}{} - select { - case <-ctx.Done(): - cancelled <- struct{}{} - case <-time.After(5 * time.Second): - return nil, nil, nil - } - return nil, nil, nil - } - cs, _, cleanup := basicConnection(t, func(s *Server) { - AddTool(s, &Tool{Name: "slow", InputSchema: &jsonschema.Schema{Type: "object"}}, slowTool) - }) - defer cleanup() - - ctx, cancel := context.WithCancel(context.Background()) - go cs.CallTool(ctx, &CallToolParams{Name: "slow"}) - <-start - cancel() - - <-cancelled - }) -} - -func TestKeepAliveFailure_Synctest(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - ctx := context.Background() - - // This test doesn't pinpoint keepalive detected failures well due to the fact that in-memory transports - // propagate `io.EOF` synchronously, causing the transport level connection to be closed immediately. - // TODO: propose better transports that would allow testing this scenario precisely. - ct, st := NewInMemoryTransports() - - // Server without keepalive (to test one-sided keepalive) - s := NewServer(testImpl, nil) - AddTool(s, greetTool(), sayHi) - ss, err := s.Connect(ctx, st, nil) - if err != nil { - t.Fatal(err) - } - - // Client with short keepalive - clientOpts := &ClientOptions{ - KeepAlive: 50 * time.Millisecond, - } - c := NewClient(testImpl, clientOpts) - cs, err := c.Connect(ctx, ct, nil) - if err != nil { - t.Fatal(err) - } - defer cs.Close() - - // Let the connection establish properly first - synctest.Wait() - - // simulate ping failure - ss.Close() - - time.Sleep(100 * time.Millisecond) - synctest.Wait() - - _, err = cs.CallTool(ctx, &CallToolParams{ - Name: "greet", - Arguments: map[string]any{"Name": "user"}, - }) - if err != nil && (errors.Is(err, ErrConnectionClosed) || strings.Contains(err.Error(), "connection closed")) { - return // Test passed - } - - t.Errorf("expected connection to be closed by keepalive, but it wasn't. Last error: %v", err) - }) -} - -func TestSynchronousNotifications_Synctest(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - var toolsChanged atomic.Int32 - clientOpts := &ClientOptions{ - ToolListChangedHandler: func(ctx context.Context, req *ToolListChangedRequest) { - toolsChanged.Add(1) - }, - CreateMessageHandler: func(ctx context.Context, req *CreateMessageRequest) (*CreateMessageResult, error) { - // See the comment after "from server" below. - if n := toolsChanged.Load(); n != 1 { - return nil, fmt.Errorf("got %d tools-changed notification, wanted 1", n) - } - // TODO(rfindley): investigate the error returned from this test if - // CreateMessageResult is new(CreateMessageResult): it's a mysterious - // unmarshalling error that we should improve. - return &CreateMessageResult{Content: &TextContent{}}, nil - }, - } - client := NewClient(testImpl, clientOpts) - - var rootsChanged atomic.Bool - serverOpts := &ServerOptions{ - RootsListChangedHandler: func(_ context.Context, req *RootsListChangedRequest) { - rootsChanged.Store(true) - }, - } - server := NewServer(testImpl, serverOpts) - addTool := func(s *Server) { - AddTool(s, &Tool{Name: "tool"}, func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { - if !rootsChanged.Load() { - return nil, nil, fmt.Errorf("didn't get root change notification") - } - return new(CallToolResult), nil, nil - }) - } - cs, ss, cleanup := basicClientServerConnection(t, client, server, addTool) - defer cleanup() - - t.Log("from client") - { - client.AddRoots(&Root{Name: "myroot", URI: "file://foo"}) - res, err := cs.CallTool(context.Background(), &CallToolParams{Name: "tool"}) - if err != nil { - t.Fatalf("CallTool failed: %v", err) - } - if res.IsError { - t.Errorf("tool error: %v", res.Content[0].(*TextContent).Text) - } - } - - t.Log("from server") - { - // Despite all this tool-changed activity, we expect only one notification. - for range 10 { - server.RemoveTools("tool") - addTool(server) - } - - time.Sleep(notificationDelay * 2) // Wait for delayed notification. - if _, err := ss.CreateMessage(context.Background(), new(CreateMessageParams)); err != nil { - t.Errorf("CreateMessage failed: %v", err) - } - } - - }) -} - -func TestNoDistributedDeadlock_Synctest(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - // This test verifies that calls are asynchronous, and so it's not possible - // to have a distributed deadlock. - // - // The setup creates potential deadlock for both the client and server: the - // client sends a call to tool1, which itself calls createMessage, which in - // turn calls tool2, which calls ping. - // - // If the server were not asynchronous, the call to tool2 would hang. If the - // client were not asynchronous, the call to ping would hang. - // - // Such a scenario is unlikely in practice, but is still theoretically - // possible, and in any case making tool calls asynchronous by default - // delegates synchronization to the user. - clientOpts := &ClientOptions{ - CreateMessageHandler: func(ctx context.Context, req *CreateMessageRequest) (*CreateMessageResult, error) { - req.Session.CallTool(ctx, &CallToolParams{Name: "tool2"}) - return &CreateMessageResult{Content: &TextContent{}}, nil - }, - } - client := NewClient(testImpl, clientOpts) - cs, _, cleanup := basicClientServerConnection(t, client, nil, func(s *Server) { - AddTool(s, &Tool{Name: "tool1"}, func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { - req.Session.CreateMessage(ctx, new(CreateMessageParams)) - return new(CallToolResult), nil, nil - }) - AddTool(s, &Tool{Name: "tool2"}, func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { - req.Session.Ping(ctx, nil) - return new(CallToolResult), nil, nil - }) - }) - defer cleanup() - - if _, err := cs.CallTool(context.Background(), &CallToolParams{Name: "tool1"}); err != nil { - // should not deadlock - t.Fatalf("CallTool failed: %v", err) - } - }) -} diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 86c0550..7380ad5 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -20,6 +20,7 @@ import ( "sync" "sync/atomic" "testing" + "testing/synctest" "time" "github.com/google/go-cmp/cmp" @@ -56,474 +57,499 @@ func codReviewPromptHandler(_ context.Context, req *GetPromptRequest) (*GetPromp }, nil } -// TODO: remove this test when Go 1.24 support is dropped (use go1.25 synctest version). +// TODO(maciej-kisiel): split this test into multiple test cases that target specific functionality. func TestEndToEnd(t *testing.T) { - ctx := context.Background() - var ct, st Transport = NewInMemoryTransports() + synctest.Test(t, func(t *testing.T) { + ctx := context.Background() + var ct, st Transport = NewInMemoryTransports() - // Channels to check if notification callbacks happened. - // These test asynchronous sending of notifications after a small delay (see - // Server.sendNotification). - notificationChans := map[string]chan int{} - for _, name := range []string{"initialized", "roots", "tools", "prompts", "resources", "progress_server", "progress_client", "resource_updated", "subscribe", "unsubscribe", "elicitation_complete"} { - notificationChans[name] = make(chan int, 1) - } - - waitForNotification := func(t *testing.T, name string) { - t.Helper() - select { - case <-notificationChans[name]: - case <-time.After(time.Second): - t.Fatalf("%s handler never called", name) - } - } - - sopts := &ServerOptions{ - InitializedHandler: func(context.Context, *InitializedRequest) { - notificationChans["initialized"] <- 0 - }, - RootsListChangedHandler: func(context.Context, *RootsListChangedRequest) { - notificationChans["roots"] <- 0 - }, - ProgressNotificationHandler: func(context.Context, *ProgressNotificationServerRequest) { - notificationChans["progress_server"] <- 0 - }, - SubscribeHandler: func(context.Context, *SubscribeRequest) error { - notificationChans["subscribe"] <- 0 - return nil - }, - UnsubscribeHandler: func(context.Context, *UnsubscribeRequest) error { - notificationChans["unsubscribe"] <- 0 - return nil - }, - } - s := NewServer(testImpl, sopts) - AddTool(s, &Tool{ - Name: "greet", - Description: "say hi", - }, sayHi) - AddTool(s, &Tool{Name: "fail", InputSchema: &jsonschema.Schema{Type: "object"}}, - func(context.Context, *CallToolRequest, map[string]any) (*CallToolResult, any, error) { - return nil, nil, errTestFailure - }) - s.AddPrompt(codeReviewPrompt, codReviewPromptHandler) - s.AddPrompt(&Prompt{Name: "fail"}, func(_ context.Context, _ *GetPromptRequest) (*GetPromptResult, error) { - return nil, errTestFailure - }) - s.AddResource(resource1, readHandler) - s.AddResource(resource2, readHandler) - - // Connect the server. - ss, err := s.Connect(ctx, st, nil) - if err != nil { - t.Fatal(err) - } - if got := slices.Collect(s.Sessions()); len(got) != 1 { - t.Errorf("after connection, Clients() has length %d, want 1", len(got)) - } - - loggingMessages := make(chan *LoggingMessageParams, 100) // big enough for all logging - opts := &ClientOptions{ - CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) { - return &CreateMessageResult{Model: "aModel", Content: &TextContent{}}, nil - }, - ElicitationHandler: func(ctx context.Context, req *ElicitRequest) (*ElicitResult, error) { - return &ElicitResult{ - Action: "accept", - Content: map[string]any{ - "name": "Test User", - "email": "test@example.com", - }, - }, nil - }, - ToolListChangedHandler: func(context.Context, *ToolListChangedRequest) { - notificationChans["tools"] <- 0 - }, - PromptListChangedHandler: func(context.Context, *PromptListChangedRequest) { - notificationChans["prompts"] <- 0 - }, - ResourceListChangedHandler: func(context.Context, *ResourceListChangedRequest) { - notificationChans["resources"] <- 0 - }, - LoggingMessageHandler: func(_ context.Context, req *LoggingMessageRequest) { - loggingMessages <- req.Params - }, - ProgressNotificationHandler: func(context.Context, *ProgressNotificationClientRequest) { - notificationChans["progress_client"] <- 0 - }, - ResourceUpdatedHandler: func(context.Context, *ResourceUpdatedNotificationRequest) { - notificationChans["resource_updated"] <- 0 - }, - ElicitationCompleteHandler: func(_ context.Context, req *ElicitationCompleteNotificationRequest) { - notificationChans["elicitation_complete"] <- 0 - }, - } - c := NewClient(testImpl, opts) - rootAbs, err := filepath.Abs(filepath.FromSlash("testdata/files")) - if err != nil { - t.Fatal(err) - } - c.AddRoots(&Root{URI: "file://" + rootAbs}) - - // Connect the client. - cs, err := c.Connect(ctx, ct, nil) - if err != nil { - t.Fatal(err) - } - - waitForNotification(t, "initialized") - if err := cs.Ping(ctx, nil); err != nil { - t.Fatalf("ping failed: %v", err) - } - t.Run("prompts", func(t *testing.T) { - res, err := cs.ListPrompts(ctx, nil) - if err != nil { - t.Fatalf("prompts/list failed: %v", err) - } - wantPrompts := []*Prompt{ - { - Name: "code_review", - Description: "do a code review", - Arguments: []*PromptArgument{{Name: "Code", Required: true}}, - }, - {Name: "fail"}, - } - if diff := cmp.Diff(wantPrompts, res.Prompts); diff != "" { - t.Fatalf("prompts/list mismatch (-want +got):\n%s", diff) + // Channels to check if notification callbacks happened. + // These test asynchronous sending of notifications after a small delay (see + // Server.sendNotification). + notificationChans := map[string]chan int{} + for _, name := range []string{"initialized", "roots", "tools", "prompts", "resources", "progress_server", "progress_client", "resource_updated", "subscribe", "unsubscribe", "elicitation_complete"} { + notificationChans[name] = make(chan int, 1) } - gotReview, err := cs.GetPrompt(ctx, &GetPromptParams{Name: "code_review", Arguments: map[string]string{"Code": "1+1"}}) - if err != nil { - t.Fatal(err) - } - wantReview := &GetPromptResult{ - Description: "Code review prompt", - Messages: []*PromptMessage{{ - Content: &TextContent{Text: "Please review the following code: 1+1"}, - Role: "user", - }}, - } - if diff := cmp.Diff(wantReview, gotReview); diff != "" { - t.Errorf("prompts/get 'code_review' mismatch (-want +got):\n%s", diff) - } - - if _, err := cs.GetPrompt(ctx, &GetPromptParams{Name: "fail"}); err == nil || !strings.Contains(err.Error(), errTestFailure.Error()) { - t.Errorf("fail returned unexpected error: got %v, want containing %v", err, errTestFailure) - } - - s.AddPrompt(&Prompt{Name: "T"}, nil) - waitForNotification(t, "prompts") - s.RemovePrompts("T") - waitForNotification(t, "prompts") - }) - - t.Run("tools", func(t *testing.T) { - // ListTools is tested in client_list_test.go. - gotHi, err := cs.CallTool(ctx, &CallToolParams{ - Name: "greet", - Arguments: map[string]any{"Name": "user"}, - }) - if err != nil { - t.Fatal(err) - } - wantHi := &CallToolResult{ - Content: []Content{ - &TextContent{Text: "hi user"}, - }, - } - if diff := cmp.Diff(wantHi, gotHi, ctrCmpOpts...); diff != "" { - t.Errorf("tools/call 'greet' mismatch (-want +got):\n%s", diff) - } - - gotFail, err := cs.CallTool(ctx, &CallToolParams{ - Name: "fail", - Arguments: map[string]any{}, - }) - // Counter-intuitively, when a tool fails, we don't expect an RPC error for - // call tool: instead, the failure is embedded in the result. - if err != nil { - t.Fatal(err) - } - wantFail := &CallToolResult{ - IsError: true, - Content: []Content{ - &TextContent{Text: errTestFailure.Error()}, - }, - } - if diff := cmp.Diff(wantFail, gotFail, ctrCmpOpts...); diff != "" { - t.Errorf("tools/call 'fail' mismatch (-want +got):\n%s", diff) - } - - // Check output schema validation. - badout := &Tool{ - Name: "badout", - OutputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "x": {Type: "string"}, - }, - }, - } - AddTool(s, badout, func(_ context.Context, _ *CallToolRequest, arg map[string]any) (*CallToolResult, map[string]any, error) { - return nil, map[string]any{"x": 1}, nil - }) - _, err = cs.CallTool(ctx, &CallToolParams{Name: "badout"}) - wantMsg := `has type "integer", want "string"` - if err == nil || !strings.Contains(err.Error(), wantMsg) { - t.Errorf("\ngot %q\nwant error message containing %q", err, wantMsg) - } - - // Check tools-changed notifications. - s.AddTool(&Tool{Name: "T", InputSchema: &jsonschema.Schema{Type: "object"}}, nopHandler) - waitForNotification(t, "tools") - s.RemoveTools("T") - waitForNotification(t, "tools") - }) - - t.Run("resources", func(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("TODO: fix for Windows") - } - wantResources := []*Resource{resource2, resource1} - lrres, err := cs.ListResources(ctx, nil) - if err != nil { - t.Fatal(err) - } - if diff := cmp.Diff(wantResources, lrres.Resources); diff != "" { - t.Errorf("resources/list mismatch (-want, +got):\n%s", diff) - } - - template := &ResourceTemplate{ - Name: "rt", - MIMEType: "text/template", - URITemplate: "file:///{+filename}", // the '+' means that filename can contain '/' - } - s.AddResourceTemplate(template, readHandler) - tres, err := cs.ListResourceTemplates(ctx, nil) - if err != nil { - t.Fatal(err) - } - if diff := cmp.Diff([]*ResourceTemplate{template}, tres.ResourceTemplates); diff != "" { - t.Errorf("resources/list mismatch (-want, +got):\n%s", diff) - } - - for _, tt := range []struct { - uri string - mimeType string // "": not found; "text/plain": resource; "text/template": template - fail bool // non-nil error returned - }{ - {"file:///info.txt", "text/plain", false}, - {"file:///fail.txt", "", false}, - {"file:///template.txt", "text/template", false}, - {"file:///../private.txt", "", true}, // not found: escaping disallowed - } { - rres, err := cs.ReadResource(ctx, &ReadResourceParams{URI: tt.uri}) - if err != nil { - if code := errorCode(err); code == CodeResourceNotFound { - if tt.mimeType != "" { - t.Errorf("%s: not found but expected it to be", tt.uri) - } - } else if !tt.fail { - t.Errorf("%s: unexpected error %v", tt.uri, err) - } - } else { - if tt.fail { - t.Errorf("%s: unexpected success", tt.uri) - } else if g, w := len(rres.Contents), 1; g != w { - t.Errorf("got %d contents, wanted %d", g, w) - } else { - c := rres.Contents[0] - if got := c.URI; got != tt.uri { - t.Errorf("got uri %q, want %q", got, tt.uri) - } - if got := c.MIMEType; got != tt.mimeType { - t.Errorf("%s: got MIME type %q, want %q", tt.uri, got, tt.mimeType) - } - } - } - } - - s.AddResource(&Resource{URI: "http://U"}, nil) - waitForNotification(t, "resources") - s.RemoveResources("http://U") - waitForNotification(t, "resources") - }) - t.Run("roots", func(t *testing.T) { - rootRes, err := ss.ListRoots(ctx, &ListRootsParams{}) - if err != nil { - t.Fatal(err) - } - gotRoots := rootRes.Roots - wantRoots := slices.Collect(c.roots.all()) - if diff := cmp.Diff(wantRoots, gotRoots); diff != "" { - t.Errorf("roots/list mismatch (-want +got):\n%s", diff) - } - - c.AddRoots(&Root{URI: "U"}) - waitForNotification(t, "roots") - c.RemoveRoots("U") - waitForNotification(t, "roots") - }) - t.Run("sampling", func(t *testing.T) { - // TODO: test that a client that doesn't have the handler returns CodeUnsupportedMethod. - res, err := ss.CreateMessage(ctx, &CreateMessageParams{}) - if err != nil { - t.Fatal(err) - } - if g, w := res.Model, "aModel"; g != w { - t.Errorf("got %q, want %q", g, w) - } - }) - t.Run("logging", func(t *testing.T) { - want := []*LoggingMessageParams{ - { - Logger: "test", - Level: "warning", - Data: map[string]any{ - "msg": "first", - "name": "Pat", - "logtest": true, - }, - }, - { - Logger: "test", - Level: "alert", - Data: map[string]any{ - "msg": "second", - "count": 2.0, - "logtest": true, - }, - }, - } - - check := func(t *testing.T) { + waitForNotification := func(t *testing.T, name string) { t.Helper() - var got []*LoggingMessageParams - // Read messages from this test until we've seen all we expect. - for len(got) < len(want) { - select { - case p := <-loggingMessages: - // Ignore logging from other tests. - if m, ok := p.Data.(map[string]any); ok && m["logtest"] != nil { - delete(m, "time") // remove time because it changes - got = append(got, p) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for log messages") - } - } - if diff := cmp.Diff(want, got); diff != "" { - t.Errorf("mismatch (-want, +got):\n%s", diff) - } + time.Sleep(notificationDelay * 2) + <-notificationChans[name] } - t.Run("direct", func(t *testing.T) { // Use the LoggingMessage method directly. + sopts := &ServerOptions{ + InitializedHandler: func(context.Context, *InitializedRequest) { + notificationChans["initialized"] <- 0 + }, + RootsListChangedHandler: func(context.Context, *RootsListChangedRequest) { + notificationChans["roots"] <- 0 + }, + ProgressNotificationHandler: func(context.Context, *ProgressNotificationServerRequest) { + notificationChans["progress_server"] <- 0 + }, + SubscribeHandler: func(context.Context, *SubscribeRequest) error { + notificationChans["subscribe"] <- 0 + return nil + }, + UnsubscribeHandler: func(context.Context, *UnsubscribeRequest) error { + notificationChans["unsubscribe"] <- 0 + return nil + }, + } + s := NewServer(testImpl, sopts) + AddTool(s, &Tool{ + Name: "greet", + Description: "say hi", + }, sayHi) + AddTool(s, &Tool{Name: "fail", InputSchema: &jsonschema.Schema{Type: "object"}}, + func(context.Context, *CallToolRequest, map[string]any) (*CallToolResult, any, error) { + return nil, nil, errTestFailure + }) + s.AddPrompt(codeReviewPrompt, codReviewPromptHandler) + s.AddPrompt(&Prompt{Name: "fail"}, func(_ context.Context, _ *GetPromptRequest) (*GetPromptResult, error) { + return nil, errTestFailure + }) + s.AddResource(resource1, readHandler) + s.AddResource(resource2, readHandler) - mustLog := func(level LoggingLevel, data any) { - t.Helper() - if err := ss.Log(ctx, &LoggingMessageParams{ - Logger: "test", - Level: level, - Data: data, - }); err != nil { - t.Fatal(err) - } + // Connect the server. + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + if got := slices.Collect(s.Sessions()); len(got) != 1 { + t.Errorf("after connection, Clients() has length %d, want 1", len(got)) + } + + loggingMessages := make(chan *LoggingMessageParams, 100) // big enough for all logging + opts := &ClientOptions{ + CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) { + return &CreateMessageResult{Model: "aModel", Content: &TextContent{}}, nil + }, + ElicitationHandler: func(ctx context.Context, req *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{ + Action: "accept", + Content: map[string]any{ + "name": "Test User", + "email": "test@example.com", + }, + }, nil + }, + ToolListChangedHandler: func(context.Context, *ToolListChangedRequest) { + notificationChans["tools"] <- 0 + }, + PromptListChangedHandler: func(context.Context, *PromptListChangedRequest) { + notificationChans["prompts"] <- 0 + }, + ResourceListChangedHandler: func(context.Context, *ResourceListChangedRequest) { + notificationChans["resources"] <- 0 + }, + LoggingMessageHandler: func(_ context.Context, req *LoggingMessageRequest) { + loggingMessages <- req.Params + }, + ProgressNotificationHandler: func(context.Context, *ProgressNotificationClientRequest) { + notificationChans["progress_client"] <- 0 + }, + ResourceUpdatedHandler: func(context.Context, *ResourceUpdatedNotificationRequest) { + notificationChans["resource_updated"] <- 0 + }, + ElicitationCompleteHandler: func(_ context.Context, req *ElicitationCompleteNotificationRequest) { + notificationChans["elicitation_complete"] <- 0 + }, + } + c := NewClient(testImpl, opts) + rootAbs, err := filepath.Abs(filepath.FromSlash("testdata/files")) + if err != nil { + t.Fatal(err) + } + c.AddRoots(&Root{URI: "file://" + rootAbs}) + + // Connect the client. + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + + waitForNotification(t, "initialized") + if err := cs.Ping(ctx, nil); err != nil { + t.Fatalf("ping failed: %v", err) + } + + // ===== prompts ===== + t.Log("Testing prompts") + { + res, err := cs.ListPrompts(ctx, nil) + if err != nil { + t.Fatalf("prompts/list failed: %v", err) + } + wantPrompts := []*Prompt{ + { + Name: "code_review", + Description: "do a code review", + Arguments: []*PromptArgument{{Name: "Code", Required: true}}, + }, + {Name: "fail"}, + } + if diff := cmp.Diff(wantPrompts, res.Prompts); diff != "" { + t.Fatalf("prompts/list mismatch (-want +got):\n%s", diff) } - // Nothing should be logged until the client sets a level. - mustLog("info", "before") - if err := cs.SetLoggingLevel(ctx, &SetLoggingLevelParams{Level: "warning"}); err != nil { + gotReview, err := cs.GetPrompt(ctx, &GetPromptParams{Name: "code_review", Arguments: map[string]string{"Code": "1+1"}}) + if err != nil { t.Fatal(err) } - mustLog("warning", want[0].Data) - mustLog("debug", "nope") // below the level - mustLog("info", "negative") // below the level - mustLog("alert", want[1].Data) - check(t) - }) + wantReview := &GetPromptResult{ + Description: "Code review prompt", + Messages: []*PromptMessage{{ + Content: &TextContent{Text: "Please review the following code: 1+1"}, + Role: "user", + }}, + } + if diff := cmp.Diff(wantReview, gotReview); diff != "" { + t.Errorf("prompts/get 'code_review' mismatch (-want +got):\n%s", diff) + } - t.Run("handler", func(t *testing.T) { // Use the slog handler. - // We can't check the "before SetLevel" behavior because it's already been set. - // Not a big deal: that check is in LoggingMessage anyway. - logger := slog.New(NewLoggingHandler(ss, &LoggingHandlerOptions{LoggerName: "test"})) - logger.Warn("first", "name", "Pat", "logtest", true) - logger.Debug("nope") // below the level - logger.Info("negative") // below the level - logger.Log(ctx, LevelAlert, "second", "count", 2, "logtest", true) - check(t) - }) - }) - t.Run("progress", func(t *testing.T) { - ss.NotifyProgress(ctx, &ProgressNotificationParams{ - ProgressToken: "token-xyz", - Message: "progress update", - Progress: 0.5, - Total: 2, - }) - waitForNotification(t, "progress_client") - cs.NotifyProgress(ctx, &ProgressNotificationParams{ - ProgressToken: "token-abc", - Message: "progress update", - Progress: 1, - Total: 2, - }) - waitForNotification(t, "progress_server") - }) + if _, err := cs.GetPrompt(ctx, &GetPromptParams{Name: "fail"}); err == nil || !strings.Contains(err.Error(), errTestFailure.Error()) { + t.Errorf("fail returned unexpected error: got %v, want containing %v", err, errTestFailure) + } - t.Run("resource_subscriptions", func(t *testing.T) { - err := cs.Subscribe(ctx, &SubscribeParams{ - URI: "test", - }) - if err != nil { - t.Fatal(err) + s.AddPrompt(&Prompt{Name: "T"}, nil) + waitForNotification(t, "prompts") + s.RemovePrompts("T") + waitForNotification(t, "prompts") } - waitForNotification(t, "subscribe") - s.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{ - URI: "test", - }) - waitForNotification(t, "resource_updated") - err = cs.Unsubscribe(ctx, &UnsubscribeParams{ - URI: "test", - }) - if err != nil { - t.Fatal(err) - } - waitForNotification(t, "unsubscribe") - // Verify the client does not receive the update after unsubscribing. - s.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{ - URI: "test", - }) - select { - case <-notificationChans["resource_updated"]: - t.Fatalf("resource updated after unsubscription") - case <-time.After(time.Second): + // ===== tools ===== + t.Log("Testing tools") + { + // ListTools is tested in client_list_test.go. + gotHi, err := cs.CallTool(ctx, &CallToolParams{ + Name: "greet", + Arguments: map[string]any{"Name": "user"}, + }) + if err != nil { + t.Fatal(err) + } + wantHi := &CallToolResult{ + Content: []Content{ + &TextContent{Text: "hi user"}, + }, + } + if diff := cmp.Diff(wantHi, gotHi, ctrCmpOpts...); diff != "" { + t.Errorf("tools/call 'greet' mismatch (-want +got):\n%s", diff) + } + + gotFail, err := cs.CallTool(ctx, &CallToolParams{ + Name: "fail", + Arguments: map[string]any{}, + }) + // Counter-intuitively, when a tool fails, we don't expect an RPC error for + // call tool: instead, the failure is embedded in the result. + if err != nil { + t.Fatal(err) + } + wantFail := &CallToolResult{ + IsError: true, + Content: []Content{ + &TextContent{Text: errTestFailure.Error()}, + }, + } + if diff := cmp.Diff(wantFail, gotFail, ctrCmpOpts...); diff != "" { + t.Errorf("tools/call 'fail' mismatch (-want +got):\n%s", diff) + } + + // Check output schema validation. + badout := &Tool{ + Name: "badout", + OutputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "x": {Type: "string"}, + }, + }, + } + AddTool(s, badout, func(_ context.Context, _ *CallToolRequest, arg map[string]any) (*CallToolResult, map[string]any, error) { + return nil, map[string]any{"x": 1}, nil + }) + _, err = cs.CallTool(ctx, &CallToolParams{Name: "badout"}) + wantMsg := `has type "integer", want "string"` + if err == nil || !strings.Contains(err.Error(), wantMsg) { + t.Errorf("\ngot %q\nwant error message containing %q", err, wantMsg) + } + + // Check tools-changed notifications. + s.AddTool(&Tool{Name: "T", InputSchema: &jsonschema.Schema{Type: "object"}}, nopHandler) + waitForNotification(t, "tools") + s.RemoveTools("T") + waitForNotification(t, "tools") + } + + // ===== resources ===== + t.Log("Testing resources") + //TODO: fix for Windows + if runtime.GOOS != "windows" { + wantResources := []*Resource{resource2, resource1} + lrres, err := cs.ListResources(ctx, nil) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(wantResources, lrres.Resources); diff != "" { + t.Errorf("resources/list mismatch (-want, +got):\n%s", diff) + } + + template := &ResourceTemplate{ + Name: "rt", + MIMEType: "text/template", + URITemplate: "file:///{+filename}", // the '+' means that filename can contain '/' + } + s.AddResourceTemplate(template, readHandler) + tres, err := cs.ListResourceTemplates(ctx, nil) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff([]*ResourceTemplate{template}, tres.ResourceTemplates); diff != "" { + t.Errorf("resources/list mismatch (-want, +got):\n%s", diff) + } + + for _, tt := range []struct { + uri string + mimeType string // "": not found; "text/plain": resource; "text/template": template + fail bool // non-nil error returned + }{ + {"file:///info.txt", "text/plain", false}, + {"file:///fail.txt", "", false}, + {"file:///template.txt", "text/template", false}, + {"file:///../private.txt", "", true}, // not found: escaping disallowed + } { + rres, err := cs.ReadResource(ctx, &ReadResourceParams{URI: tt.uri}) + if err != nil { + if code := errorCode(err); code == CodeResourceNotFound { + if tt.mimeType != "" { + t.Errorf("%s: not found but expected it to be", tt.uri) + } + } else if !tt.fail { + t.Errorf("%s: unexpected error %v", tt.uri, err) + } + } else { + if tt.fail { + t.Errorf("%s: unexpected success", tt.uri) + } else if g, w := len(rres.Contents), 1; g != w { + t.Errorf("got %d contents, wanted %d", g, w) + } else { + c := rres.Contents[0] + if got := c.URI; got != tt.uri { + t.Errorf("got uri %q, want %q", got, tt.uri) + } + if got := c.MIMEType; got != tt.mimeType { + t.Errorf("%s: got MIME type %q, want %q", tt.uri, got, tt.mimeType) + } + } + } + } + + s.AddResource(&Resource{URI: "http://U"}, nil) + waitForNotification(t, "resources") + s.RemoveResources("http://U") + waitForNotification(t, "resources") + } + + // ===== roots ===== + t.Log("Testing roots") + { + rootRes, err := ss.ListRoots(ctx, &ListRootsParams{}) + if err != nil { + t.Fatal(err) + } + gotRoots := rootRes.Roots + wantRoots := slices.Collect(c.roots.all()) + if diff := cmp.Diff(wantRoots, gotRoots); diff != "" { + t.Errorf("roots/list mismatch (-want +got):\n%s", diff) + } + + c.AddRoots(&Root{URI: "U"}) + waitForNotification(t, "roots") + c.RemoveRoots("U") + waitForNotification(t, "roots") + } + + // ===== sampling ===== + t.Log("Testing sampling") + { + // TODO: test that a client that doesn't have the handler returns CodeUnsupportedMethod. + res, err := ss.CreateMessage(ctx, &CreateMessageParams{}) + if err != nil { + t.Fatal(err) + } + if g, w := res.Model, "aModel"; g != w { + t.Errorf("got %q, want %q", g, w) + } + } + + // ===== logging ===== + t.Log("Testing logging") + { + want := []*LoggingMessageParams{ + { + Logger: "test", + Level: "warning", + Data: map[string]any{ + "msg": "first", + "name": "Pat", + "logtest": true, + }, + }, + { + Logger: "test", + Level: "alert", + Data: map[string]any{ + "msg": "second", + "count": 2.0, + "logtest": true, + }, + }, + } + + check := func(t *testing.T) { + t.Helper() + var got []*LoggingMessageParams + // Read messages from this test until we've seen all we expect. + for len(got) < len(want) { + p := <-loggingMessages + // Ignore logging from other tests. + if m, ok := p.Data.(map[string]any); ok && m["logtest"] != nil { + delete(m, "time") + got = append(got, p) + } + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("mismatch (-want, +got):\n%s", diff) + } + } + + // Use the LoggingMessage method directly. + t.Log("Testing logging (direct)") + { + mustLog := func(level LoggingLevel, data any) { + t.Helper() + if err := ss.Log(ctx, &LoggingMessageParams{ + Logger: "test", + Level: level, + Data: data, + }); err != nil { + t.Fatal(err) + } + } + + // Nothing should be logged until the client sets a level. + mustLog("info", "before") + if err := cs.SetLoggingLevel(ctx, &SetLoggingLevelParams{Level: "warning"}); err != nil { + t.Fatal(err) + } + mustLog("warning", want[0].Data) + mustLog("debug", "nope") // below the level + mustLog("info", "negative") // below the level + mustLog("alert", want[1].Data) + check(t) + } + + // Use the slog handler. + t.Log("Testing logging (handler)") + { + // We can't check the "before SetLevel" behavior because it's already been set. + // Not a big deal: that check is in LoggingMessage anyway. + logger := slog.New(NewLoggingHandler(ss, &LoggingHandlerOptions{LoggerName: "test"})) + logger.Warn("first", "name", "Pat", "logtest", true) + logger.Debug("nope") // below the level + logger.Info("negative") // below the level + logger.Log(ctx, LevelAlert, "second", "count", 2, "logtest", true) + check(t) + } + } + + // ===== progress ===== + t.Log("Testing progress") + { + ss.NotifyProgress(ctx, &ProgressNotificationParams{ + ProgressToken: "token-xyz", + Message: "progress update", + Progress: 0.5, + Total: 2, + }) + waitForNotification(t, "progress_client") + + cs.NotifyProgress(ctx, &ProgressNotificationParams{ + ProgressToken: "token-abc", + Message: "progress update", + Progress: 1, + Total: 2, + }) + waitForNotification(t, "progress_server") + } + + // ===== resource_subscriptions ===== + t.Log("Testing resource_subscriptions") + { + err := cs.Subscribe(ctx, &SubscribeParams{ + URI: "test", + }) + if err != nil { + t.Fatal(err) + } + synctest.Wait() + <-notificationChans["subscribe"] + + s.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{ + URI: "test", + }) + waitForNotification(t, "resource_updated") + + err = cs.Unsubscribe(ctx, &UnsubscribeParams{ + URI: "test", + }) + if err != nil { + t.Fatal(err) + } + waitForNotification(t, "unsubscribe") + + // Verify the client does not receive the update after unsubscribing. + s.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{ + URI: "test", + }) + synctest.Wait() + select { + case <-notificationChans["resource_updated"]: + t.Fatalf("resource updated after unsubscription") + default: + // Expected: no notification received + } + } + + // ===== elicitation ===== + t.Log("Testing elicitation") + { + result, err := ss.Elicit(ctx, &ElicitParams{ + Message: "Please provide information", + }) + if err != nil { + t.Fatal(err) + } + if result.Action != "accept" { + t.Errorf("got action %q, want %q", result.Action, "accept") + } + } + + // Disconnect. + cs.Close() + if err := ss.Wait(); err != nil { + t.Errorf("server failed: %v", err) + } + + // After disconnecting, neither client nor server should have any + // connections. + for range s.Sessions() { + t.Errorf("unexpected client after disconnection") } }) - - t.Run("elicitation", func(t *testing.T) { - result, err := ss.Elicit(ctx, &ElicitParams{ - Message: "Please provide information", - }) - if err != nil { - t.Fatal(err) - } - if result.Action != "accept" { - t.Errorf("got action %q, want %q", result.Action, "accept") - } - }) - - // Disconnect. - cs.Close() - if err := ss.Wait(); err != nil { - t.Errorf("server failed: %v", err) - } - - // After disconnecting, neither client nor server should have any - // connections. - for range s.Sessions() { - t.Errorf("unexpected client after disconnection") - } } // Registry of values to be referenced in tests. @@ -673,75 +699,34 @@ func TestServerClosing(t *testing.T) { } } -// TODO: remove this test when Go 1.24 support is dropped (use go1.25 synctest version). -func TestBatching(t *testing.T) { - ctx := context.Background() - ct, st := NewInMemoryTransports() - - s := NewServer(testImpl, nil) - _, err := s.Connect(ctx, st, nil) - if err != nil { - t.Fatal(err) - } - - c := NewClient(testImpl, nil) - // TODO: this test is broken, because increasing the batch size here causes - // 'initialize' to block. Therefore, we can only test with a size of 1. - // Since batching is being removed, we can probably just delete this. - const batchSize = 1 - cs, err := c.Connect(ctx, ct, nil) - if err != nil { - t.Fatal(err) - } - defer cs.Close() - - errs := make(chan error, batchSize) - for i := range batchSize { - go func() { - _, err := cs.ListTools(ctx, nil) - errs <- err - }() - time.Sleep(2 * time.Millisecond) - if i < batchSize-1 { - select { - case <-errs: - t.Errorf("ListTools: unexpected result for incomplete batch: %v", err) - default: - } - } - } -} - -// TODO: remove this test when Go 1.24 support is dropped (use go1.25 synctest version). func TestCancellation(t *testing.T) { - var ( - start = make(chan struct{}) - cancelled = make(chan struct{}, 1) // don't block the request - ) - slowTool := func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { - start <- struct{}{} - select { - case <-ctx.Done(): - cancelled <- struct{}{} - case <-time.After(5 * time.Second): + synctest.Test(t, func(t *testing.T) { + var ( + start = make(chan struct{}) + cancelled = make(chan struct{}, 1) // don't block the request + ) + slowTool := func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { + start <- struct{}{} + select { + case <-ctx.Done(): + cancelled <- struct{}{} + case <-time.After(5 * time.Second): + return nil, nil, nil + } return nil, nil, nil } - return nil, nil, nil - } - cs, _, cleanup := basicConnection(t, func(s *Server) { - AddTool(s, &Tool{Name: "slow", InputSchema: &jsonschema.Schema{Type: "object"}}, slowTool) - }) - defer cleanup() + cs, _, cleanup := basicConnection(t, func(s *Server) { + AddTool(s, &Tool{Name: "slow", InputSchema: &jsonschema.Schema{Type: "object"}}, slowTool) + }) + defer cleanup() - ctx, cancel := context.WithCancel(context.Background()) - go cs.CallTool(ctx, &CallToolParams{Name: "slow"}) - <-start - cancel() - select { - case <-cancelled: - case <-time.After(5 * time.Second): - t.Fatal("timeout waiting for cancellation") - } + ctx, cancel := context.WithCancel(context.Background()) + go cs.CallTool(ctx, &CallToolParams{Name: "slow"}) + <-start + cancel() + + <-cancelled + }) } func TestMiddleware(t *testing.T) { @@ -906,55 +891,53 @@ func nopHandler(context.Context, *CallToolRequest) (*CallToolResult, error) { return nil, nil } -// TODO: remove this test when Go 1.24 support is dropped (use go1.25 synctest version). func TestKeepAlive(t *testing.T) { - // TODO: try to use the new synctest package for this test once we upgrade to Go 1.24+. - // synctest would allow us to control time and avoid the time.Sleep calls, making the test - // faster and more deterministic. - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() + synctest.Test(t, func(t *testing.T) { + ctx := context.Background() - ct, st := NewInMemoryTransports() + ct, st := NewInMemoryTransports() - serverOpts := &ServerOptions{ - KeepAlive: 100 * time.Millisecond, - } - s := NewServer(testImpl, serverOpts) - AddTool(s, greetTool(), sayHi) + serverOpts := &ServerOptions{ + KeepAlive: 100 * time.Millisecond, + } + s := NewServer(testImpl, serverOpts) + AddTool(s, greetTool(), sayHi) - ss, err := s.Connect(ctx, st, nil) - if err != nil { - t.Fatal(err) - } - defer ss.Close() + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() - clientOpts := &ClientOptions{ - KeepAlive: 100 * time.Millisecond, - } - c := NewClient(testImpl, clientOpts) - cs, err := c.Connect(ctx, ct, nil) - if err != nil { - t.Fatal(err) - } - defer cs.Close() + clientOpts := &ClientOptions{ + KeepAlive: 100 * time.Millisecond, + } + c := NewClient(testImpl, clientOpts) + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() - // Wait for a few keepalive cycles to ensure pings are working - time.Sleep(300 * time.Millisecond) + // Wait for a few keepalive cycles to ensure pings are working. + // With synctest, this advances simulated time instantly. + time.Sleep(300 * time.Millisecond) - // Test that the connection is still alive by making a call - result, err := cs.CallTool(ctx, &CallToolParams{ - Name: "greet", - Arguments: map[string]any{"Name": "user"}, + // Test that the connection is still alive by making a call + result, err := cs.CallTool(ctx, &CallToolParams{ + Name: "greet", + Arguments: map[string]any{"Name": "user"}, + }) + if err != nil { + t.Fatalf("call failed after keepalive: %v", err) + } + if len(result.Content) == 0 { + t.Fatal("expected content in result") + } + if textContent, ok := result.Content[0].(*TextContent); !ok || textContent.Text != "hi user" { + t.Fatalf("unexpected result: %v", result.Content[0]) + } }) - if err != nil { - t.Fatalf("call failed after keepalive: %v", err) - } - if len(result.Content) == 0 { - t.Fatal("expected content in result") - } - if textContent, ok := result.Content[0].(*TextContent); !ok || textContent.Text != "hi user" { - t.Fatalf("unexpected result: %v", result.Content[0]) - } } func TestElicitationUnsupportedMethod(t *testing.T) { @@ -1787,53 +1770,53 @@ func TestElicitationDefaultValues(t *testing.T) { } } -// TODO: remove this test when Go 1.24 support is dropped (use go1.25 synctest version). func TestKeepAliveFailure(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() + synctest.Test(t, func(t *testing.T) { + ctx := context.Background() - ct, st := NewInMemoryTransports() + // This test doesn't pinpoint keepalive detected failures well due to the fact that in-memory transports + // propagate `io.EOF` synchronously, causing the transport level connection to be closed immediately. + // TODO: propose better transports that would allow testing this scenario precisely. + ct, st := NewInMemoryTransports() - // Server without keepalive (to test one-sided keepalive) - s := NewServer(testImpl, nil) - AddTool(s, greetTool(), sayHi) - ss, err := s.Connect(ctx, st, nil) - if err != nil { - t.Fatal(err) - } + // Server without keepalive (to test one-sided keepalive) + s := NewServer(testImpl, nil) + AddTool(s, greetTool(), sayHi) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } - // Client with short keepalive - clientOpts := &ClientOptions{ - KeepAlive: 50 * time.Millisecond, - } - c := NewClient(testImpl, clientOpts) - cs, err := c.Connect(ctx, ct, nil) - if err != nil { - t.Fatal(err) - } - defer cs.Close() + // Client with short keepalive + clientOpts := &ClientOptions{ + KeepAlive: 50 * time.Millisecond, + } + c := NewClient(testImpl, clientOpts) + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() - // Let the connection establish properly first - time.Sleep(30 * time.Millisecond) + // Let the connection establish properly first + synctest.Wait() - // simulate ping failure - ss.Close() + // simulate ping failure + ss.Close() + + time.Sleep(100 * time.Millisecond) + synctest.Wait() - // Wait for keepalive to detect the failure and close the client - // check periodically instead of just waiting - deadline := time.Now().Add(1 * time.Second) - for time.Now().Before(deadline) { _, err = cs.CallTool(ctx, &CallToolParams{ Name: "greet", Arguments: map[string]any{"Name": "user"}, }) - if errors.Is(err, ErrConnectionClosed) { + if err != nil && (errors.Is(err, ErrConnectionClosed) || strings.Contains(err.Error(), "connection closed")) { return // Test passed } - time.Sleep(25 * time.Millisecond) - } - t.Errorf("expected connection to be closed by keepalive, but it wasn't. Last error: %v", err) + t.Errorf("expected connection to be closed by keepalive, but it wasn't. Last error: %v", err) + }) } func TestAddTool_DuplicateNoPanicAndNoDuplicate(t *testing.T) { @@ -1871,109 +1854,112 @@ func TestAddTool_DuplicateNoPanicAndNoDuplicate(t *testing.T) { } } -// TODO: remove this test when Go 1.24 support is dropped (use go1.25 synctest version). func TestSynchronousNotifications(t *testing.T) { - var toolsChanged atomic.Int32 - clientOpts := &ClientOptions{ - ToolListChangedHandler: func(ctx context.Context, req *ToolListChangedRequest) { - toolsChanged.Add(1) - }, - CreateMessageHandler: func(ctx context.Context, req *CreateMessageRequest) (*CreateMessageResult, error) { - // See the comment after "from server" below. - if n := toolsChanged.Load(); n != 1 { - return nil, fmt.Errorf("got %d tools-changed notification, wanted 1", n) + synctest.Test(t, func(t *testing.T) { + var toolsChanged atomic.Int32 + clientOpts := &ClientOptions{ + ToolListChangedHandler: func(ctx context.Context, req *ToolListChangedRequest) { + toolsChanged.Add(1) + }, + CreateMessageHandler: func(ctx context.Context, req *CreateMessageRequest) (*CreateMessageResult, error) { + // See the comment after "from server" below. + if n := toolsChanged.Load(); n != 1 { + return nil, fmt.Errorf("got %d tools-changed notification, wanted 1", n) + } + // TODO(rfindley): investigate the error returned from this test if + // CreateMessageResult is new(CreateMessageResult): it's a mysterious + // unmarshalling error that we should improve. + return &CreateMessageResult{Content: &TextContent{}}, nil + }, + } + client := NewClient(testImpl, clientOpts) + + var rootsChanged atomic.Bool + serverOpts := &ServerOptions{ + RootsListChangedHandler: func(_ context.Context, req *RootsListChangedRequest) { + rootsChanged.Store(true) + }, + } + server := NewServer(testImpl, serverOpts) + addTool := func(s *Server) { + AddTool(s, &Tool{Name: "tool"}, func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { + if !rootsChanged.Load() { + return nil, nil, fmt.Errorf("didn't get root change notification") + } + return new(CallToolResult), nil, nil + }) + } + cs, ss, cleanup := basicClientServerConnection(t, client, server, addTool) + defer cleanup() + + t.Log("from client") + { + client.AddRoots(&Root{Name: "myroot", URI: "file://foo"}) + res, err := cs.CallTool(context.Background(), &CallToolParams{Name: "tool"}) + if err != nil { + t.Fatalf("CallTool failed: %v", err) } - // TODO(rfindley): investigate the error returned from this test if - // CreateMessageResult is new(CreateMessageResult): it's a mysterious - // unmarshalling error that we should improve. - return &CreateMessageResult{Content: &TextContent{}}, nil - }, - } - client := NewClient(testImpl, clientOpts) - - var rootsChanged atomic.Bool - serverOpts := &ServerOptions{ - RootsListChangedHandler: func(_ context.Context, req *RootsListChangedRequest) { - rootsChanged.Store(true) - }, - } - server := NewServer(testImpl, serverOpts) - addTool := func(s *Server) { - AddTool(s, &Tool{Name: "tool"}, func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { - if !rootsChanged.Load() { - return nil, nil, fmt.Errorf("didn't get root change notification") + if res.IsError { + t.Errorf("tool error: %v", res.Content[0].(*TextContent).Text) } - return new(CallToolResult), nil, nil - }) - } - cs, ss, cleanup := basicClientServerConnection(t, client, server, addTool) - defer cleanup() - - t.Run("from client", func(t *testing.T) { - client.AddRoots(&Root{Name: "myroot", URI: "file://foo"}) - res, err := cs.CallTool(context.Background(), &CallToolParams{Name: "tool"}) - if err != nil { - t.Fatalf("CallTool failed: %v", err) - } - if res.IsError { - t.Errorf("tool error: %v", res.Content[0].(*TextContent).Text) - } - }) - - t.Run("from server", func(t *testing.T) { - // Despite all this tool-changed activity, we expect only one notification. - for range 10 { - server.RemoveTools("tool") - addTool(server) } - time.Sleep(notificationDelay * 2) // Wait for delayed notification. - if _, err := ss.CreateMessage(context.Background(), new(CreateMessageParams)); err != nil { - t.Errorf("CreateMessage failed: %v", err) + t.Log("from server") + { + // Despite all this tool-changed activity, we expect only one notification. + for range 10 { + server.RemoveTools("tool") + addTool(server) + } + + time.Sleep(notificationDelay * 2) // Wait for delayed notification. + if _, err := ss.CreateMessage(context.Background(), new(CreateMessageParams)); err != nil { + t.Errorf("CreateMessage failed: %v", err) + } } + }) } -// TODO: remove this test when Go 1.24 support is dropped (use go1.25 synctest version). func TestNoDistributedDeadlock(t *testing.T) { - // This test verifies that calls are asynchronous, and so it's not possible - // to have a distributed deadlock. - // - // The setup creates potential deadlock for both the client and server: the - // client sends a call to tool1, which itself calls createMessage, which in - // turn calls tool2, which calls ping. - // - // If the server were not asynchronous, the call to tool2 would hang. If the - // client were not asynchronous, the call to ping would hang. - // - // Such a scenario is unlikely in practice, but is still theoretically - // possible, and in any case making tool calls asynchronous by default - // delegates synchronization to the user. - clientOpts := &ClientOptions{ - CreateMessageHandler: func(ctx context.Context, req *CreateMessageRequest) (*CreateMessageResult, error) { - req.Session.CallTool(ctx, &CallToolParams{Name: "tool2"}) - return &CreateMessageResult{Content: &TextContent{}}, nil - }, - } - client := NewClient(testImpl, clientOpts) - cs, _, cleanup := basicClientServerConnection(t, client, nil, func(s *Server) { - AddTool(s, &Tool{Name: "tool1"}, func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { - req.Session.CreateMessage(ctx, new(CreateMessageParams)) - return new(CallToolResult), nil, nil + synctest.Test(t, func(t *testing.T) { + // This test verifies that calls are asynchronous, and so it's not possible + // to have a distributed deadlock. + // + // The setup creates potential deadlock for both the client and server: the + // client sends a call to tool1, which itself calls createMessage, which in + // turn calls tool2, which calls ping. + // + // If the server were not asynchronous, the call to tool2 would hang. If the + // client were not asynchronous, the call to ping would hang. + // + // Such a scenario is unlikely in practice, but is still theoretically + // possible, and in any case making tool calls asynchronous by default + // delegates synchronization to the user. + clientOpts := &ClientOptions{ + CreateMessageHandler: func(ctx context.Context, req *CreateMessageRequest) (*CreateMessageResult, error) { + req.Session.CallTool(ctx, &CallToolParams{Name: "tool2"}) + return &CreateMessageResult{Content: &TextContent{}}, nil + }, + } + client := NewClient(testImpl, clientOpts) + cs, _, cleanup := basicClientServerConnection(t, client, nil, func(s *Server) { + AddTool(s, &Tool{Name: "tool1"}, func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { + req.Session.CreateMessage(ctx, new(CreateMessageParams)) + return new(CallToolResult), nil, nil + }) + AddTool(s, &Tool{Name: "tool2"}, func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { + req.Session.Ping(ctx, nil) + return new(CallToolResult), nil, nil + }) }) - AddTool(s, &Tool{Name: "tool2"}, func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { - req.Session.Ping(ctx, nil) - return new(CallToolResult), nil, nil - }) - }) - defer cleanup() + defer cleanup() - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if _, err := cs.CallTool(ctx, &CallToolParams{Name: "tool1"}); err != nil { - // should not deadlock - t.Fatalf("CallTool failed: %v", err) - } + if _, err := cs.CallTool(context.Background(), &CallToolParams{Name: "tool1"}); err != nil { + // should not deadlock + t.Fatalf("CallTool failed: %v", err) + } + }) } var testImpl = &Implementation{Name: "test", Version: "v1.0.0"} diff --git a/mcp/server.go b/mcp/server.go index e3c03e2..00f88c0 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -834,7 +834,7 @@ func (s *Server) lookupResourceHandler(uri string) (ResourceHandler, string, boo // and the current working directory is unavailable, fileResourceHandler panics. // // Lexical path traversal attacks, where the path has ".." elements that escape dir, -// are always caught. Go 1.24 and above also protects against symlink-based attacks, +// are always caught. The SDK also protects against symlink-based attacks, // where symlinks under dir lead out of the tree. func fileResourceHandler(dir string) ResourceHandler { // Convert dir to an absolute path. diff --git a/mcp/transport_example_test.go b/mcp/transport_example_test.go index 62fc031..fa05fc0 100644 --- a/mcp/transport_example_test.go +++ b/mcp/transport_example_test.go @@ -2,9 +2,6 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. -// Uses strings.SplitSeq. -//go:build go1.24 - package mcp_test import ( From 755b9ed4dfe2da1ce054ddd6b2d236cdff797630 Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Mon, 23 Mar 2026 08:35:27 +0100 Subject: [PATCH 230/418] mcp: re-enable race test after fixing data races (#851) 1. (non-test) Don't queue notifications if there are no connected sessions. 2. Change `reconnectInitialDelay` to be atomic, as it is modified in tests. 3. Protect map access with mutex in `TestServerTransportCleanup`. --- .github/workflows/test.yml | 6 ++---- mcp/server.go | 8 ++++++++ mcp/streamable.go | 9 +++++++-- mcp/streamable_client_test.go | 16 ++++++++-------- mcp/streamable_test.go | 11 +++++++++-- 5 files changed, 34 insertions(+), 16 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a6824b8..4c07f0f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: - go-version: "^1.25" + go-version: "^1.26" - name: Check formatting run: | unformatted=$(gofmt -l .) @@ -52,8 +52,6 @@ jobs: run: go test -v ./... race-test: - # Temporarily disable until fixes are prepared. - if: false runs-on: ubuntu-latest steps: - name: Check out code @@ -61,6 +59,6 @@ jobs: - name: Set up Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: - go-version: "1.25" + go-version: "1.26" - name: Test with -race run: go test -v -race ./... diff --git a/mcp/server.go b/mcp/server.go index 00f88c0..c13955c 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -628,6 +628,14 @@ func (s *Server) changeAndNotify(notification string, change func() bool) { s.mu.Lock() defer s.mu.Unlock() if change() && s.shouldSendListChangedNotification(notification) { + if len(s.sessions) == 0 { + if t := s.pendingNotifications[notification]; t != nil { + t.Stop() + s.pendingNotifications[notification] = nil + } + return + } + // Reset the outstanding delayed call, if any. if t := s.pendingNotifications[notification]; t == nil { s.pendingNotifications[notification] = time.AfterFunc(notificationDelay, func() { s.notifySessions(notification) }) diff --git a/mcp/streamable.go b/mcp/streamable.go index 16bca07..7f09b40 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -26,6 +26,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/modelcontextprotocol/go-sdk/auth" @@ -1515,9 +1516,13 @@ var ( // reconnectInitialDelay is the base delay for the first reconnect attempt. // // Mutable for testing. - reconnectInitialDelay = 1 * time.Second + reconnectInitialDelay atomic.Int64 ) +func init() { + reconnectInitialDelay.Store(int64(1 * time.Second)) +} + // Connect implements the [Transport] interface. // // The resulting [Connection] writes messages via POST requests to the @@ -2196,7 +2201,7 @@ func calculateReconnectDelay(attempt int) time.Duration { return 0 } // Calculate the exponential backoff using the grow factor. - backoffDuration := time.Duration(float64(reconnectInitialDelay) * math.Pow(reconnectGrowFactor, float64(attempt-1))) + backoffDuration := time.Duration(float64(reconnectInitialDelay.Load()) * math.Pow(reconnectGrowFactor, float64(attempt-1))) // Cap the backoffDuration at maxDelay. backoffDuration = min(backoffDuration, reconnectMaxDelay) diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index d189ca4..e805aa5 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -446,10 +446,10 @@ func TestStreamableClientResumption_Cancelled(t *testing.T) { // // TODO(#680): experiment with instead using synctest. const tick = 10 * time.Millisecond - defer func(delay time.Duration) { - reconnectInitialDelay = delay - }(reconnectInitialDelay) - reconnectInitialDelay = 2 * tick + defer func(delay int64) { + reconnectInitialDelay.Store(delay) + }(reconnectInitialDelay.Load()) + reconnectInitialDelay.Store(int64(2 * tick)) // The setup: terminate a request stream and make the resumed request hang // indefinitely. CallTool should still exit when its context is canceled. @@ -702,10 +702,10 @@ func TestStreamableClientTransientErrors(t *testing.T) { func TestStreamableClientRetryWithoutProgress(t *testing.T) { // Speed up reconnection delays for testing. const tick = 10 * time.Millisecond - defer func(delay time.Duration) { - reconnectInitialDelay = delay - }(reconnectInitialDelay) - reconnectInitialDelay = tick + defer func(delay int64) { + reconnectInitialDelay.Store(delay) + }(reconnectInitialDelay.Load()) + reconnectInitialDelay.Store(int64(tick)) // Use the fakeStreamableServer pattern like other tests to avoid race conditions. ctx := context.Background() diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index f688a90..d1dc482 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -626,7 +626,10 @@ func TestServerTransportCleanup(t *testing.T) { handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil) handler.onTransportDeletion = func(sessionID string) { - chans[sessionID] <- struct{}{} + mu.Lock() + ch := chans[sessionID] + mu.Unlock() + ch <- struct{}{} } httpServer := httptest.NewServer(mustNotPanic(t, handler)) @@ -658,7 +661,11 @@ func TestServerTransportCleanup(t *testing.T) { t.Cleanup(func() { _ = clientSession.Close() }) } - for _, ch := range chans { + mu.Lock() + channels := slices.Collect(maps.Values(chans)) + mu.Unlock() + + for _, ch := range channels { select { case <-ctx.Done(): t.Errorf("did not capture transport deletion event from all session in 10 seconds") From 150bca797dcc81166c5eb8284bfe8673ae905cc1 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Fri, 27 Mar 2026 11:58:03 -0400 Subject: [PATCH 231/418] mcp: handle empty chunks in MemoryEventStore (#862) When purging chunks, allow empty chunks without panicking. Fixes #860. --- mcp/event.go | 9 +++++---- mcp/event_test.go | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/mcp/event.go b/mcp/event.go index 62dd2ad..80b42f5 100644 --- a/mcp/event.go +++ b/mcp/event.go @@ -376,10 +376,11 @@ func (s *MemoryEventStore) purge() { for _, dl := range sm { if dl.size > 0 { r := dl.removeFirst() - if r > 0 { - changed = true - s.nBytes -= r - } + // Even if we remove an empty chunk, that's + // still progress. There may be non-empty + // chunks after it. + changed = true + s.nBytes -= r } } } diff --git a/mcp/event_test.go b/mcp/event_test.go index 685e648..91ddb9d 100644 --- a/mcp/event_test.go +++ b/mcp/event_test.go @@ -327,6 +327,29 @@ func TestMemoryEventStoreAfter(t *testing.T) { } } +func TestMemoryEventStorePurgeEmpty(t *testing.T) { + ctx := context.Background() + s := NewMemoryEventStore(nil) + s.SetMaxBytes(100) + + // Append an empty chunk first. + if err := s.Append(ctx, "S1", "1", []byte("")); err != nil { + t.Fatal(err) + } + // Append a non-empty chunk. + if err := s.Append(ctx, "S1", "1", []byte("1234567890")); err != nil { + t.Fatal(err) + } + // Now nBytes = 10, but the first chunk is empty. + + // This should not panic. + s.SetMaxBytes(5) + + if s.nBytes > 5 { + t.Errorf("got nBytes %d, want <= 5", s.nBytes) + } +} + func BenchmarkMemoryEventStore(b *testing.B) { // Benchmark with various settings for event store size, number of session, // and payload size. From 05277f6e3c93bac26c8f19e272240eb9f0584bcd Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Mon, 30 Mar 2026 14:52:15 +0200 Subject: [PATCH 232/418] oauthex: use internal JSON library for decoding. (#866) --- internal/json/json.go | 19 ++++++++++++++++--- internal/json/json_test.go | 38 ++++++++++++++++++++++++++++++++++++++ oauthex/oauth2.go | 2 +- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/internal/json/json.go b/internal/json/json.go index 1148770..b4c897f 100644 --- a/internal/json/json.go +++ b/internal/json/json.go @@ -8,12 +8,25 @@ package json import ( "bytes" + "io" "github.com/segmentio/encoding/json" ) +type Decoder struct { + dec *json.Decoder +} + +func NewDecoder(r io.Reader) *Decoder { + dec := json.NewDecoder(r) + dec.DontMatchCaseInsensitiveStructFields() + return &Decoder{dec: dec} +} + +func (d *Decoder) Decode(v any) error { + return d.dec.Decode(v) +} + func Unmarshal(data []byte, v any) error { - dec := json.NewDecoder(bytes.NewReader(data)) - dec.DontMatchCaseInsensitiveStructFields() - return dec.Decode(v) + return NewDecoder(bytes.NewReader(data)).Decode(v) } diff --git a/internal/json/json_test.go b/internal/json/json_test.go index 34d0226..d037e88 100644 --- a/internal/json/json_test.go +++ b/internal/json/json_test.go @@ -8,6 +8,7 @@ import ( "bytes" "encoding/json" "fmt" + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -65,6 +66,43 @@ func TestUnmarshalCaseSensitivity(t *testing.T) { } } +func TestNewDecoderCaseSensitivity(t *testing.T) { + type Target struct { + Field string `json:"field"` + TaggedField string `json:"custom_tag"` + } + + tests := []struct { + name string + json string + want Target + }{ + { + name: "exact match", + json: `{"field": "value", "custom_tag": "tagged"}`, + want: Target{Field: "value", TaggedField: "tagged"}, + }, + { + name: "case mismatch", + json: `{"Field": "value", "Custom_tag": "tagged"}`, + want: Target{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got Target + dec := NewDecoder(strings.NewReader(tt.json)) + if err := dec.Decode(&got); err != nil { + t.Fatalf("Decode failed: %v", err) + } + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("Decode mismatch (-want +got):\n%s", diff) + } + }) + } +} + func TestUnmarshalNullCharacter(t *testing.T) { type Target struct { Field string `json:"field"` diff --git a/oauthex/oauth2.go b/oauthex/oauth2.go index d8aeb3c..82c1c28 100644 --- a/oauthex/oauth2.go +++ b/oauthex/oauth2.go @@ -10,7 +10,6 @@ package oauthex import ( "context" - "encoding/json" "fmt" "io" "mime" @@ -18,6 +17,7 @@ import ( "net/url" "strings" + "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/util" ) From 2d43865b976be72049370950fb010eb3acfe2ac9 Mon Sep 17 00:00:00 2001 From: Pranav RK <39577726+radar07@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:59:30 +0530 Subject: [PATCH 233/418] Enterprise managed authorization (#770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `auth`, `oauthex`: implement Enterprise Managed Authorization (SEP-990) This PR implements Enterprise Managed Authorization (SEP-990) for the Go MCP SDK, enabling MCP Clients and Servers to leverage enterprise Identity Providers for seamless authorization without requiring users to authenticate separately to each MCP Server. Overview Enterprise Managed Authorization follows the Identity Assertion Authorization Grant specification (draft-ietf-oauth-identity-assertion-authz-grant), implementing a three-step flow: 1. **Single Sign-On (SSO)**: User authenticates to the MCP Client via enterprise IdP (Okta, Auth0, Azure AD, etc.) 2. **Token Exchange (RFC 8693)**: Client exchanges ID Token for Identity Assertion JWT Authorization Grant (ID-JAG) at the IdP 3. **JWT Bearer Grant (RFC 7523)**: Client exchanges ID-JAG for Access Token at the MCP Server This enables: - **For end users**: Single sign-on across MCP Clients and Servers—no manual connection/authorization per server - **For enterprise admins**: Centralized visibility and control over which MCP Servers can be used within the organization - **For MCP clients**: Automatic token acquisition without user interaction for each server Closes: [#628](https://github.com/modelcontextprotocol/go-sdk/issues/628) --------- Co-authored-by: Maciej Kisiel --- auth/authorization_code.go | 143 ++--- auth/authorization_code_test.go | 130 +---- auth/client.go | 2 - auth/extauth/enterprise_handler.go | 256 +++++++++ auth/extauth/enterprise_handler_test.go | 497 +++++++++++++++++ auth/extauth/oidc_login.go | 242 +++++++++ auth/extauth/oidc_login_test.go | 505 ++++++++++++++++++ auth/shared.go | 71 +++ auth/shared_test.go | 101 ++++ .../everything-client/client_private.go | 6 +- docs/protocol.md | 74 ++- examples/auth/client/main.go | 7 +- examples/auth/enterprise/main.go | 229 ++++++++ internal/docs/protocol.src.md | 74 ++- oauthex/client.go | 59 ++ oauthex/oauth2.go | 1 + oauthex/token_exchange.go | 185 +++++++ oauthex/token_exchange_test.go | 239 +++++++++ 18 files changed, 2595 insertions(+), 226 deletions(-) create mode 100644 auth/extauth/enterprise_handler.go create mode 100644 auth/extauth/enterprise_handler_test.go create mode 100644 auth/extauth/oidc_login.go create mode 100644 auth/extauth/oidc_login_test.go create mode 100644 auth/shared.go create mode 100644 auth/shared_test.go create mode 100644 examples/auth/enterprise/main.go create mode 100644 oauthex/client.go create mode 100644 oauthex/token_exchange.go create mode 100644 oauthex/token_exchange_test.go diff --git a/auth/authorization_code.go b/auth/authorization_code.go index ac51ea1..1389ec2 100644 --- a/auth/authorization_code.go +++ b/auth/authorization_code.go @@ -11,6 +11,7 @@ import ( "crypto/rand" "errors" "fmt" + "io" "net/http" "net/url" "slices" @@ -20,18 +21,6 @@ import ( "golang.org/x/oauth2" ) -// ClientSecretAuthConfig is used to configure client authentication using client_secret. -// Authentication method will be selected based on the authorization server's supported methods, -// according to the following preference order: -// 1. client_secret_post -// 2. client_secret_basic -type ClientSecretAuthConfig struct { - // ClientID is the client ID to be used for client authentication. - ClientID string - // ClientSecret is the client secret to be used for client authentication. - ClientSecret string -} - // ClientIDMetadataDocumentConfig is used to configure the Client ID Metadata Document // based client registration per // https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#client-id-metadata-documents. @@ -42,14 +31,6 @@ type ClientIDMetadataDocumentConfig struct { URL string } -// PreregisteredClientConfig is used to configure a pre-registered client per -// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration. -// Currently only "client_secret_basic" and "client_secret_post" authentication methods are supported. -type PreregisteredClientConfig struct { - // ClientSecretAuthConfig is the client_secret based configuration to be used for client authentication. - ClientSecretAuthConfig *ClientSecretAuthConfig -} - // DynamicClientRegistrationConfig is used to configure dynamic client registration per // https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#dynamic-client-registration. type DynamicClientRegistrationConfig struct { @@ -67,12 +48,18 @@ type AuthorizationResult struct { State string } -// AuthorizationArgs is the input to [AuthorizationCodeHandlerConfig].AuthorizationCodeFetcher. +// AuthorizationArgs is the input to [AuthorizationCodeFetcher]. type AuthorizationArgs struct { // Authorization URL to be opened in a browser for the user to start the authorization process. URL string } +// AuthorizationCodeFetcher is called to initiate the OAuth authorization flow. +// It is responsible for directing the user to the authorization URL (e.g., opening +// in a browser) and returning the authorization code and state once the Authorization +// Server redirects back to the configured RedirectURL. +type AuthorizationCodeFetcher func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) + // AuthorizationCodeHandlerConfig is the configuration for [AuthorizationCodeHandler]. type AuthorizationCodeHandlerConfig struct { // Client registration configuration. @@ -82,7 +69,7 @@ type AuthorizationCodeHandlerConfig struct { // 3. Dynamic Client Registration // At least one method must be configured. ClientIDMetadataDocumentConfig *ClientIDMetadataDocumentConfig - PreregisteredClientConfig *PreregisteredClientConfig + PreregisteredClient *oauthex.ClientCredentials DynamicClientRegistrationConfig *DynamicClientRegistrationConfig // RedirectURL is a required URL to redirect to after authorization. @@ -97,10 +84,8 @@ type AuthorizationCodeHandlerConfig struct { RedirectURL string // AuthorizationCodeFetcher is a required function called to initiate the authorization flow. - // It is responsible for opening the URL in a browser for the user to start the authorization process. - // It should return the authorization code and state once the Authorization Server - // redirects back to the RedirectURL. - AuthorizationCodeFetcher func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) + // See [AuthorizationCodeFetcher] for details. + AuthorizationCodeFetcher AuthorizationCodeFetcher // Client is an optional HTTP client to use for HTTP requests. // It is used for the following requests: @@ -127,8 +112,6 @@ type AuthorizationCodeHandler struct { var _ OAuthHandler = (*AuthorizationCodeHandler)(nil) -func (h *AuthorizationCodeHandler) isOAuthHandler() {} - func (h *AuthorizationCodeHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) { return h.tokenSource, nil } @@ -141,7 +124,7 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho return nil, errors.New("config must be provided") } if config.ClientIDMetadataDocumentConfig == nil && - config.PreregisteredClientConfig == nil && + config.PreregisteredClient == nil && config.DynamicClientRegistrationConfig == nil { return nil, errors.New("at least one client registration configuration must be provided") } @@ -151,13 +134,9 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho if config.ClientIDMetadataDocumentConfig != nil && !isNonRootHTTPSURL(config.ClientIDMetadataDocumentConfig.URL) { return nil, fmt.Errorf("client ID metadata document URL must be a non-root HTTPS URL") } - preCfg := config.PreregisteredClientConfig - if preCfg != nil { - if preCfg.ClientSecretAuthConfig == nil { - return nil, errors.New("ClientSecretAuthConfig is required for pre-registered client") - } - if preCfg.ClientSecretAuthConfig.ClientID == "" || preCfg.ClientSecretAuthConfig.ClientSecret == "" { - return nil, fmt.Errorf("pre-registered client ID or secret is empty") + if config.PreregisteredClient != nil { + if err := config.PreregisteredClient.Validate(); err != nil { + return nil, fmt.Errorf("invalid PreregisteredClient configuration: %w", err) } } dCfg := config.DynamicClientRegistrationConfig @@ -198,6 +177,7 @@ func isNonRootHTTPSURL(u string) bool { // On success, [AuthorizationCodeHandler.TokenSource] will return a token source with the fetched token. func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error { defer resp.Body.Close() + defer io.Copy(io.Discard, resp.Body) wwwChallenges, err := oauthex.ParseWWWAuthenticate(resp.Header[http.CanonicalHeaderKey("WWW-Authenticate")]) if err != nil { @@ -218,9 +198,20 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ return err } - asm, err := h.getAuthServerMetadata(ctx, prm) + asm, err := GetAuthServerMetadata(ctx, prm.AuthorizationServers[0], h.config.Client) if err != nil { - return err + return fmt.Errorf("failed to get authorization server metadata: %w", err) + } + if asm == nil { + // Fallback to 2025-03-26 spec: predefined endpoints. + // https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery + authServerURL := prm.AuthorizationServers[0] + asm = &oauthex.AuthServerMeta{ + Issuer: authServerURL, + AuthorizationEndpoint: authServerURL + "/authorize", + TokenEndpoint: authServerURL + "/token", + RegistrationEndpoint: authServerURL + "/register", + } } resolvedClientConfig, err := h.handleRegistration(ctx, asm) @@ -365,70 +356,6 @@ func protectedResourceMetadataURLs(metadataURL, resourceURL string) []prmURL { return urls } -// getAuthServerMetadata returns the authorization server metadata. -// The provided Protected Resource Metadata must not be nil and must contain -// at least one authorization server. -// It returns an error if the metadata request fails with non-4xx HTTP status code -// or the fetched metadata fails security checks. -// If no metadata was found, it returns a minimal set of endpoints -// as a fallback to 2025-03-26 spec. -func (h *AuthorizationCodeHandler) getAuthServerMetadata(ctx context.Context, prm *oauthex.ProtectedResourceMetadata) (*oauthex.AuthServerMeta, error) { - authServerURL := prm.AuthorizationServers[0] - for _, u := range authorizationServerMetadataURLs(authServerURL) { - asm, err := oauthex.GetAuthServerMeta(ctx, u, authServerURL, h.config.Client) - if err != nil { - return nil, fmt.Errorf("failed to get authorization server metadata: %w", err) - } - if asm != nil { - return asm, nil - } - } - - // Fallback to 2025-03-26 spec: predefined endpoints. - // https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery - asm := &oauthex.AuthServerMeta{ - Issuer: authServerURL, - AuthorizationEndpoint: authServerURL + "/authorize", - TokenEndpoint: authServerURL + "/token", - RegistrationEndpoint: authServerURL + "/register", - } - return asm, nil -} - -// authorizationServerMetadataURLs returns a list of URLs to try when looking for -// authorization server metadata as mandated by the MCP specification: -// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery. -func authorizationServerMetadataURLs(issuerURL string) []string { - var urls []string - - baseURL, err := url.Parse(issuerURL) - if err != nil { - return nil - } - - if baseURL.Path == "" { - // "OAuth 2.0 Authorization Server Metadata". - baseURL.Path = "/.well-known/oauth-authorization-server" - urls = append(urls, baseURL.String()) - // "OpenID Connect Discovery 1.0". - baseURL.Path = "/.well-known/openid-configuration" - urls = append(urls, baseURL.String()) - return urls - } - - originalPath := baseURL.Path - // "OAuth 2.0 Authorization Server Metadata with path insertion". - baseURL.Path = "/.well-known/oauth-authorization-server/" + strings.TrimLeft(originalPath, "/") - urls = append(urls, baseURL.String()) - // "OpenID Connect Discovery 1.0 with path insertion". - baseURL.Path = "/.well-known/openid-configuration/" + strings.TrimLeft(originalPath, "/") - urls = append(urls, baseURL.String()) - // "OpenID Connect Discovery 1.0 with path appending". - baseURL.Path = "/" + strings.Trim(originalPath, "/") + "/.well-known/openid-configuration" - urls = append(urls, baseURL.String()) - return urls -} - type registrationType int const ( @@ -491,13 +418,17 @@ func (h *AuthorizationCodeHandler) handleRegistration(ctx context.Context, asm * }, nil } // 2. Attempt to use pre-registered client configuration. - pCfg := h.config.PreregisteredClientConfig - if pCfg != nil { + preCfg := h.config.PreregisteredClient + if preCfg != nil { authStyle := selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported) + clientSecret := "" + if preCfg.ClientSecretAuth != nil { + clientSecret = preCfg.ClientSecretAuth.ClientSecret + } return &resolvedClientConfig{ registrationType: registrationTypePreregistered, - clientID: pCfg.ClientSecretAuthConfig.ClientID, - clientSecret: pCfg.ClientSecretAuthConfig.ClientSecret, + clientID: preCfg.ClientID, + clientSecret: clientSecret, authStyle: authStyle, }, nil } diff --git a/auth/authorization_code_test.go b/auth/authorization_code_test.go index d371cba..879031a 100644 --- a/auth/authorization_code_test.go +++ b/auth/authorization_code_test.go @@ -46,9 +46,9 @@ func TestAuthorize(t *testing.T) { handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{ RedirectURL: "http://localhost:12345/callback", - PreregisteredClientConfig: &PreregisteredClientConfig{ - ClientSecretAuthConfig: &ClientSecretAuthConfig{ - ClientID: "test_client_id", + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "test_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ ClientSecret: "test_client_secret", }, }, @@ -154,9 +154,9 @@ func TestNewAuthorizationCodeHandler_Success(t *testing.T) { { name: "PreregisteredClientConfig", config: &AuthorizationCodeHandlerConfig{ - PreregisteredClientConfig: &PreregisteredClientConfig{ - ClientSecretAuthConfig: &ClientSecretAuthConfig{ - ClientID: "test_client_id", + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "test_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ ClientSecret: "test_client_secret", }, }, @@ -223,7 +223,7 @@ func TestNewAuthorizationCodeHandler_Error(t *testing.T) { config: func() *AuthorizationCodeHandlerConfig { cfg := validConfig() cfg.ClientIDMetadataDocumentConfig = nil - cfg.PreregisteredClientConfig = nil + cfg.PreregisteredClient = nil cfg.DynamicClientRegistrationConfig = nil return cfg }, @@ -256,7 +256,7 @@ func TestNewAuthorizationCodeHandler_Error(t *testing.T) { name: "InvalidPreregistered_MissingSecretConfig", config: func() *AuthorizationCodeHandlerConfig { cfg := validConfig() - cfg.PreregisteredClientConfig = &PreregisteredClientConfig{} + cfg.PreregisteredClient = &oauthex.ClientCredentials{} return cfg }, }, @@ -264,8 +264,9 @@ func TestNewAuthorizationCodeHandler_Error(t *testing.T) { name: "InvalidPreregistered_EmptyID", config: func() *AuthorizationCodeHandlerConfig { cfg := validConfig() - cfg.PreregisteredClientConfig = &PreregisteredClientConfig{ - ClientSecretAuthConfig: &ClientSecretAuthConfig{ + cfg.PreregisteredClient = &oauthex.ClientCredentials{ + ClientID: "", + ClientSecretAuth: &oauthex.ClientSecretAuth{ ClientSecret: "secret", }, } @@ -276,9 +277,10 @@ func TestNewAuthorizationCodeHandler_Error(t *testing.T) { name: "InvalidPreregistered_EmptySecret", config: func() *AuthorizationCodeHandlerConfig { cfg := validConfig() - cfg.PreregisteredClientConfig = &PreregisteredClientConfig{ - ClientSecretAuthConfig: &ClientSecretAuthConfig{ - ClientID: "test_client_id", + cfg.PreregisteredClient = &oauthex.ClientCredentials{ + ClientID: "test_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "", }, } return cfg @@ -438,90 +440,6 @@ func TestGetProtectedResourceMetadata_Error(t *testing.T) { } } -func TestGetAuthServerMetadata(t *testing.T) { - handler, err := NewAuthorizationCodeHandler(validConfig()) - if err != nil { - t.Fatalf("NewAuthorizationCodeHandler() error = %v", err) - } - - tests := []struct { - name string - issuerPath string - endpointConfig *oauthtest.MetadataEndpointConfig - }{ - { - name: "OAuthEndpoint_Root", - issuerPath: "", - endpointConfig: &oauthtest.MetadataEndpointConfig{ - ServeOAuthInsertedEndpoint: true, - }, - }, - { - name: "OpenIDEndpoint_Root", - issuerPath: "", - endpointConfig: &oauthtest.MetadataEndpointConfig{ - ServeOpenIDInsertedEndpoint: true, - }, - }, - { - name: "OAuthEndpoint_Path", - issuerPath: "/oauth", - endpointConfig: &oauthtest.MetadataEndpointConfig{ - ServeOAuthInsertedEndpoint: true, - }, - }, - { - name: "OpenIDEndpoint_Path", - issuerPath: "/openid", - endpointConfig: &oauthtest.MetadataEndpointConfig{ - ServeOpenIDInsertedEndpoint: true, - }, - }, - { - name: "OpenIDAppendedEndpoint_Path", - issuerPath: "/openid", - endpointConfig: &oauthtest.MetadataEndpointConfig{ - ServeOpenIDAppendedEndpoint: true, - }, - }, - { - name: "NoMetadata", - issuerPath: "", - endpointConfig: &oauthtest.MetadataEndpointConfig{ - // All metadata endpoints disabled. - ServeOAuthInsertedEndpoint: false, - ServeOpenIDInsertedEndpoint: false, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - s := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ - IssuerPath: tt.issuerPath, - MetadataEndpointConfig: tt.endpointConfig, - }) - s.Start(t) - issuerURL := s.URL() + tt.issuerPath - prm := &oauthex.ProtectedResourceMetadata{ - Resource: "https://example.com/resource", - AuthorizationServers: []string{issuerURL}, - } - - got, err := handler.getAuthServerMetadata(t.Context(), prm) - if err != nil { - t.Fatalf("getAuthServerMetadata() error = %v, want nil", err) - } - if got == nil { - t.Fatal("getAuthServerMetadata() got nil, want metadata") - } - if got.Issuer != issuerURL { - t.Errorf("getAuthServerMetadata() issuer = %q, want %q", got.Issuer, issuerURL) - } - }) - } -} - func TestSelectTokenAuthMethod(t *testing.T) { tests := []struct { name string @@ -587,9 +505,9 @@ func TestHandleRegistration(t *testing.T) { }, }, handlerConfig: &AuthorizationCodeHandlerConfig{ - PreregisteredClientConfig: &PreregisteredClientConfig{ - ClientSecretAuthConfig: &ClientSecretAuthConfig{ - ClientID: "pre_client_id", + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "pre_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ ClientSecret: "pre_client_secret", }, }, @@ -622,11 +540,9 @@ func TestHandleRegistration(t *testing.T) { if err != nil { t.Fatalf("NewAuthorizationCodeHandler() error = %v, want nil", err) } - asm, err := handler.getAuthServerMetadata(t.Context(), &oauthex.ProtectedResourceMetadata{ - AuthorizationServers: []string{s.URL()}, - }) + asm, err := GetAuthServerMetadata(t.Context(), s.URL(), http.DefaultClient) if err != nil { - t.Fatalf("getAuthServerMetadata() error = %v, want nil", err) + t.Fatalf("GetAuthServerMetadata() unexpected error = %v", err) } got, err := handler.handleRegistration(t.Context(), asm) if err != nil { @@ -672,11 +588,9 @@ func TestDynamicRegistration(t *testing.T) { if err != nil { t.Fatalf("NewAuthorizationCodeHandler() error = %v", err) } - asm, err := handler.getAuthServerMetadata(t.Context(), &oauthex.ProtectedResourceMetadata{ - AuthorizationServers: []string{s.URL()}, - }) + asm, err := GetAuthServerMetadata(t.Context(), s.URL(), http.DefaultClient) if err != nil { - t.Fatalf("getAuthServerMetadata() error = %v, want nil", err) + t.Fatalf("GetAuthServerMetadata() unexpected error = %v", err) } got, err := handler.handleRegistration(t.Context(), asm) if err != nil { diff --git a/auth/client.go b/auth/client.go index 0af6963..db32d97 100644 --- a/auth/client.go +++ b/auth/client.go @@ -22,8 +22,6 @@ import ( // [github.com/modelcontextprotocol/go-sdk/mcp.StreamableClientTransport] // for an example. type OAuthHandler interface { - isOAuthHandler() - // TokenSource returns a token source to be used for outgoing requests. // Returned token source might be nil. In that case, the transport will not // add any authorization headers to the request. diff --git a/auth/extauth/enterprise_handler.go b/auth/extauth/enterprise_handler.go new file mode 100644 index 0000000..1d45d4a --- /dev/null +++ b/auth/extauth/enterprise_handler.go @@ -0,0 +1,256 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// Package extauth provides OAuth handler implementations for MCP authorization extensions. +// This package implements Enterprise Managed Authorization as defined in SEP-990. + +//go:build mcp_go_client_oauth + +package extauth + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + + "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/oauthex" + "golang.org/x/oauth2" +) + +// grantTypeJWTBearer is the grant type for RFC 7523 JWT Bearer authorization grant. +const grantTypeJWTBearer = "urn:ietf:params:oauth:grant-type:jwt-bearer" + +// IDTokenFetcher is called to obtain an ID Token from the enterprise IdP. +// This is typically done via OIDC login flow where the user authenticates +// with their enterprise identity provider. +// +// Returns an oauth2.Token where Extra("id_token") contains the OpenID Connect ID Token (JWT). +type IDTokenFetcher func(ctx context.Context) (*oauth2.Token, error) + +// EnterpriseHandlerConfig is the configuration for [EnterpriseHandler]. +type EnterpriseHandlerConfig struct { + // IdP configuration (where the user authenticates) + + // IdPIssuerURL is the enterprise IdP's issuer URL (e.g., "https://acme.okta.com"). + // Used for OIDC discovery to find the token endpoint. + // REQUIRED. + IdPIssuerURL string + + // IdPCredentials contains the MCP Client's credentials registered at the IdP. + // REQUIRED. These credentials are used for token exchange at the IdP. + // The ClientID is always required. ClientSecretAuth is optional and only needed + // if the IdP requires client authentication (confidential clients). + IdPCredentials *oauthex.ClientCredentials + + // MCP Server configuration (the resource being accessed) + + // MCPAuthServerURL is the MCP Server's authorization server issuer URL. + // Used as the audience for token exchange and for metadata discovery. + // REQUIRED. + MCPAuthServerURL string + + // MCPResourceURI is the MCP Server's resource identifier (RFC 9728). + // Used as the resource parameter in token exchange. + // REQUIRED. + MCPResourceURI string + + // MCPCredentials contains the MCP Client's credentials registered at the MCP Server. + // REQUIRED. These credentials are used for JWT Bearer grant at the MCP Server. + // The ClientID is always required. ClientSecretAuth is optional and only needed + // if the MCP Server requires client authentication. + MCPCredentials *oauthex.ClientCredentials + + // MCPScopes is the list of scopes to request at the MCP Server. + // OPTIONAL. + MCPScopes []string + + // IDTokenFetcher is called to obtain an ID Token when authorization is needed. + // The implementation should handle the OIDC login flow (e.g., browser redirect, + // callback handling) and return the ID token. + // REQUIRED. + IDTokenFetcher IDTokenFetcher + + // HTTPClient is an optional HTTP client for customization. + // If nil, http.DefaultClient is used. + // OPTIONAL. + HTTPClient *http.Client +} + +// EnterpriseHandler is an implementation of [auth.OAuthHandler] that uses +// Enterprise Managed Authorization (SEP-990) to obtain access tokens. +// +// The flow consists of: +// 1. OIDC Login: User authenticates with enterprise IdP → ID Token +// 2. Token Exchange (RFC 8693): ID Token → ID-JAG at IdP +// 3. JWT Bearer Grant (RFC 7523): ID-JAG → Access Token at MCP Server +type EnterpriseHandler struct { + config *EnterpriseHandlerConfig + + // tokenSource is the token source obtained after authorization. + tokenSource oauth2.TokenSource +} + +// Compile-time check that EnterpriseHandler implements auth.OAuthHandler. +var _ auth.OAuthHandler = (*EnterpriseHandler)(nil) + +// NewEnterpriseHandler creates a new EnterpriseHandler. +// It performs validation of the configuration and returns an error if invalid. +func NewEnterpriseHandler(config *EnterpriseHandlerConfig) (*EnterpriseHandler, error) { + if config == nil { + return nil, errors.New("config must be provided") + } + if config.IdPIssuerURL == "" { + return nil, errors.New("IdPIssuerURL is required") + } + if config.IdPCredentials == nil { + return nil, errors.New("IdPCredentials is required") + } + if err := config.IdPCredentials.Validate(); err != nil { + return nil, fmt.Errorf("invalid IdPCredentials: %w", err) + } + if config.MCPAuthServerURL == "" { + return nil, errors.New("MCPAuthServerURL is required") + } + if config.MCPResourceURI == "" { + return nil, errors.New("MCPResourceURI is required") + } + if config.MCPCredentials == nil { + return nil, errors.New("MCPCredentials is required") + } + if err := config.MCPCredentials.Validate(); err != nil { + return nil, fmt.Errorf("invalid MCPCredentials: %w", err) + } + if config.IDTokenFetcher == nil { + return nil, errors.New("IDTokenFetcher is required") + } + return &EnterpriseHandler{config: config}, nil +} + +// TokenSource returns the token source for outgoing requests. +// Returns nil if authorization has not been performed yet. +func (h *EnterpriseHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) { + return h.tokenSource, nil +} + +// Authorize performs the Enterprise Managed Authorization flow. +// It is called when a request fails with 401 or 403. +func (h *EnterpriseHandler) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error { + defer resp.Body.Close() + defer io.Copy(io.Discard, resp.Body) + + httpClient := h.config.HTTPClient + if httpClient == nil { + httpClient = http.DefaultClient + } + + // Step 1: Get ID Token via the configured fetcher (e.g., OIDC login) + oidcToken, err := h.config.IDTokenFetcher(ctx) + if err != nil { + return fmt.Errorf("failed to obtain ID token: %w", err) + } + + // Extract ID token from the oauth2.Token + idToken, ok := oidcToken.Extra("id_token").(string) + if !ok || idToken == "" { + return fmt.Errorf("id_token not found in OIDC token response") + } + + // Step 2: Discover IdP token endpoint via OIDC discovery + idpMeta, err := auth.GetAuthServerMetadata(ctx, h.config.IdPIssuerURL, httpClient) + if err != nil { + return fmt.Errorf("failed to discover IdP metadata: %w", err) + } + if idpMeta == nil { + return fmt.Errorf("no authorization server metadata found for IdP: %s", h.config.IdPIssuerURL) + } + + // Step 3: Token Exchange (ID Token → ID-JAG) + tokenExchangeReq := &oauthex.TokenExchangeRequest{ + RequestedTokenType: oauthex.TokenTypeIDJAG, + Audience: h.config.MCPAuthServerURL, + Resource: h.config.MCPResourceURI, + Scope: h.config.MCPScopes, + SubjectToken: idToken, + SubjectTokenType: oauthex.TokenTypeIDToken, + } + + idJAGToken, err := oauthex.ExchangeToken( + ctx, + idpMeta.TokenEndpoint, + tokenExchangeReq, + h.config.IdPCredentials, + httpClient, + ) + if err != nil { + return fmt.Errorf("token exchange failed: %w", err) + } + + // Step 4: Discover MCP Server token endpoint + mcpMeta, err := auth.GetAuthServerMetadata(ctx, h.config.MCPAuthServerURL, httpClient) + if err != nil { + return fmt.Errorf("failed to discover MCP auth server metadata: %w", err) + } + if mcpMeta == nil { + return fmt.Errorf("no authorization server metadata found for MCP server: %s", h.config.MCPAuthServerURL) + } + + // Step 5: JWT Bearer Grant (ID-JAG → Access Token) + // The ID-JAG is in the AccessToken field of the token (despite the name) + accessToken, err := exchangeJWTBearer( + ctx, + mcpMeta.TokenEndpoint, + idJAGToken.AccessToken, + h.config.MCPCredentials, + httpClient, + ) + if err != nil { + return fmt.Errorf("JWT bearer grant failed: %w", err) + } + + // Store the token source for subsequent requests + h.tokenSource = oauth2.StaticTokenSource(accessToken) + return nil +} + +// exchangeJWTBearer exchanges an Identity Assertion JWT Authorization Grant (ID-JAG) +// for an access token using JWT Bearer Grant per RFC 7523. +func exchangeJWTBearer( + ctx context.Context, + tokenEndpoint string, + assertion string, + clientCreds *oauthex.ClientCredentials, + httpClient *http.Client, +) (*oauth2.Token, error) { + cfg := &oauth2.Config{ + ClientID: clientCreds.ClientID, + Endpoint: oauth2.Endpoint{ + TokenURL: tokenEndpoint, + AuthStyle: oauth2.AuthStyleInParams, + }, + } + // Set ClientSecret if ClientSecretAuth is configured + if clientCreds.ClientSecretAuth != nil { + cfg.ClientSecret = clientCreds.ClientSecretAuth.ClientSecret + } + + if httpClient == nil { + httpClient = http.DefaultClient + } + ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient) + + token, err := cfg.Exchange( + ctxWithClient, + "", + oauth2.SetAuthURLParam("grant_type", grantTypeJWTBearer), + oauth2.SetAuthURLParam("assertion", assertion), + ) + if err != nil { + return nil, fmt.Errorf("JWT bearer grant request failed: %w", err) + } + + return token, nil +} diff --git a/auth/extauth/enterprise_handler_test.go b/auth/extauth/enterprise_handler_test.go new file mode 100644 index 0000000..08c042b --- /dev/null +++ b/auth/extauth/enterprise_handler_test.go @@ -0,0 +1,497 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +//go:build mcp_go_client_oauth + +package extauth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/oauthex" + "golang.org/x/oauth2" +) + +// TestNewEnterpriseHandler_Validation tests validation in NewEnterpriseHandler. +func TestNewEnterpriseHandler_Validation(t *testing.T) { + validConfig := &EnterpriseHandlerConfig{ + IdPIssuerURL: "https://idp.example.com", + IdPCredentials: &oauthex.ClientCredentials{ + ClientID: "idp_client_id", + }, + MCPAuthServerURL: "https://mcp-auth.example.com", + MCPResourceURI: "https://mcp.example.com", + MCPCredentials: &oauthex.ClientCredentials{ + ClientID: "mcp_client_id", + }, + IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { + token := &oauth2.Token{ + AccessToken: "mock_access_token", + TokenType: "Bearer", + } + return token.WithExtra(map[string]interface{}{"id_token": "mock_id_token"}), nil + }, + } + + tests := []struct { + name string + config *EnterpriseHandlerConfig + wantError string + }{ + { + name: "nil config", + config: nil, + wantError: "config must be provided", + }, + { + name: "missing IdPIssuerURL", + config: &EnterpriseHandlerConfig{ + IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, + MCPAuthServerURL: "https://mcp-auth.example.com", + MCPResourceURI: "https://mcp.example.com", + MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, + IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil }, + }, + wantError: "IdPIssuerURL is required", + }, + { + name: "nil IdPCredentials", + config: &EnterpriseHandlerConfig{ + IdPIssuerURL: "https://idp.example.com", + IdPCredentials: nil, + MCPAuthServerURL: "https://mcp-auth.example.com", + MCPResourceURI: "https://mcp.example.com", + MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, + IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil }, + }, + wantError: "IdPCredentials is required", + }, + { + name: "invalid IdPCredentials - empty ClientID", + config: &EnterpriseHandlerConfig{ + IdPIssuerURL: "https://idp.example.com", + IdPCredentials: &oauthex.ClientCredentials{ + ClientID: "", // Invalid - empty + }, + MCPAuthServerURL: "https://mcp-auth.example.com", + MCPResourceURI: "https://mcp.example.com", + MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, + IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil }, + }, + wantError: "invalid IdPCredentials", + }, + { + name: "invalid IdPCredentials - empty ClientSecret in ClientSecretAuth", + config: &EnterpriseHandlerConfig{ + IdPIssuerURL: "https://idp.example.com", + IdPCredentials: &oauthex.ClientCredentials{ + ClientID: "idp_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "", // Invalid - empty secret when ClientSecretAuth is set + }, + }, + MCPAuthServerURL: "https://mcp-auth.example.com", + MCPResourceURI: "https://mcp.example.com", + MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, + IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil }, + }, + wantError: "invalid IdPCredentials", + }, + { + name: "missing MCPAuthServerURL", + config: &EnterpriseHandlerConfig{ + IdPIssuerURL: "https://idp.example.com", + IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, + MCPAuthServerURL: "", + MCPResourceURI: "https://mcp.example.com", + MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, + IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil }, + }, + wantError: "MCPAuthServerURL is required", + }, + { + name: "missing MCPResourceURI", + config: &EnterpriseHandlerConfig{ + IdPIssuerURL: "https://idp.example.com", + IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, + MCPAuthServerURL: "https://mcp-auth.example.com", + MCPResourceURI: "", + MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, + IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil }, + }, + wantError: "MCPResourceURI is required", + }, + { + name: "nil MCPCredentials", + config: &EnterpriseHandlerConfig{ + IdPIssuerURL: "https://idp.example.com", + IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, + MCPAuthServerURL: "https://mcp-auth.example.com", + MCPResourceURI: "https://mcp.example.com", + MCPCredentials: nil, + IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil }, + }, + wantError: "MCPCredentials is required", + }, + { + name: "invalid MCPCredentials - empty ClientID", + config: &EnterpriseHandlerConfig{ + IdPIssuerURL: "https://idp.example.com", + IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, + MCPAuthServerURL: "https://mcp-auth.example.com", + MCPResourceURI: "https://mcp.example.com", + MCPCredentials: &oauthex.ClientCredentials{ + ClientID: "", // Invalid - empty + }, + IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil }, + }, + wantError: "invalid MCPCredentials", + }, + { + name: "missing IDTokenFetcher", + config: &EnterpriseHandlerConfig{ + IdPIssuerURL: "https://idp.example.com", + IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, + MCPAuthServerURL: "https://mcp-auth.example.com", + MCPResourceURI: "https://mcp.example.com", + MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, + IDTokenFetcher: nil, + }, + wantError: "IDTokenFetcher is required", + }, + { + name: "valid config - public clients (no ClientSecretAuth)", + config: validConfig, + wantError: "", + }, + { + name: "valid config - confidential clients (with ClientSecretAuth)", + config: &EnterpriseHandlerConfig{ + IdPIssuerURL: "https://idp.example.com", + IdPCredentials: &oauthex.ClientCredentials{ + ClientID: "idp_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "idp_secret", + }, + }, + MCPAuthServerURL: "https://mcp-auth.example.com", + MCPResourceURI: "https://mcp.example.com", + MCPCredentials: &oauthex.ClientCredentials{ + ClientID: "mcp_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "mcp_secret", + }, + }, + IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { + token := &oauth2.Token{ + AccessToken: "mock_access_token", + TokenType: "Bearer", + } + return token.WithExtra(map[string]interface{}{"id_token": "mock_id_token"}), nil + }, + }, + wantError: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler, err := NewEnterpriseHandler(tt.config) + if tt.wantError != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantError) + } + if !strings.Contains(err.Error(), tt.wantError) { + t.Fatalf("expected error containing %q, got %v", tt.wantError, err) + } + } else { + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if handler == nil { + t.Fatal("expected handler to be non-nil") + } + } + }) + } +} + +// TestEnterpriseHandler_Authorize_E2E tests the complete enterprise authorization flow. +func TestEnterpriseHandler_Authorize_E2E(t *testing.T) { + // Set up IdP (Identity Provider) fake server with token exchange support + idpServer := setupIdPServer(t) + + // Set up MCP authorization server with JWT bearer grant support + mcpAuthServer := setupMCPAuthServer(t) + + // Create enterprise handler + handler, err := NewEnterpriseHandler(&EnterpriseHandlerConfig{ + IdPIssuerURL: idpServer.URL, + IdPCredentials: &oauthex.ClientCredentials{ + ClientID: "idp_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "idp_secret", + }, + }, + MCPAuthServerURL: mcpAuthServer.URL, + MCPResourceURI: "https://mcp.example.com", + MCPCredentials: &oauthex.ClientCredentials{ + ClientID: "mcp_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "mcp_secret", + }, + }, + MCPScopes: []string{"read", "write"}, + IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { + token := &oauth2.Token{ + AccessToken: "mock_access_token", + TokenType: "Bearer", + } + return token.WithExtra(map[string]interface{}{"id_token": "mock_id_token_from_user_login"}), nil + }, + }) + if err != nil { + t.Fatalf("NewEnterpriseHandler failed: %v", err) + } + + // Simulate a 401 response from MCP server + req := httptest.NewRequest(http.MethodGet, "https://mcp.example.com/api", nil) + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: http.NoBody, + Request: req, + } + + // Perform authorization + if err := handler.Authorize(context.Background(), req, resp); err != nil { + t.Fatalf("Authorize failed: %v", err) + } + + // Verify token source is set + tokenSource, err := handler.TokenSource(context.Background()) + if err != nil { + t.Fatalf("TokenSource failed: %v", err) + } + if tokenSource == nil { + t.Fatal("expected token source to be set after authorization") + } + + // Verify we can get a token + token, err := tokenSource.Token() + if err != nil { + t.Fatalf("Token() failed: %v", err) + } + if token.AccessToken != "mcp_access_token_from_jwt_bearer" { + t.Errorf("unexpected access token: got %q, want %q", + token.AccessToken, "mcp_access_token_from_jwt_bearer") + } +} + +// setupIdPServer creates a fake IdP server that supports token exchange. +func setupIdPServer(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + + var server *httptest.Server + + // OAuth/OIDC metadata endpoint - uses closure to get server URL + mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "issuer": server.URL, + "token_endpoint": server.URL + "/token", + "authorization_endpoint": server.URL + "/authorize", + "code_challenge_methods_supported": []string{"S256"}, + }) + }) + + // Token endpoint - supports token exchange (RFC 8693) + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "failed to parse form", http.StatusBadRequest) + return + } + + grantType := r.Form.Get("grant_type") + if grantType != oauthex.GrantTypeTokenExchange { + http.Error(w, fmt.Sprintf("unsupported grant_type: %s", grantType), http.StatusBadRequest) + return + } + + // Verify client authentication + clientID := r.Form.Get("client_id") + clientSecret := r.Form.Get("client_secret") + if clientID != "idp_client_id" || clientSecret != "idp_secret" { + http.Error(w, "invalid client credentials", http.StatusUnauthorized) + return + } + + // Verify token exchange parameters + if r.Form.Get("requested_token_type") != oauthex.TokenTypeIDJAG { + http.Error(w, "invalid requested_token_type", http.StatusBadRequest) + return + } + if r.Form.Get("subject_token_type") != oauthex.TokenTypeIDToken { + http.Error(w, "invalid subject_token_type", http.StatusBadRequest) + return + } + if r.Form.Get("subject_token") == "" { + http.Error(w, "missing subject_token", http.StatusBadRequest) + return + } + + // Return ID-JAG (Identity Assertion JWT Authorization Grant) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "id-jag-token-from-idp", + "issued_token_type": oauthex.TokenTypeIDJAG, + "token_type": "N_A", + "expires_in": 300, + "scope": "read write", + }) + }) + + server = httptest.NewServer(mux) + t.Cleanup(server.Close) + + return server +} + +// setupMCPAuthServer creates a fake MCP authorization server that supports JWT bearer grant. +func setupMCPAuthServer(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + + var server *httptest.Server + + // OAuth metadata endpoint - uses closure to get server URL + mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "issuer": server.URL, + "token_endpoint": server.URL + "/token", + "code_challenge_methods_supported": []string{"S256"}, + }) + }) + + // Token endpoint - supports JWT bearer grant (RFC 7523) + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "failed to parse form", http.StatusBadRequest) + return + } + + grantType := r.Form.Get("grant_type") + if grantType != "urn:ietf:params:oauth:grant-type:jwt-bearer" { + http.Error(w, fmt.Sprintf("unsupported grant_type: %s", grantType), http.StatusBadRequest) + return + } + + // Verify client authentication + clientID := r.Form.Get("client_id") + clientSecret := r.Form.Get("client_secret") + if clientID != "mcp_client_id" || clientSecret != "mcp_secret" { + http.Error(w, "invalid client credentials", http.StatusUnauthorized) + return + } + + // Verify assertion (ID-JAG) + assertion := r.Form.Get("assertion") + if assertion != "id-jag-token-from-idp" { + http.Error(w, "invalid assertion", http.StatusBadRequest) + return + } + + // Return access token + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "mcp_access_token_from_jwt_bearer", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "read write", + }) + }) + + server = httptest.NewServer(mux) + t.Cleanup(server.Close) + + return server +} + +// TestEnterpriseHandler_Authorize_IDTokenFetcherError tests error handling when IDTokenFetcher fails. +func TestEnterpriseHandler_Authorize_IDTokenFetcherError(t *testing.T) { + handler, err := NewEnterpriseHandler(&EnterpriseHandlerConfig{ + IdPIssuerURL: "https://idp.example.com", + IdPCredentials: &oauthex.ClientCredentials{ + ClientID: "idp_client_id", + }, + MCPAuthServerURL: "https://mcp-auth.example.com", + MCPResourceURI: "https://mcp.example.com", + MCPCredentials: &oauthex.ClientCredentials{ + ClientID: "mcp_client_id", + }, + IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { + return nil, fmt.Errorf("user cancelled login") + }, + }) + if err != nil { + t.Fatalf("NewEnterpriseHandler failed: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "https://mcp.example.com/api", nil) + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: http.NoBody, + Request: req, + } + + err = handler.Authorize(context.Background(), req, resp) + if err == nil { + t.Fatal("expected error from Authorize, got nil") + } + if !strings.Contains(err.Error(), "failed to obtain ID token") { + t.Errorf("expected error about ID token, got: %v", err) + } +} + +// TestEnterpriseHandler_TokenSource_BeforeAuthorization tests TokenSource before authorization. +func TestEnterpriseHandler_TokenSource_BeforeAuthorization(t *testing.T) { + handler, err := NewEnterpriseHandler(&EnterpriseHandlerConfig{ + IdPIssuerURL: "https://idp.example.com", + IdPCredentials: &oauthex.ClientCredentials{ + ClientID: "idp_client_id", + }, + MCPAuthServerURL: "https://mcp-auth.example.com", + MCPResourceURI: "https://mcp.example.com", + MCPCredentials: &oauthex.ClientCredentials{ + ClientID: "mcp_client_id", + }, + IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { + token := &oauth2.Token{ + AccessToken: "mock_access_token", + TokenType: "Bearer", + } + return token.WithExtra(map[string]interface{}{"id_token": "mock_id_token"}), nil + }, + }) + if err != nil { + t.Fatalf("NewEnterpriseHandler failed: %v", err) + } + + tokenSource, err := handler.TokenSource(context.Background()) + if err != nil { + t.Fatalf("TokenSource failed: %v", err) + } + if tokenSource != nil { + t.Errorf("expected nil token source before authorization, got %v", tokenSource) + } +} diff --git a/auth/extauth/oidc_login.go b/auth/extauth/oidc_login.go new file mode 100644 index 0000000..848f020 --- /dev/null +++ b/auth/extauth/oidc_login.go @@ -0,0 +1,242 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// This file implements OIDC Authorization Code flow for obtaining ID tokens +// as part of Enterprise Managed Authorization (SEP-990). +// See https://openid.net/specs/openid-connect-core-1_0.html + +//go:build mcp_go_client_oauth + +package extauth + +import ( + "context" + "crypto/rand" + "fmt" + "net/http" + "net/url" + "slices" + "strings" + + "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/oauthex" + "golang.org/x/oauth2" +) + +// OIDCLoginConfig configures the OIDC Authorization Code flow for obtaining +// an ID Token. This is used with [PerformOIDCLogin] to authenticate users +// with an enterprise IdP before calling the Enterprise Managed Authorization flow. +type OIDCLoginConfig struct { + // IssuerURL is the IdP's issuer URL (e.g., "https://acme.okta.com"). + // REQUIRED. + IssuerURL string + // Credentials contains the MCP Client's credentials registered at the IdP. + // The ClientID field is REQUIRED. The ClientSecret field is OPTIONAL + // (only required if the client is confidential, not a public client). + // REQUIRED (struct itself), but ClientSecret field can be empty. + Credentials *oauthex.ClientCredentials + // RedirectURL is the OAuth2 redirect URI registered with the IdP. + // This must match exactly what was registered with the IdP. + // REQUIRED. + RedirectURL string + // Scopes are the OAuth2/OIDC scopes to request. + // "openid" is REQUIRED for OIDC. Common values: ["openid", "profile", "email"] + // REQUIRED. + Scopes []string + // LoginHint is a hint to the IdP about the user's identity. + // Some IdPs may require this (e.g., as an email address for routing to SSO providers). + // Example: "user@example.com" + // OPTIONAL. + LoginHint string + // HTTPClient is the HTTP client for making requests. + // If nil, http.DefaultClient is used. + // OPTIONAL. + HTTPClient *http.Client +} + +// PerformOIDCLogin performs the complete OIDC Authorization Code flow with PKCE +// in a single function call. This is the recommended approach for obtaining an +// ID Token for use with [EnterpriseHandler]. +// +// Returns an oauth2.Token where: +// - Extra("id_token") contains the OpenID Connect ID Token (JWT) +// - AccessToken contains the OAuth2 access token (if issued by IdP) +// - RefreshToken contains the OAuth2 refresh token (if issued by IdP) +// - TokenType is the token type (typically "Bearer") +// - Expiry is when the token expires +func PerformOIDCLogin( + ctx context.Context, + config *OIDCLoginConfig, + authCodeFetcher auth.AuthorizationCodeFetcher, +) (*oauth2.Token, error) { + if authCodeFetcher == nil { + return nil, fmt.Errorf("authCodeFetcher is required") + } + + authReq, oauth2Config, err := initiateOIDCLogin(ctx, config) + if err != nil { + return nil, fmt.Errorf("failed to initiate OIDC login: %w", err) + } + + authResult, err := authCodeFetcher(ctx, &auth.AuthorizationArgs{URL: authReq.authURL}) + if err != nil { + return nil, fmt.Errorf("failed to fetch authorization code: %w", err) + } + + if authResult.State != authReq.state { + return nil, fmt.Errorf("state mismatch: expected %q, got %q", authReq.state, authResult.State) + } + + tokens, err := completeOIDCLogin(ctx, config, oauth2Config, authResult.Code, authReq.codeVerifier) + if err != nil { + return nil, fmt.Errorf("failed to complete OIDC login: %w", err) + } + + return tokens, nil +} + +// oidcAuthorizationRequest holds internal state for OIDC authorization. +type oidcAuthorizationRequest struct { + authURL string + state string + codeVerifier string +} + +// initiateOIDCLogin initiates an OIDC Authorization Code flow with PKCE. +func initiateOIDCLogin( + ctx context.Context, + config *OIDCLoginConfig, +) (*oidcAuthorizationRequest, *oauth2.Config, error) { + if config == nil { + return nil, nil, fmt.Errorf("config is required") + } + if config.IssuerURL == "" { + return nil, nil, fmt.Errorf("IssuerURL is required") + } + if config.Credentials == nil || config.Credentials.ClientID == "" { + return nil, nil, fmt.Errorf("Credentials.ClientID is required") + } + if config.RedirectURL == "" { + return nil, nil, fmt.Errorf("RedirectURL is required") + } + if len(config.Scopes) == 0 { + return nil, nil, fmt.Errorf("Scopes is required (must include 'openid')") + } + + if !slices.Contains(config.Scopes, "openid") { + return nil, nil, fmt.Errorf("Scopes must include 'openid' for OIDC") + } + + if err := checkURLScheme(config.IssuerURL); err != nil { + return nil, nil, fmt.Errorf("invalid IssuerURL: %w", err) + } + if err := checkURLScheme(config.RedirectURL); err != nil { + return nil, nil, fmt.Errorf("invalid RedirectURL: %w", err) + } + + httpClient := config.HTTPClient + if httpClient == nil { + httpClient = http.DefaultClient + } + meta, err := auth.GetAuthServerMetadata(ctx, config.IssuerURL, httpClient) + if err != nil { + return nil, nil, fmt.Errorf("failed to discover OIDC metadata: %w", err) + } + if meta == nil { + return nil, nil, fmt.Errorf("no authorization server metadata found for OIDC issuer: %s", config.IssuerURL) + } + if meta.AuthorizationEndpoint == "" { + return nil, nil, fmt.Errorf("authorization_endpoint not found in OIDC metadata") + } + + codeVerifier := oauth2.GenerateVerifier() + state := rand.Text() + + oauth2Config := &oauth2.Config{ + ClientID: config.Credentials.ClientID, + RedirectURL: config.RedirectURL, + Scopes: config.Scopes, + Endpoint: oauth2.Endpoint{ + AuthURL: meta.AuthorizationEndpoint, + TokenURL: meta.TokenEndpoint, + }, + } + // Set ClientSecret if ClientSecretAuth is configured + if config.Credentials.ClientSecretAuth != nil { + oauth2Config.ClientSecret = config.Credentials.ClientSecretAuth.ClientSecret + } + + authURLOpts := []oauth2.AuthCodeOption{ + oauth2.S256ChallengeOption(codeVerifier), + } + if config.LoginHint != "" { + authURLOpts = append(authURLOpts, oauth2.SetAuthURLParam("login_hint", config.LoginHint)) + } + authURL := oauth2Config.AuthCodeURL(state, authURLOpts...) + + return &oidcAuthorizationRequest{ + authURL: authURL, + state: state, + codeVerifier: codeVerifier, + }, oauth2Config, nil +} + +// completeOIDCLogin completes the OIDC Authorization Code flow by exchanging +// the authorization code for tokens. +func completeOIDCLogin( + ctx context.Context, + config *OIDCLoginConfig, + oauth2Config *oauth2.Config, + authCode string, + codeVerifier string, +) (*oauth2.Token, error) { + if authCode == "" { + return nil, fmt.Errorf("authCode is required") + } + if codeVerifier == "" { + return nil, fmt.Errorf("codeVerifier is required") + } + + httpClient := config.HTTPClient + if httpClient == nil { + httpClient = http.DefaultClient + } + ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient) + + oauth2Token, err := oauth2Config.Exchange( + ctxWithClient, + authCode, + oauth2.VerifierOption(codeVerifier), + ) + if err != nil { + return nil, fmt.Errorf("token exchange failed: %w", err) + } + + // Validate that id_token is present in the response + idToken, ok := oauth2Token.Extra("id_token").(string) + if !ok || idToken == "" { + return nil, fmt.Errorf("id_token not found in token response") + } + + return oauth2Token, nil +} + +// checkURLScheme ensures that its argument is a valid URL with a scheme +// that prevents XSS attacks. +// See #526. +// Note: a copy of this function exists in oauthex/oauth2.go; keep these in sync. +func checkURLScheme(u string) error { + if u == "" { + return nil + } + uu, err := url.Parse(u) + if err != nil { + return err + } + scheme := strings.ToLower(uu.Scheme) + if scheme == "javascript" || scheme == "data" || scheme == "vbscript" { + return fmt.Errorf("URL has disallowed scheme %q", scheme) + } + return nil +} diff --git a/auth/extauth/oidc_login_test.go b/auth/extauth/oidc_login_test.go new file mode 100644 index 0000000..298158b --- /dev/null +++ b/auth/extauth/oidc_login_test.go @@ -0,0 +1,505 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +//go:build mcp_go_client_oauth + +package extauth + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/oauthex" +) + +// TestInitiateOIDCLogin tests the OIDC authorization request generation. +func TestInitiateOIDCLogin(t *testing.T) { + // Create mock IdP server + idpServer := createMockOIDCServer(t) + defer idpServer.Close() + config := &OIDCLoginConfig{ + IssuerURL: idpServer.URL, + Credentials: &oauthex.ClientCredentials{ + ClientID: "test-client", + }, + RedirectURL: "http://localhost:8080/callback", + Scopes: []string{"openid", "profile", "email"}, + HTTPClient: idpServer.Client(), + } + t.Run("successful initiation", func(t *testing.T) { + authReq, _, err := initiateOIDCLogin(context.Background(), config) + if err != nil { + t.Fatalf("initiateOIDCLogin failed: %v", err) + } + // Validate authURL + if authReq.authURL == "" { + t.Error("authURL is empty") + } + // Parse and validate URL parameters + u, err := url.Parse(authReq.authURL) + if err != nil { + t.Fatalf("Failed to parse authURL: %v", err) + } + q := u.Query() + if q.Get("response_type") != "code" { + t.Errorf("expected response_type 'code', got '%s'", q.Get("response_type")) + } + if q.Get("client_id") != "test-client" { + t.Errorf("expected client_id 'test-client', got '%s'", q.Get("client_id")) + } + if q.Get("redirect_uri") != "http://localhost:8080/callback" { + t.Errorf("expected redirect_uri 'http://localhost:8080/callback', got '%s'", q.Get("redirect_uri")) + } + if q.Get("scope") != "openid profile email" { + t.Errorf("expected scope 'openid profile email', got '%s'", q.Get("scope")) + } + if q.Get("code_challenge_method") != "S256" { + t.Errorf("expected code_challenge_method 'S256', got '%s'", q.Get("code_challenge_method")) + } + // Validate state is generated + if authReq.state == "" { + t.Error("state is empty") + } + if q.Get("state") != authReq.state { + t.Errorf("state in URL doesn't match returned state") + } + // Validate PKCE parameters + if authReq.codeVerifier == "" { + t.Error("codeVerifier is empty") + } + if q.Get("code_challenge") == "" { + t.Error("code_challenge is empty") + } + }) + t.Run("with login_hint", func(t *testing.T) { + configWithHint := *config + configWithHint.LoginHint = "user@example.com" + authReq, _, err := initiateOIDCLogin(context.Background(), &configWithHint) + if err != nil { + t.Fatalf("initiateOIDCLogin failed: %v", err) + } + u, err := url.Parse(authReq.authURL) + if err != nil { + t.Fatalf("Failed to parse authURL: %v", err) + } + q := u.Query() + if q.Get("login_hint") != "user@example.com" { + t.Errorf("expected login_hint 'user@example.com', got '%s'", q.Get("login_hint")) + } + }) + t.Run("without login_hint", func(t *testing.T) { + authReq, _, err := initiateOIDCLogin(context.Background(), config) + if err != nil { + t.Fatalf("initiateOIDCLogin failed: %v", err) + } + u, err := url.Parse(authReq.authURL) + if err != nil { + t.Fatalf("Failed to parse authURL: %v", err) + } + q := u.Query() + if q.Has("login_hint") { + t.Errorf("expected no login_hint parameter, but got '%s'", q.Get("login_hint")) + } + }) + t.Run("nil config", func(t *testing.T) { + _, _, err := initiateOIDCLogin(context.Background(), nil) + if err == nil { + t.Error("expected error for nil config, got nil") + } + }) + t.Run("missing openid scope", func(t *testing.T) { + badConfig := *config + badConfig.Scopes = []string{"profile", "email"} // Missing "openid" + _, _, err := initiateOIDCLogin(context.Background(), &badConfig) + if err == nil { + t.Error("expected error for missing openid scope, got nil") + } + if !strings.Contains(err.Error(), "openid") { + t.Errorf("expected error about missing 'openid', got: %v", err) + } + }) + t.Run("missing required fields", func(t *testing.T) { + tests := []struct { + name string + mutate func(*OIDCLoginConfig) + expectErr string + }{ + { + name: "missing IssuerURL", + mutate: func(c *OIDCLoginConfig) { c.IssuerURL = "" }, + expectErr: "IssuerURL is required", + }, + { + name: "missing ClientID", + mutate: func(c *OIDCLoginConfig) { c.Credentials.ClientID = "" }, + expectErr: "ClientID is required", + }, + { + name: "missing RedirectURL", + mutate: func(c *OIDCLoginConfig) { + c.RedirectURL = "" + // Ensure ClientID is present to test RedirectURL validation + c.Credentials = &oauthex.ClientCredentials{ClientID: "test"} + }, + expectErr: "RedirectURL is required", + }, + { + name: "missing Scopes", + mutate: func(c *OIDCLoginConfig) { + c.Scopes = nil + // Ensure required fields are present to test Scopes validation + c.Credentials = &oauthex.ClientCredentials{ClientID: "test"} + c.RedirectURL = "http://localhost:8080/callback" + }, + expectErr: "Scopes is required", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + badConfig := *config + tt.mutate(&badConfig) + _, _, err := initiateOIDCLogin(context.Background(), &badConfig) + if err == nil { + t.Error("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.expectErr) { + t.Errorf("expected error containing '%s', got: %v", tt.expectErr, err) + } + }) + } + }) +} + +// TestCompleteOIDCLogin tests the authorization code exchange. +func TestCompleteOIDCLogin(t *testing.T) { + // Create mock IdP server + idpServer := createMockOIDCServerWithToken(t) + defer idpServer.Close() + config := &OIDCLoginConfig{ + IssuerURL: idpServer.URL, + Credentials: &oauthex.ClientCredentials{ + ClientID: "test-client", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "test-secret", + }, + }, + RedirectURL: "http://localhost:8080/callback", + Scopes: []string{"openid", "profile", "email"}, + HTTPClient: idpServer.Client(), + } + t.Run("successful code exchange", func(t *testing.T) { + // First initiate to get oauth2Config + _, oauth2Config, err := initiateOIDCLogin(context.Background(), config) + if err != nil { + t.Fatalf("initiateOIDCLogin failed: %v", err) + } + + token, err := completeOIDCLogin( + context.Background(), + config, + oauth2Config, + "test-auth-code", + "test-code-verifier", + ) + if err != nil { + t.Fatalf("completeOIDCLogin failed: %v", err) + } + // Validate tokens + idToken, ok := token.Extra("id_token").(string) + if !ok || idToken == "" { + t.Error("id_token is missing or empty") + } + if token.AccessToken == "" { + t.Error("AccessToken is empty") + } + if token.TokenType != "Bearer" { + t.Errorf("expected TokenType 'Bearer', got '%s'", token.TokenType) + } + if token.Expiry.IsZero() { + t.Error("Expiry is zero") + } + }) + t.Run("missing parameters", func(t *testing.T) { + _, oauth2Config, _ := initiateOIDCLogin(context.Background(), config) + + tests := []struct { + name string + authCode string + codeVerifier string + expectErr string + }{ + { + name: "missing authCode", + authCode: "", + codeVerifier: "test-verifier", + expectErr: "authCode is required", + }, + { + name: "missing codeVerifier", + authCode: "test-code", + codeVerifier: "", + expectErr: "codeVerifier is required", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := completeOIDCLogin( + context.Background(), + config, + oauth2Config, + tt.authCode, + tt.codeVerifier, + ) + if err == nil { + t.Error("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.expectErr) { + t.Errorf("expected error containing '%s', got: %v", tt.expectErr, err) + } + }) + } + }) +} + +// TestOIDCLoginE2E tests the complete OIDC login flow end-to-end. +func TestOIDCLoginE2E(t *testing.T) { + // Create mock IdP server + idpServer := createMockOIDCServerWithToken(t) + defer idpServer.Close() + config := &OIDCLoginConfig{ + IssuerURL: idpServer.URL, + Credentials: &oauthex.ClientCredentials{ + ClientID: "test-client", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "test-secret", + }, + }, + RedirectURL: "http://localhost:8080/callback", + Scopes: []string{"openid", "profile", "email"}, + HTTPClient: idpServer.Client(), + } + // Step 1: Initiate login + authReq, oauth2Config, err := initiateOIDCLogin(context.Background(), config) + if err != nil { + t.Fatalf("initiateOIDCLogin failed: %v", err) + } + // Step 2: Simulate user authentication and redirect + // (In real flow, user would visit authReq.authURL and IdP would redirect back) + // Here we just use a mock authorization code + mockAuthCode := "mock-authorization-code" + // Step 3: Complete login with authorization code + token, err := completeOIDCLogin( + context.Background(), + config, + oauth2Config, + mockAuthCode, + authReq.codeVerifier, + ) + if err != nil { + t.Fatalf("completeOIDCLogin failed: %v", err) + } + // Validate we got an ID token + idToken, ok := token.Extra("id_token").(string) + if !ok || idToken == "" { + t.Error("Expected ID token, got empty or missing") + } + // Validate ID token is a JWT (has 3 parts) + parts := strings.Split(idToken, ".") + if len(parts) != 3 { + t.Errorf("Expected JWT with 3 parts, got %d parts", len(parts)) + } +} + +// createMockOIDCServer creates a mock OIDC server for testing initiateOIDCLogin. +func createMockOIDCServer(t *testing.T) *httptest.Server { + var serverURL string + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Handle OIDC discovery + if r.URL.Path == "/.well-known/openid-configuration" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "issuer": serverURL, + "authorization_endpoint": serverURL + "/authorize", + "token_endpoint": serverURL + "/token", + "jwks_uri": serverURL + "/.well-known/jwks.json", + "response_types_supported": []string{"code"}, + "code_challenge_methods_supported": []string{"S256"}, + "grant_types_supported": []string{"authorization_code"}, + }) + return + } + http.NotFound(w, r) + })) + serverURL = server.URL + return server +} + +// createMockOIDCServerWithToken creates a mock OIDC server that also handles token exchange. +func createMockOIDCServerWithToken(t *testing.T) *httptest.Server { + var serverURL string + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Handle OIDC discovery + if r.URL.Path == "/.well-known/openid-configuration" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "issuer": serverURL, + "authorization_endpoint": serverURL + "/authorize", + "token_endpoint": serverURL + "/token", + "jwks_uri": serverURL + "/.well-known/jwks.json", + "response_types_supported": []string{"code"}, + "code_challenge_methods_supported": []string{"S256"}, + "grant_types_supported": []string{"authorization_code"}, + }) + return + } + // Handle token endpoint + if r.URL.Path == "/token" { + if err := r.ParseForm(); err != nil { + http.Error(w, "failed to parse form", http.StatusBadRequest) + return + } + // Validate grant type + if r.FormValue("grant_type") != "authorization_code" { + http.Error(w, "invalid grant_type", http.StatusBadRequest) + return + } + // Create mock ID token (JWT) + now := time.Now().Unix() + idToken := fmt.Sprintf("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.%s.mock-signature", + base64EncodeClaims(map[string]interface{}{ + "iss": serverURL, + "sub": "test-user", + "aud": "test-client", + "exp": now + 3600, + "iat": now, + "email": "test@example.com", + })) + // Return token response + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "mock-refresh-token", + "id_token": idToken, + }) + return + } + http.NotFound(w, r) + })) + serverURL = server.URL + return server +} + +// base64EncodeClaims encodes JWT claims for testing. +func base64EncodeClaims(claims map[string]interface{}) string { + claimsJSON, _ := json.Marshal(claims) + return base64.RawURLEncoding.EncodeToString(claimsJSON) +} + +// TestPerformOIDCLogin tests the combined OIDC login flow with callback. +func TestPerformOIDCLogin(t *testing.T) { + // Create mock IdP server + idpServer := createMockOIDCServerWithToken(t) + defer idpServer.Close() + config := &OIDCLoginConfig{ + IssuerURL: idpServer.URL, + Credentials: &oauthex.ClientCredentials{ + ClientID: "test-client", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "test-secret", + }, + }, + RedirectURL: "http://localhost:8080/callback", + Scopes: []string{"openid", "profile", "email"}, + HTTPClient: idpServer.Client(), + } + + t.Run("successful flow", func(t *testing.T) { + token, err := PerformOIDCLogin(context.Background(), config, + func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { + // Validate authURL has required parameters + u, err := url.Parse(args.URL) + if err != nil { + return nil, fmt.Errorf("invalid authURL: %w", err) + } + q := u.Query() + if q.Get("response_type") != "code" { + return nil, fmt.Errorf("missing response_type") + } + if q.Get("state") == "" { + return nil, fmt.Errorf("missing state") + } + + // Simulate successful user authentication + return &auth.AuthorizationResult{ + Code: "mock-auth-code", + State: q.Get("state"), // Return the expected state from URL + }, nil + }) + + if err != nil { + t.Fatalf("PerformOIDCLogin failed: %v", err) + } + + idToken, ok := token.Extra("id_token").(string) + if !ok || idToken == "" { + t.Error("id_token is missing or empty") + } + if token.AccessToken == "" { + t.Error("AccessToken is empty") + } + }) + + t.Run("state mismatch", func(t *testing.T) { + _, err := PerformOIDCLogin(context.Background(), config, + func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { + // Return wrong state to simulate CSRF attack + return &auth.AuthorizationResult{ + Code: "mock-auth-code", + State: "wrong-state", + }, nil + }) + + if err == nil { + t.Error("expected error for state mismatch, got nil") + } + if !strings.Contains(err.Error(), "state mismatch") { + t.Errorf("expected state mismatch error, got: %v", err) + } + }) + + t.Run("fetcher error", func(t *testing.T) { + _, err := PerformOIDCLogin(context.Background(), config, + func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { + return nil, fmt.Errorf("user cancelled") + }) + + if err == nil { + t.Error("expected error, got nil") + } + if !strings.Contains(err.Error(), "user cancelled") { + t.Errorf("expected 'user cancelled' error, got: %v", err) + } + }) + + t.Run("nil fetcher", func(t *testing.T) { + _, err := PerformOIDCLogin(context.Background(), config, nil) + if err == nil { + t.Error("expected error for nil fetcher, got nil") + } + if !strings.Contains(err.Error(), "authCodeFetcher is required") { + t.Errorf("expected 'authCodeFetcher is required' error, got: %v", err) + } + }) +} diff --git a/auth/shared.go b/auth/shared.go new file mode 100644 index 0000000..569c5a1 --- /dev/null +++ b/auth/shared.go @@ -0,0 +1,71 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// This file contains shared utilities for OAuth handlers. + +//go:build mcp_go_client_oauth + +package auth + +import ( + "context" + "net/http" + "net/url" + "strings" + + "github.com/modelcontextprotocol/go-sdk/oauthex" +) + +// GetAuthServerMetadata fetches authorization server metadata for the given issuer URL. +// It tries standard well-known endpoints (OAuth 2.0 and OIDC) and returns the first successful result. +// +// Returns (nil, nil) when no metadata endpoints respond (404s), allowing callers to implement +// fallback logic. Returns an error for any non-client error (network failures, invalid JSON, etc.). +func GetAuthServerMetadata(ctx context.Context, issuerURL string, httpClient *http.Client) (*oauthex.AuthServerMeta, error) { + for _, metadataURL := range authorizationServerMetadataURLs(issuerURL) { + asm, err := oauthex.GetAuthServerMeta(ctx, metadataURL, issuerURL, httpClient) + if err != nil { + return nil, err + } + if asm != nil { + return asm, nil + } + } + return nil, nil +} + +// authorizationServerMetadataURLs returns a list of URLs to try when looking for +// authorization server metadata as mandated by the MCP specification: +// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery. +func authorizationServerMetadataURLs(issuerURL string) []string { + var urls []string + + baseURL, err := url.Parse(issuerURL) + if err != nil { + return nil + } + + if baseURL.Path == "" { + // "OAuth 2.0 Authorization Server Metadata". + baseURL.Path = "/.well-known/oauth-authorization-server" + urls = append(urls, baseURL.String()) + // "OpenID Connect Discovery 1.0". + baseURL.Path = "/.well-known/openid-configuration" + urls = append(urls, baseURL.String()) + return urls + } + + originalPath := baseURL.Path + // "OAuth 2.0 Authorization Server Metadata with path insertion". + baseURL.Path = "/.well-known/oauth-authorization-server/" + strings.TrimLeft(originalPath, "/") + urls = append(urls, baseURL.String()) + // "OpenID Connect Discovery 1.0 with path insertion". + baseURL.Path = "/.well-known/openid-configuration/" + strings.TrimLeft(originalPath, "/") + urls = append(urls, baseURL.String()) + // "OpenID Connect Discovery 1.0 with path appending". + baseURL.Path = "/" + strings.Trim(originalPath, "/") + "/.well-known/openid-configuration" + urls = append(urls, baseURL.String()) + + return urls +} diff --git a/auth/shared_test.go b/auth/shared_test.go new file mode 100644 index 0000000..9f51d7e --- /dev/null +++ b/auth/shared_test.go @@ -0,0 +1,101 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +//go:build mcp_go_client_oauth + +package auth + +import ( + "net/http" + "testing" + + "github.com/modelcontextprotocol/go-sdk/internal/oauthtest" +) + +func TestGetAuthServerMetadata(t *testing.T) { + tests := []struct { + name string + issuerPath string + endpointConfig *oauthtest.MetadataEndpointConfig + wantNil bool + }{ + { + name: "OAuthEndpoint_Root", + issuerPath: "", + endpointConfig: &oauthtest.MetadataEndpointConfig{ + ServeOAuthInsertedEndpoint: true, + }, + }, + { + name: "OpenIDEndpoint_Root", + issuerPath: "", + endpointConfig: &oauthtest.MetadataEndpointConfig{ + ServeOpenIDInsertedEndpoint: true, + }, + }, + { + name: "OAuthEndpoint_Path", + issuerPath: "/oauth", + endpointConfig: &oauthtest.MetadataEndpointConfig{ + ServeOAuthInsertedEndpoint: true, + }, + }, + { + name: "OpenIDEndpoint_Path", + issuerPath: "/openid", + endpointConfig: &oauthtest.MetadataEndpointConfig{ + ServeOpenIDInsertedEndpoint: true, + }, + }, + { + name: "OpenIDAppendedEndpoint_Path", + issuerPath: "/openid", + endpointConfig: &oauthtest.MetadataEndpointConfig{ + ServeOpenIDAppendedEndpoint: true, + }, + }, + { + name: "NoMetadata", + issuerPath: "", + endpointConfig: &oauthtest.MetadataEndpointConfig{ + // All metadata endpoints disabled. + ServeOAuthInsertedEndpoint: false, + ServeOpenIDInsertedEndpoint: false, + }, + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + IssuerPath: tt.issuerPath, + MetadataEndpointConfig: tt.endpointConfig, + }) + s.Start(t) + issuerURL := s.URL() + tt.issuerPath + + got, err := GetAuthServerMetadata(t.Context(), issuerURL, http.DefaultClient) + if tt.wantNil { + // When no metadata is found, GetAuthServerMetadata returns (nil, nil). + if err != nil { + t.Fatalf("GetAuthServerMetadata() unexpected error = %v, want nil", err) + } + if got != nil { + t.Fatal("GetAuthServerMetadata() expected nil for no metadata, got metadata") + } + return + } + if err != nil { + t.Fatalf("GetAuthServerMetadata() error = %v, want nil", err) + } + if got == nil { + t.Fatal("GetAuthServerMetadata() got nil, want metadata") + } + if got.Issuer != issuerURL { + t.Errorf("GetAuthServerMetadata() issuer = %q, want %q", got.Issuer, issuerURL) + } + }) + } +} diff --git a/conformance/everything-client/client_private.go b/conformance/everything-client/client_private.go index 3b0c659..6cddc95 100644 --- a/conformance/everything-client/client_private.go +++ b/conformance/everything-client/client_private.go @@ -98,9 +98,9 @@ func runAuthClient(ctx context.Context, serverURL string, configCtx map[string]a // Try pre-registered client information if provided in the context. if clientID, ok := configCtx["client_id"].(string); ok { if clientSecret, ok := configCtx["client_secret"].(string); ok { - authConfig.PreregisteredClientConfig = &auth.PreregisteredClientConfig{ - ClientSecretAuthConfig: &auth.ClientSecretAuthConfig{ - ClientID: clientID, + authConfig.PreregisteredClient = &oauthex.ClientCredentials{ + ClientID: clientID, + ClientSecretAuth: &oauthex.ClientSecretAuth{ ClientSecret: clientSecret, }, } diff --git a/docs/protocol.md b/docs/protocol.md index af92f93..72b901d 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -354,8 +354,77 @@ client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, session, err := client.Connect(ctx, transport, nil) ``` -The `auth.AuthorizationCodeHandler` automatically manages token refreshing -and step-up authentication (when the server returns `insufficient_scope` error). +The `auth.AuthorizationCodeHandler` automatically manages token refreshing (if the server provides a refresh token) and step-up authentication (when the server returns `insufficient_scope` error). + +#### Enterprise Managed Authorization (SEP-990) + +For enterprise SSO scenarios where users authenticate with an enterprise Identity Provider (IdP), +the SDK provides +[`extauth.EnterpriseHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth/extauth#EnterpriseHandler), +an implementation of `OAuthHandler` that automates the Enterprise Managed Authorization flow: + +1. **OIDC Login**: User authenticates with enterprise IdP → ID Token +2. **Token Exchange** (RFC 8693): ID Token → ID-JAG at IdP +3. **JWT Bearer Grant** (RFC 7523): ID-JAG → Access Token at MCP Server + +To use enterprise managed authorization, create an `EnterpriseHandler` and assign it to your transport: + +```go +// Create ID token fetcher using OIDC login +idTokenFetcher := func(ctx context.Context) (*oauth2.Token, error) { + oidcConfig := &extauth.OIDCLoginConfig{ + IssuerURL: "https://company.okta.com", + Credentials: &oauthex.ClientCredentials{ + ClientID: "idp-client-id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "idp-client-secret", + }, + }, + RedirectURL: "http://localhost:3142", + Scopes: []string{"openid", "profile", "email"}, + } + + tokens, err := extauth.PerformOIDCLogin(ctx, oidcConfig, authCodeFetcher) + if err != nil { + return nil, err + } + + return tokens, nil +} + +// Create Enterprise Handler +enterpriseHandler, err := extauth.NewEnterpriseHandler(&extauth.EnterpriseHandlerConfig{ + IdPIssuerURL: "https://company.okta.com", + IdPCredentials: &oauthex.ClientCredentials{ + ClientID: "idp-client-id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "idp-client-secret", + }, + }, + MCPAuthServerURL: "https://auth.mcpserver.example", + MCPResourceURI: "https://mcp.mcpserver.example", + MCPCredentials: &oauthex.ClientCredentials{ + ClientID: "mcp-client-id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "mcp-client-secret", + }, + }, + MCPScopes: []string{"read", "write"}, + IDTokenFetcher: idTokenFetcher, +}) + +// Use with transport +transport := &mcp.StreamableClientTransport{ + Endpoint: "https://example.com/mcp", + OAuthHandler: enterpriseHandler, +} +client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) +session, err := client.Connect(ctx, transport, nil) +``` + +The `EnterpriseHandler` automatically manages the token exchange flow. Note that it intentionally does not support refresh tokens - when an access token expires, the entire authorization flow is repeated to ensure enterprise policies are consistently enforced. + +For a complete working example, see [examples/auth/enterprise](https://github.com/modelcontextprotocol/go-sdk/tree/main/examples/auth/enterprise). ## Security @@ -574,3 +643,4 @@ func Example_progress() { // frobbing widgets 2/2 } ``` + diff --git a/examples/auth/client/main.go b/examples/auth/client/main.go index b9ddf4d..a245a8a 100644 --- a/examples/auth/client/main.go +++ b/examples/auth/client/main.go @@ -87,10 +87,11 @@ func main() { RedirectURL: fmt.Sprintf("http://localhost:%d", *callbackPort), AuthorizationCodeFetcher: receiver.getAuthorizationCode, // Uncomment the client configuration you want to use. - // PreregisteredClientConfig: &auth.PreregisteredClientConfig{ - // ClientSecretAuthConfig: &auth.ClientSecretAuthConfig{ + // PreregisteredClient: &oauthex.ClientCredentials{ // ClientID: "", - // ClientSecret: "", + // ClientSecretAuth: &oauthex.ClientSecretAuth{ + // ClientSecret: "", + // }, // }, // }, // DynamicClientRegistrationConfig: &auth.DynamicClientRegistrationConfig{ diff --git a/examples/auth/enterprise/main.go b/examples/auth/enterprise/main.go new file mode 100644 index 0000000..4698f25 --- /dev/null +++ b/examples/auth/enterprise/main.go @@ -0,0 +1,229 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +//go:build mcp_go_client_oauth + +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log" + "net" + "net/http" + + "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/auth/extauth" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/modelcontextprotocol/go-sdk/oauthex" + "golang.org/x/oauth2" +) + +var ( + // IdP (Identity Provider) configuration. + idpIssuerURL = flag.String("idp_issuer", "https://your-idp.okta.com", "IdP issuer URL (e.g., https://your-company.okta.com)") + idpClientID = flag.String("idp_client_id", "", "Client ID registered at the IdP") + idpClientSecret = flag.String("idp_client_secret", "", "Client secret at the IdP (optional for public clients)") + + // MCP Server configuration. + mcpServerURL = flag.String("mcp_server", "http://localhost:8000/mcp", "URL of the MCP server") + mcpAuthServerURL = flag.String("mcp_auth_server", "https://auth.mcpserver.example", "MCP server's authorization server URL") + mcpResourceURI = flag.String("mcp_resource_uri", "https://mcp.mcpserver.example", "MCP server's resource identifier (RFC 9728)") + mcpClientID = flag.String("mcp_client_id", "", "Client ID at the MCP server (optional)") + mcpClientSecret = flag.String("mcp_client_secret", "", "Client secret at the MCP server (optional)") + + // OAuth callback configuration. + callbackPort = flag.Int("callback_port", 3142, "Port for the local HTTP server that will receive the OAuth callback") +) + +// codeReceiver handles the OAuth callback from the IdP's authorization endpoint. +// It starts a local HTTP server to receive the authorization code after the user +// authenticates with their enterprise IdP. +type codeReceiver struct { + authChan chan *auth.AuthorizationResult + errChan chan error + listener net.Listener + server *http.Server +} + +// serveRedirectHandler starts an HTTP server to handle the OAuth redirect callback. +func (r *codeReceiver) serveRedirectHandler(listener net.Listener) { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + // Extract the authorization code and state from the callback URL. + r.authChan <- &auth.AuthorizationResult{ + Code: req.URL.Query().Get("code"), + State: req.URL.Query().Get("state"), + } + fmt.Fprint(w, "Authentication successful. You can close this window.") + }) + + r.server = &http.Server{ + Addr: fmt.Sprintf("localhost:%d", *callbackPort), + Handler: mux, + } + if err := r.server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + r.errChan <- err + } +} + +// getAuthorizationCode implements the AuthorizationCodeFetcher interface. +// It displays the authorization URL to the user and waits for the callback. +func (r *codeReceiver) getAuthorizationCode(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { + fmt.Printf("\nPlease open the following URL in your browser to authenticate:\n%s\n\n", args.URL) + select { + case authRes := <-r.authChan: + return authRes, nil + case err := <-r.errChan: + return nil, err + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// close shuts down the HTTP server. +func (r *codeReceiver) close() { + if r.server != nil { + r.server.Close() + } +} + +func main() { + flag.Parse() + + // Validate required configuration. + if *idpClientID == "" { + log.Fatal("--idp_client_id is required") + } + + // Set up the OAuth callback receiver. + receiver := &codeReceiver{ + authChan: make(chan *auth.AuthorizationResult), + errChan: make(chan error), + } + listener, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", *callbackPort)) + if err != nil { + log.Fatalf("failed to listen on port %d: %v", *callbackPort, err) + } + go receiver.serveRedirectHandler(listener) + defer receiver.close() + + log.Printf("OAuth callback server listening on http://localhost:%d", *callbackPort) + + // Create an ID Token fetcher that performs OIDC login with the enterprise IdP. + idTokenFetcher := func(ctx context.Context) (*oauth2.Token, error) { + log.Println("Starting OIDC login flow...") + + creds := &oauthex.ClientCredentials{ + ClientID: *idpClientID, + } + if *idpClientSecret != "" { + creds.ClientSecretAuth = &oauthex.ClientSecretAuth{ + ClientSecret: *idpClientSecret, + } + } + + oidcConfig := &extauth.OIDCLoginConfig{ + IssuerURL: *idpIssuerURL, + Credentials: creds, + RedirectURL: fmt.Sprintf("http://localhost:%d", *callbackPort), + Scopes: []string{"openid", "profile", "email"}, + } + + // PerformOIDCLogin handles the complete OIDC Authorization Code flow with PKCE. + tokens, err := extauth.PerformOIDCLogin(ctx, oidcConfig, receiver.getAuthorizationCode) + if err != nil { + return nil, fmt.Errorf("OIDC login failed: %w", err) + } + + log.Println("OIDC login successful, obtained ID token") + return tokens, nil + } + + // Create the Enterprise Handler. + // This handler implements the complete Enterprise Managed Authorization flow: + // 1. OIDC Login: User authenticates with enterprise IdP → ID Token (via idTokenFetcher). + // 2. Token Exchange (RFC 8693): ID Token → ID-JAG at IdP. + // 3. JWT Bearer Grant (RFC 7523): ID-JAG → Access Token at MCP Server. + log.Println("Creating enterprise authorization handler...") + + // Prepare IdP credentials + idpCreds := &oauthex.ClientCredentials{ + ClientID: *idpClientID, + } + if *idpClientSecret != "" { + idpCreds.ClientSecretAuth = &oauthex.ClientSecretAuth{ + ClientSecret: *idpClientSecret, + } + } + + // Prepare MCP credentials + var mcpCreds *oauthex.ClientCredentials + if *mcpClientID != "" { + mcpCreds = &oauthex.ClientCredentials{ + ClientID: *mcpClientID, + } + if *mcpClientSecret != "" { + mcpCreds.ClientSecretAuth = &oauthex.ClientSecretAuth{ + ClientSecret: *mcpClientSecret, + } + } + } + + enterpriseHandler, err := extauth.NewEnterpriseHandler(&extauth.EnterpriseHandlerConfig{ + // IdP configuration (where the user authenticates). + IdPIssuerURL: *idpIssuerURL, + IdPCredentials: idpCreds, + + // MCP Server configuration (the resource being accessed). + MCPAuthServerURL: *mcpAuthServerURL, + MCPResourceURI: *mcpResourceURI, + MCPCredentials: mcpCreds, + MCPScopes: []string{"read", "write"}, + + // ID Token fetcher (performs OIDC login when needed). + IDTokenFetcher: idTokenFetcher, + }) + if err != nil { + log.Fatalf("failed to create enterprise handler: %v", err) + } + + // Create the MCP client transport with the enterprise handler. + transport := &mcp.StreamableClientTransport{ + Endpoint: *mcpServerURL, + OAuthHandler: enterpriseHandler, + } + + ctx := context.Background() + client := mcp.NewClient(&mcp.Implementation{ + Name: "enterprise-client-example", + Version: "1.0.0", + }, nil) + + log.Printf("Connecting to MCP server at %s...", *mcpServerURL) + session, err := client.Connect(ctx, transport, nil) + if err != nil { + log.Fatalf("failed to connect to MCP server: %v", err) + } + defer session.Close() + + log.Println("Successfully connected to MCP server!") + + // List available tools as a demonstration. + tools, err := session.ListTools(ctx, nil) + if err != nil { + log.Fatalf("failed to list tools: %v", err) + } + + log.Println("\nAvailable tools:") + if len(tools.Tools) == 0 { + log.Println(" (no tools available)") + } else { + for _, tool := range tools.Tools { + log.Printf(" - %q: %s", tool.Name, tool.Description) + } + } +} diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index 5758e03..5de26d9 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -279,8 +279,77 @@ client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, session, err := client.Connect(ctx, transport, nil) ``` -The `auth.AuthorizationCodeHandler` automatically manages token refreshing -and step-up authentication (when the server returns `insufficient_scope` error). +The `auth.AuthorizationCodeHandler` automatically manages token refreshing (if the server provides a refresh token) and step-up authentication (when the server returns `insufficient_scope` error). + +#### Enterprise Managed Authorization (SEP-990) + +For enterprise SSO scenarios where users authenticate with an enterprise Identity Provider (IdP), +the SDK provides +[`extauth.EnterpriseHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth/extauth#EnterpriseHandler), +an implementation of `OAuthHandler` that automates the Enterprise Managed Authorization flow: + +1. **OIDC Login**: User authenticates with enterprise IdP → ID Token +2. **Token Exchange** (RFC 8693): ID Token → ID-JAG at IdP +3. **JWT Bearer Grant** (RFC 7523): ID-JAG → Access Token at MCP Server + +To use enterprise managed authorization, create an `EnterpriseHandler` and assign it to your transport: + +```go +// Create ID token fetcher using OIDC login +idTokenFetcher := func(ctx context.Context) (*oauth2.Token, error) { + oidcConfig := &extauth.OIDCLoginConfig{ + IssuerURL: "https://company.okta.com", + Credentials: &oauthex.ClientCredentials{ + ClientID: "idp-client-id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "idp-client-secret", + }, + }, + RedirectURL: "http://localhost:3142", + Scopes: []string{"openid", "profile", "email"}, + } + + tokens, err := extauth.PerformOIDCLogin(ctx, oidcConfig, authCodeFetcher) + if err != nil { + return nil, err + } + + return tokens, nil +} + +// Create Enterprise Handler +enterpriseHandler, err := extauth.NewEnterpriseHandler(&extauth.EnterpriseHandlerConfig{ + IdPIssuerURL: "https://company.okta.com", + IdPCredentials: &oauthex.ClientCredentials{ + ClientID: "idp-client-id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "idp-client-secret", + }, + }, + MCPAuthServerURL: "https://auth.mcpserver.example", + MCPResourceURI: "https://mcp.mcpserver.example", + MCPCredentials: &oauthex.ClientCredentials{ + ClientID: "mcp-client-id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "mcp-client-secret", + }, + }, + MCPScopes: []string{"read", "write"}, + IDTokenFetcher: idTokenFetcher, +}) + +// Use with transport +transport := &mcp.StreamableClientTransport{ + Endpoint: "https://example.com/mcp", + OAuthHandler: enterpriseHandler, +} +client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) +session, err := client.Connect(ctx, transport, nil) +``` + +The `EnterpriseHandler` automatically manages the token exchange flow. Note that it intentionally does not support refresh tokens - when an access token expires, the entire authorization flow is repeated to ensure enterprise policies are consistently enforced. + +For a complete working example, see [examples/auth/enterprise](https://github.com/modelcontextprotocol/go-sdk/tree/main/examples/auth/enterprise). ## Security @@ -397,3 +466,4 @@ or Issue #460 discusses some potential ergonomic improvements to this API. %include ../../mcp/mcp_example_test.go progress - + diff --git a/oauthex/client.go b/oauthex/client.go new file mode 100644 index 0000000..8fd2f76 --- /dev/null +++ b/oauthex/client.go @@ -0,0 +1,59 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +//go:build mcp_go_client_oauth + +package oauthex + +import "errors" + +// ClientCredentials holds client authentication credentials for OAuth token requests. +// It supports multiple authentication methods, but only one method should be set at a time. +// Use the Validate method to ensure proper configuration. +type ClientCredentials struct { + // ClientID is the OAuth2 client identifier. + // REQUIRED for all authentication methods. + ClientID string + + // ClientSecretAuth configures client authentication using a client secret. + // This is the most common authentication method for confidential clients. + // OPTIONAL. If not provided, the client is treated as a public client. + ClientSecretAuth *ClientSecretAuth +} + +// ClientSecretAuth holds client secret authentication credentials. +// This authentication method supports both "client_secret_basic" and "client_secret_post" +// methods as defined in RFC 6749 Section 2.3.1. +type ClientSecretAuth struct { + // ClientSecret is the OAuth2 client secret for confidential clients. + // REQUIRED when using ClientSecretAuth. + ClientSecret string +} + +// Validate checks that the ClientCredentials are properly configured. +// It ensures that: +// - ClientID is not empty. +// - At most one authentication method is configured. +// - If ClientSecretAuth is set, ClientSecret is not empty. +func (c *ClientCredentials) Validate() error { + if c.ClientID == "" { + return errors.New("ClientID is required") + } + + // Count how many auth methods are configured. + authMethodCount := 0 + if c.ClientSecretAuth != nil { + authMethodCount++ + if c.ClientSecretAuth.ClientSecret == "" { + return errors.New("ClientSecret is required when using ClientSecretAuth") + } + } + + // Allow zero auth methods (public client) or exactly one auth method. + if authMethodCount > 1 { + return errors.New("only one client authentication method can be configured") + } + + return nil +} diff --git a/oauthex/oauth2.go b/oauthex/oauth2.go index 82c1c28..ba6b774 100644 --- a/oauthex/oauth2.go +++ b/oauthex/oauth2.go @@ -66,6 +66,7 @@ func getJSON[T any](ctx context.Context, c *http.Client, url string, limit int64 // checkURLScheme ensures that its argument is a valid URL with a scheme // that prevents XSS attacks. // See #526. +// Note: a copy of this function exists in auth/extauth/oidc_login.go; keep these in sync. func checkURLScheme(u string) error { if u == "" { return nil diff --git a/oauthex/token_exchange.go b/oauthex/token_exchange.go new file mode 100644 index 0000000..f77cafa --- /dev/null +++ b/oauthex/token_exchange.go @@ -0,0 +1,185 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// This file implements Token Exchange (RFC 8693) for Enterprise Managed Authorization. +// See https://datatracker.ietf.org/doc/html/rfc8693 + +//go:build mcp_go_client_oauth + +package oauthex + +import ( + "context" + "fmt" + "net/http" + "strings" + + "golang.org/x/oauth2" +) + +// Token type identifiers defined by RFC 8693 and SEP-990. +const ( + // TokenTypeIDToken is the URN for OpenID Connect ID Tokens. + TokenTypeIDToken = "urn:ietf:params:oauth:token-type:id_token" + + // TokenTypeSAML2 is the URN for SAML 2.0 assertions. + TokenTypeSAML2 = "urn:ietf:params:oauth:token-type:saml2" + + // TokenTypeIDJAG is the URN for Identity Assertion JWT Authorization Grants. + // This is the token type returned by IdP during token exchange for SEP-990. + TokenTypeIDJAG = "urn:ietf:params:oauth:token-type:id-jag" + + // GrantTypeTokenExchange is the grant type for RFC 8693 token exchange. + GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" +) + +// TokenExchangeRequest represents a Token Exchange request per RFC 8693. +// This is used for Enterprise Managed Authorization (SEP-990) where an MCP Client +// exchanges an ID Token from an enterprise IdP for an ID-JAG that can be used +// to obtain an access token from an MCP Server's authorization server. +type TokenExchangeRequest struct { + // RequestedTokenType indicates the type of security token being requested. + // For SEP-990, this MUST be TokenTypeIDJAG. + RequestedTokenType string + + // Audience is the logical name of the target service where the client + // intends to use the requested token. For SEP-990, this MUST be the + // Issuer URL of the MCP Server's authorization server. + Audience string + + // Resource is the physical location or identifier of the target resource. + // For SEP-990, this MUST be the RFC9728 Resource Identifier of the MCP Server. + Resource string + + // Scope is a list of space-separated scopes for the requested token. + // This is OPTIONAL per RFC 8693 but commonly used in SEP-990. + Scope []string + + // SubjectToken is the security token that represents the identity of the + // party on behalf of whom the request is being made. For SEP-990, this is + // typically an OpenID Connect ID Token. + SubjectToken string + + // SubjectTokenType is the type of the security token in SubjectToken. + // For SEP-990 with OIDC, this MUST be TokenTypeIDToken. + SubjectTokenType string +} + +// ExchangeToken performs a token exchange request per RFC 8693 for Enterprise +// Managed Authorization (SEP-990). It exchanges an identity assertion (typically +// an ID Token) for an Identity Assertion JWT Authorization Grant (ID-JAG) that +// can be used to obtain an access token from an MCP Server. +// +// The tokenEndpoint parameter should be the IdP's token endpoint (typically +// obtained from the IdP's authorization server metadata). +// +// Returns an oauth2.Token where: +// - Extra("issued_token_type") contains the type of the issued token (e.g., TokenTypeIDJAG) +// - AccessToken contains the ID-JAG JWT (despite the name, this is not an OAuth access token) +// - TokenType is typically "N_A" for SEP-990 +// - Extra("scope") may contain the scope if different from the request +// - Expiry is when the token expires +func ExchangeToken( + ctx context.Context, + tokenEndpoint string, + req *TokenExchangeRequest, + clientCreds *ClientCredentials, + httpClient *http.Client, +) (*oauth2.Token, error) { + if tokenEndpoint == "" { + return nil, fmt.Errorf("token endpoint is required") + } + if req == nil { + return nil, fmt.Errorf("token exchange request is required") + } + if clientCreds == nil { + return nil, fmt.Errorf("client credentials are required") + } + if err := clientCreds.Validate(); err != nil { + return nil, fmt.Errorf("invalid client credentials: %w", err) + } + + // Validate required fields per SEP-990 Section 4. + if req.RequestedTokenType == "" { + return nil, fmt.Errorf("requested_token_type is required") + } + if req.Audience == "" { + return nil, fmt.Errorf("audience is required") + } + if req.Resource == "" { + return nil, fmt.Errorf("resource is required") + } + if req.SubjectToken == "" { + return nil, fmt.Errorf("subject_token is required") + } + if req.SubjectTokenType == "" { + return nil, fmt.Errorf("subject_token_type is required") + } + + // Validate URL schemes to prevent XSS attacks (see #526). + if err := checkURLScheme(tokenEndpoint); err != nil { + return nil, fmt.Errorf("invalid token endpoint: %w", err) + } + if err := checkURLScheme(req.Audience); err != nil { + return nil, fmt.Errorf("invalid audience: %w", err) + } + if err := checkURLScheme(req.Resource); err != nil { + return nil, fmt.Errorf("invalid resource: %w", err) + } + + // Per RFC 6749 Section 3.2, parameters sent without a value (like the empty + // "code" parameter) MUST be treated as if they were omitted from the request. + // The oauth2 library's Exchange method sends an empty code, but compliant + // servers should ignore it. + cfg := &oauth2.Config{ + ClientID: clientCreds.ClientID, + Endpoint: oauth2.Endpoint{ + TokenURL: tokenEndpoint, + AuthStyle: oauth2.AuthStyleInParams, + }, + } + // Set ClientSecret if ClientSecretAuth is configured. + if clientCreds.ClientSecretAuth != nil { + cfg.ClientSecret = clientCreds.ClientSecretAuth.ClientSecret + } + + // Use custom HTTP client if provided. + if httpClient == nil { + httpClient = http.DefaultClient + } + ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient) + + // Build token exchange parameters per RFC 8693. + opts := []oauth2.AuthCodeOption{ + oauth2.SetAuthURLParam("grant_type", GrantTypeTokenExchange), + oauth2.SetAuthURLParam("requested_token_type", req.RequestedTokenType), + oauth2.SetAuthURLParam("audience", req.Audience), + oauth2.SetAuthURLParam("resource", req.Resource), + oauth2.SetAuthURLParam("subject_token", req.SubjectToken), + oauth2.SetAuthURLParam("subject_token_type", req.SubjectTokenType), + } + if len(req.Scope) > 0 { + opts = append(opts, oauth2.SetAuthURLParam("scope", strings.Join(req.Scope, " "))) + } + + // Exchange with token exchange grant type. + // SetAuthURLParam overrides the default grant_type and adds all required parameters. + token, err := cfg.Exchange( + ctxWithClient, + "", // empty code - per RFC 6749 Section 3.2, empty params should be ignored + opts..., + ) + if err != nil { + return nil, fmt.Errorf("token exchange request failed: %w", err) + } + + // Validate that issued_token_type is present in the response. + // The oauth2 library stores additional response fields in Extra. + issuedTokenType, _ := token.Extra("issued_token_type").(string) + if issuedTokenType == "" { + return nil, fmt.Errorf("response missing required field: issued_token_type") + } + + return token, nil +} diff --git a/oauthex/token_exchange_test.go b/oauthex/token_exchange_test.go new file mode 100644 index 0000000..d5a8a16 --- /dev/null +++ b/oauthex/token_exchange_test.go @@ -0,0 +1,239 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +//go:build mcp_go_client_oauth + +package oauthex + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// TestExchangeToken tests the basic token exchange flow. +func TestExchangeToken(t *testing.T) { + // Create a test IdP server that implements token exchange + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request method and content type + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + contentType := r.Header.Get("Content-Type") + if contentType != "application/x-www-form-urlencoded" { + t.Errorf("expected application/x-www-form-urlencoded, got %s", contentType) + http.Error(w, "invalid content type", http.StatusBadRequest) + return + } + + // Parse form data + if err := r.ParseForm(); err != nil { + http.Error(w, "failed to parse form", http.StatusBadRequest) + return + } + + // Verify required parameters per SEP-990 Section 4 + grantType := r.FormValue("grant_type") + if grantType != GrantTypeTokenExchange { + t.Errorf("expected grant_type %s, got %s", GrantTypeTokenExchange, grantType) + writeErrorResponse(w, "invalid_grant", "invalid grant_type") + return + } + + requestedTokenType := r.FormValue("requested_token_type") + if requestedTokenType != TokenTypeIDJAG { + t.Errorf("expected requested_token_type %s, got %s", TokenTypeIDJAG, requestedTokenType) + writeErrorResponse(w, "invalid_request", "invalid requested_token_type") + return + } + + audience := r.FormValue("audience") + if audience == "" { + t.Error("audience is required") + writeErrorResponse(w, "invalid_request", "missing audience") + return + } + + resource := r.FormValue("resource") + if resource == "" { + t.Error("resource is required") + writeErrorResponse(w, "invalid_request", "missing resource") + return + } + + subjectToken := r.FormValue("subject_token") + if subjectToken == "" { + t.Error("subject_token is required") + writeErrorResponse(w, "invalid_request", "missing subject_token") + return + } + + subjectTokenType := r.FormValue("subject_token_type") + if subjectTokenType != TokenTypeIDToken { + t.Errorf("expected subject_token_type %s, got %s", TokenTypeIDToken, subjectTokenType) + writeErrorResponse(w, "invalid_request", "invalid subject_token_type") + return + } + + // Verify client authentication + clientID := r.FormValue("client_id") + clientSecret := r.FormValue("client_secret") + if clientID == "" || clientSecret == "" { + t.Error("client authentication required") + writeErrorResponse(w, "invalid_client", "client authentication failed") + return + } + + if clientID != "test-client-id" || clientSecret != "test-client-secret" { + t.Error("invalid client credentials") + writeErrorResponse(w, "invalid_client", "invalid credentials") + return + } + + // Return successful token exchange response per SEP-990 Section 4.2 + resp := map[string]interface{}{ + "issued_token_type": TokenTypeIDJAG, + "access_token": "fake-id-jag-token", + "token_type": "N_A", + "scope": r.FormValue("scope"), + "expires_in": 300, + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + // Test successful token exchange + t.Run("successful exchange", func(t *testing.T) { + req := &TokenExchangeRequest{ + RequestedTokenType: TokenTypeIDJAG, + Audience: "https://auth.mcpserver.example/", + Resource: "https://mcp.mcpserver.example/", + Scope: []string{"read", "write"}, + SubjectToken: "fake-id-token", + SubjectTokenType: TokenTypeIDToken, + } + + token, err := ExchangeToken( + context.Background(), + server.URL, + req, + &ClientCredentials{ + ClientID: "test-client-id", + ClientSecretAuth: &ClientSecretAuth{ + ClientSecret: "test-client-secret", + }, + }, + server.Client(), + ) + + if err != nil { + t.Fatalf("ExchangeToken failed: %v", err) + } + + issuedTokenType, ok := token.Extra("issued_token_type").(string) + if !ok || issuedTokenType != TokenTypeIDJAG { + t.Errorf("expected issued_token_type %s, got %s", TokenTypeIDJAG, issuedTokenType) + } + + if token.AccessToken != "fake-id-jag-token" { + t.Errorf("expected access_token 'fake-id-jag-token', got %s", token.AccessToken) + } + + if token.TokenType != "N_A" { + t.Errorf("expected token_type 'N_A', got %s", token.TokenType) + } + + scope, ok := token.Extra("scope").(string) + if !ok || scope != "read write" { + t.Errorf("expected scope 'read write', got %s", scope) + } + + // expires_in should be available in Extra + expiresIn, ok := token.Extra("expires_in").(float64) + if !ok || int(expiresIn) != 300 { + t.Errorf("expected expires_in 300, got %v", expiresIn) + } + }) + + // Test missing required fields + t.Run("missing audience", func(t *testing.T) { + req := &TokenExchangeRequest{ + RequestedTokenType: TokenTypeIDJAG, + Resource: "https://mcp.mcpserver.example/", + SubjectToken: "fake-id-token", + SubjectTokenType: TokenTypeIDToken, + } + + _, err := ExchangeToken( + context.Background(), + server.URL, + req, + &ClientCredentials{ + ClientID: "test-client-id", + ClientSecretAuth: &ClientSecretAuth{ + ClientSecret: "test-client-secret", + }, + }, + server.Client(), + ) + + if err == nil { + t.Error("expected error for missing audience, got nil") + } + }) + + // Test invalid URL schemes + t.Run("invalid audience URL scheme", func(t *testing.T) { + req := &TokenExchangeRequest{ + RequestedTokenType: TokenTypeIDJAG, + Audience: "javascript:alert(1)", + Resource: "https://mcp.mcpserver.example/", + SubjectToken: "fake-id-token", + SubjectTokenType: TokenTypeIDToken, + } + + _, err := ExchangeToken( + context.Background(), + server.URL, + req, + &ClientCredentials{ + ClientID: "test-client-id", + ClientSecretAuth: &ClientSecretAuth{ + ClientSecret: "test-client-secret", + }, + }, + server.Client(), + ) + + if err == nil { + t.Error("expected error for invalid audience URL scheme, got nil") + } + }) +} + +// writeErrorResponse writes an OAuth 2.0 error response per RFC 6749 Section 5.2. +func writeErrorResponse(w http.ResponseWriter, errorCode, errorDescription string) { + errResp := struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description,omitempty"` + }{ + Error: errorCode, + ErrorDescription: errorDescription, + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(errResp) +} From b71a8391a66b6a3e295ae34583adf34dfee489b9 Mon Sep 17 00:00:00 2001 From: Oleksandr Redko Date: Mon, 30 Mar 2026 19:43:01 +0300 Subject: [PATCH 234/418] all: fix typos (#869) Grammar issues in docs, comments, and error messages. --- docs/protocol.md | 4 ++-- examples/server/memory/main.go | 2 +- examples/server/toolschemas/main.go | 2 +- internal/docs/protocol.src.md | 4 ++-- mcp/client.go | 2 +- mcp/cmd_test.go | 2 +- mcp/conformance_test.go | 2 +- mcp/protocol.go | 4 ++-- mcp/server.go | 2 +- mcp/streamable.go | 4 ++-- mcp/streamable_test.go | 2 +- 11 files changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index 72b901d..70f5f41 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -219,7 +219,7 @@ to see the logical session > modelcontextprotocol/modelcontextprotocol#1442 for potential refinements. _See [examples/server/distributed](../examples/server/distributed/main.go) for -an example using statless mode to implement a server distributed across +an example using stateless mode to implement a server distributed across multiple processes._ ### Custom transports @@ -264,7 +264,7 @@ and invokes the passed to `RequireBearerToken` to parse the token and perform validation. The middleware function checks expiration and scopes (if they are provided in [`RequireBearerTokenOptions.Scopes`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#RequireBearerTokenOptions.Scopes)), so the -`TokenVerifer` doesn't have to. +`TokenVerifier` doesn't have to. If [`RequireBearerTokenOptions.ResourceMetadataURL`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#RequireBearerTokenOptions.ResourceMetadataURL) is set and verification fails, the middleware function sets the WWW-Authenticate header as required by the [Protected Resource Metadata spec](https://datatracker.ietf.org/doc/html/rfc9728). diff --git a/examples/server/memory/main.go b/examples/server/memory/main.go index 99d109a..039c21b 100644 --- a/examples/server/memory/main.go +++ b/examples/server/memory/main.go @@ -61,7 +61,7 @@ type DeleteEntitiesArgs struct { // DeleteObservationsArgs defines the delete observations tool parameters. type DeleteObservationsArgs struct { - Deletions []Observation `json:"deletions" mcp:"obeservations to delete"` + Deletions []Observation `json:"deletions" mcp:"observations to delete"` } // DeleteRelationsArgs defines the delete relations tool parameters. diff --git a/examples/server/toolschemas/main.go b/examples/server/toolschemas/main.go index abc1e3f..0a2cfbd 100644 --- a/examples/server/toolschemas/main.go +++ b/examples/server/toolschemas/main.go @@ -36,7 +36,7 @@ func simpleGreeting(_ context.Context, _ *mcp.CallToolRequest, input Input) (*mc // manualGreeter handles the parsing and validation of input and output manually. // -// Therfore, it needs to close over its resolved schemas, to use them in +// Therefore, it needs to close over its resolved schemas, to use them in // validation. type manualGreeter struct { inputSchema *jsonschema.Resolved diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index 5de26d9..7d132d2 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -144,7 +144,7 @@ to see the logical session > modelcontextprotocol/modelcontextprotocol#1442 for potential refinements. _See [examples/server/distributed](../examples/server/distributed/main.go) for -an example using statless mode to implement a server distributed across +an example using stateless mode to implement a server distributed across multiple processes._ ### Custom transports @@ -189,7 +189,7 @@ and invokes the passed to `RequireBearerToken` to parse the token and perform validation. The middleware function checks expiration and scopes (if they are provided in [`RequireBearerTokenOptions.Scopes`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#RequireBearerTokenOptions.Scopes)), so the -`TokenVerifer` doesn't have to. +`TokenVerifier` doesn't have to. If [`RequireBearerTokenOptions.ResourceMetadataURL`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#RequireBearerTokenOptions.ResourceMetadataURL) is set and verification fails, the middleware function sets the WWW-Authenticate header as required by the [Protected Resource Metadata spec](https://datatracker.ietf.org/doc/html/rfc9728). diff --git a/mcp/client.go b/mcp/client.go index 74900b1..dc7bef1 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -641,7 +641,7 @@ func (c *Client) elicit(ctx context.Context, req *ElicitRequest) (*ElicitResult, } err = resolved.ApplyDefaults(&res.Content) if err != nil { - return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: fmt.Sprintf("failed to apply schema defalts to elicitation result: %v", err)} + return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: fmt.Sprintf("failed to apply schema defaults to elicitation result: %v", err)} } } return res, nil diff --git a/mcp/cmd_test.go b/mcp/cmd_test.go index 1bbff30..57f45a2 100644 --- a/mcp/cmd_test.go +++ b/mcp/cmd_test.go @@ -254,7 +254,7 @@ func TestCommandTransportTerminateDuration(t *testing.T) { requireExec(t) // Unfortunately, since it does I/O, this test needs to rely on timing (we - // can't use synctest). However, we can still decreate the default + // can't use synctest). However, we can still decrease the default // termination duration to speed up the test. const defaultDur = 50 * time.Millisecond defer mcp.SetDefaultTerminateDuration(defaultDur)() diff --git a/mcp/conformance_test.go b/mcp/conformance_test.go index 3695ad5..7f1a744 100644 --- a/mcp/conformance_test.go +++ b/mcp/conformance_test.go @@ -323,7 +323,7 @@ func runServerTest(t *testing.T, test *conformanceTest) { // Before closing the stream, wait for all messages to be processed. synctest.Wait() if err != nil { - t.Fatalf("reading server messages failedd: %v", err) + t.Fatalf("reading server messages failed: %v", err) } if extra != nil { t.Fatalf("got extra response: %v", extra) diff --git a/mcp/protocol.go b/mcp/protocol.go index d071740..0b6bf47 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -1220,7 +1220,7 @@ type ToolChoice struct { // ElicitationCapabilities describes the capabilities for elicitation. // -// If neither Form nor URL is set, the 'Form' capabilitiy is assumed. +// If neither Form nor URL is set, the 'Form' capability is assumed. type ElicitationCapabilities struct { Form *FormElicitationCapabilities `json:"form,omitempty"` URL *URLElicitationCapabilities `json:"url,omitempty"` @@ -1552,7 +1552,7 @@ type ServerCapabilities struct { Logging *LoggingCapabilities `json:"logging,omitempty"` // Prompts is present if the server supports prompts. Prompts *PromptCapabilities `json:"prompts,omitempty"` - // Resources is present if the server supports resourcs. + // Resources is present if the server supports resources. Resources *ResourceCapabilities `json:"resources,omitempty"` // Tools is present if the supports tools. Tools *ToolCapabilities `json:"tools,omitempty"` diff --git a/mcp/server.go b/mcp/server.go index c13955c..207276c 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1293,7 +1293,7 @@ func (ss *ServerSession) Elicit(ctx context.Context, params *ElicitParams) (*Eli } err = resolved.ApplyDefaults(&res.Content) if err != nil { - return nil, fmt.Errorf("failed to apply schema defalts to elicitation result: %v", err) + return nil, fmt.Errorf("failed to apply schema defaults to elicitation result: %v", err) } return res, nil diff --git a/mcp/streamable.go b/mcp/streamable.go index 7f09b40..ceb1742 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -97,7 +97,7 @@ func (i *sessionInfo) startPOST() { i.refs++ } -// endPOST sigals that a request for this session is ending, starting the +// endPOST signals that a request for this session is ending, starting the // timeout if there are no other requests running. func (i *sessionInfo) endPOST() { if i.timeout <= 0 { @@ -1203,7 +1203,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // // Create a logical stream to track its responses. // Important: don't publish the incoming messages until the stream is - // registered, as the server may attempt to respond to imcoming messages as + // registered, as the server may attempt to respond to incoming messages as // soon as they're published. stream, err := c.newStream(req.Context(), calls, crand.Text()) if err != nil { diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index d1dc482..ea75940 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1024,7 +1024,7 @@ func TestStreamableServerTransport(t *testing.T) { { name: "background", // Enabling replay is necessary here because the standalone "GET" request - // is fully asynronous. Replay is needed to guarantee message delivery. + // is fully asynchronous. Replay is needed to guarantee message delivery. // // TODO(rfindley): this should no longer be necessary. replay: true, From 74d2751d88c771bea44778ffacf2539623b10c80 Mon Sep 17 00:00:00 2001 From: Ravish Gupta Date: Tue, 31 Mar 2026 01:14:36 -0700 Subject: [PATCH 235/418] mcp: return input validation errors as tool results, not JSON-RPC errors (#863) This implements [SEP-1303](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1303). Input validation errors (missing required fields, wrong types) during tool calls are now returned as `CallToolResult` with `IsError=true` instead of JSON-RPC `-32602` errors. This allows LLMs to see the validation error and self-correct, per the MCP spec. The existing pattern for handler-returned errors (lines 347-350 in server.go) already converts regular errors to tool results with `IsError=true`. This change applies the same pattern to argument schema validation and unmarshal errors, resolving the `TODO(#450)` in the code. Thanks to @iwyrkore for filing the issue and proposing the approach, and @findleyr for the cross-SDK analysis in the discussion. Fixes #450 Co-authored-by: Maciej Kisiel --- mcp/error_test.go | 52 ++++++++++++++++----- mcp/server.go | 9 ++-- mcp/server_test.go | 13 ++++-- mcp/testdata/conformance/server/tools.txtar | 22 ++++++--- 4 files changed, 73 insertions(+), 23 deletions(-) diff --git a/mcp/error_test.go b/mcp/error_test.go index dad47aa..47a99f8 100644 --- a/mcp/error_test.go +++ b/mcp/error_test.go @@ -51,17 +51,9 @@ func TestServerErrors(t *testing.T) { executeCall func() error expectedCode int64 }{ - { - name: "missing required param", - executeCall: func() error { - _, err := cs.CallTool(ctx, &CallToolParams{ - Name: "validate", - Arguments: map[string]any{}, // Missing required "name" field - }) - return err - }, - expectedCode: jsonrpc.CodeInvalidParams, - }, + // Note: "missing required param" is tested separately below, because + // input validation errors are returned as tool results with IsError=true + // rather than JSON-RPC errors (see #450). { name: "unknown tool", executeCall: func() error { @@ -119,6 +111,44 @@ func TestServerErrors(t *testing.T) { } } +// TestInputValidationToolError validates that input validation errors (missing +// required params, wrong types) are returned as tool results with IsError=true, +// not as JSON-RPC errors. This allows LLMs to see the error and self-correct. +// See #450. +func TestInputValidationToolError(t *testing.T) { + ctx := context.Background() + + type RequiredParams struct { + Name string `json:"name" jsonschema:"the name is required"` + } + handler := func(ctx context.Context, req *CallToolRequest, args RequiredParams) (*CallToolResult, any, error) { + return &CallToolResult{ + Content: []Content{&TextContent{Text: "success"}}, + }, nil, nil + } + + cs, _, cleanup := basicConnection(t, func(s *Server) { + AddTool(s, &Tool{Name: "validate", Description: "validates params"}, handler) + }) + defer cleanup() + + // Call the tool with missing required "name" field. + result, err := cs.CallTool(ctx, &CallToolParams{ + Name: "validate", + Arguments: map[string]any{}, + }) + if err != nil { + t.Fatalf("CallTool returned error: %v; want tool result with IsError", err) + } + if !result.IsError { + t.Fatal("got IsError=false, want IsError=true for missing required param") + } + text := result.Content[0].(*TextContent).Text + if !strings.Contains(text, "name") { + t.Errorf("error text %q does not mention missing field \"name\"", text) + } +} + // TestURLElicitationRequired validates that URL elicitation required errors // are properly created and handled by the client. func TestURLElicitationRequired(t *testing.T) { diff --git a/mcp/server.go b/mcp/server.go index 207276c..97b5d44 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -321,15 +321,18 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out], cache *SchemaCa var err error input, err = applySchema(input, inputResolved) if err != nil { - // TODO(#450): should this be considered a tool error? (and similar below) - return nil, fmt.Errorf("%w: validating \"arguments\": %v", jsonrpc2.ErrInvalidParams, err) + var errRes CallToolResult + errRes.SetError(fmt.Errorf("validating \"arguments\": %v", err)) + return &errRes, nil } // Unmarshal and validate args. var in In if input != nil { if err := internaljson.Unmarshal(input, &in); err != nil { - return nil, fmt.Errorf("%w: %v", jsonrpc2.ErrInvalidParams, err) + var errRes CallToolResult + errRes.SetError(err) + return &errRes, nil } } diff --git a/mcp/server_test.go b/mcp/server_test.go index e57af1e..85c3990 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -676,12 +676,19 @@ func testToolForSchema[In, Out any](t *testing.T, tool *Tool, in string, out Out } result, err := goth(context.Background(), ctr) if wantErrContaining != "" { - if err == nil { - t.Errorf("got nil error, want error containing %q", wantErrContaining) - } else { + // Input validation errors are returned as tool results with IsError=true, + // not as Go errors. Check both possibilities. + if err != nil { if !strings.Contains(err.Error(), wantErrContaining) { t.Errorf("got error %q, want containing %q", err, wantErrContaining) } + } else if result != nil && result.IsError { + text := result.Content[0].(*TextContent).Text + if !strings.Contains(text, wantErrContaining) { + t.Errorf("got tool error %q, want containing %q", text, wantErrContaining) + } + } else { + t.Errorf("got no error, want error containing %q", wantErrContaining) } } else if err != nil { t.Errorf("got error %v, want no error", err) diff --git a/mcp/testdata/conformance/server/tools.txtar b/mcp/testdata/conformance/server/tools.txtar index ba0facc..aadad12 100644 --- a/mcp/testdata/conformance/server/tools.txtar +++ b/mcp/testdata/conformance/server/tools.txtar @@ -222,9 +222,14 @@ inc { "jsonrpc": "2.0", "id": 8, - "error": { - "code": -32602, - "message": "invalid params: validating \"arguments\": validating root: required: missing properties: [\"In\"]" + "result": { + "content": [ + { + "type": "text", + "text": "validating \"arguments\": validating root: required: missing properties: [\"In\"]" + } + ], + "isError": true } } { @@ -245,9 +250,14 @@ inc { "jsonrpc": "2.0", "id": 10, - "error": { - "code": -32602, - "message": "invalid params: validating \"arguments\": validating root: required: missing properties: [\"Name\"]" + "result": { + "content": [ + { + "type": "text", + "text": "validating \"arguments\": validating root: required: missing properties: [\"Name\"]" + } + ], + "isError": true } } { From a1f808c86e7a98add824b25cb03ddf4998515bfd Mon Sep 17 00:00:00 2001 From: Oleksandr Redko Date: Tue, 31 Mar 2026 11:58:02 +0300 Subject: [PATCH 236/418] all: modernize code (#868) Use `go fix ./...` to modernize the code. --- auth/extauth/enterprise_handler_test.go | 16 ++++----- auth/extauth/oidc_login_test.go | 10 +++--- examples/client/loadtest/main.go | 6 ++-- examples/server/distributed/main.go | 12 +++---- .../oauthtest/fake_authorization_server.go | 2 +- mcp/cmd_test.go | 6 ++-- mcp/content_nil_test.go | 10 +++--- mcp/mcp_test.go | 6 ++-- mcp/schema_cache_test.go | 2 +- mcp/streamable_bench_test.go | 7 ++-- mcp/streamable_test.go | 34 ++++++------------- oauthex/token_exchange_test.go | 2 +- 12 files changed, 44 insertions(+), 69 deletions(-) diff --git a/auth/extauth/enterprise_handler_test.go b/auth/extauth/enterprise_handler_test.go index 08c042b..7f1d521 100644 --- a/auth/extauth/enterprise_handler_test.go +++ b/auth/extauth/enterprise_handler_test.go @@ -36,7 +36,7 @@ func TestNewEnterpriseHandler_Validation(t *testing.T) { AccessToken: "mock_access_token", TokenType: "Bearer", } - return token.WithExtra(map[string]interface{}{"id_token": "mock_id_token"}), nil + return token.WithExtra(map[string]any{"id_token": "mock_id_token"}), nil }, } @@ -194,7 +194,7 @@ func TestNewEnterpriseHandler_Validation(t *testing.T) { AccessToken: "mock_access_token", TokenType: "Bearer", } - return token.WithExtra(map[string]interface{}{"id_token": "mock_id_token"}), nil + return token.WithExtra(map[string]any{"id_token": "mock_id_token"}), nil }, }, wantError: "", @@ -254,7 +254,7 @@ func TestEnterpriseHandler_Authorize_E2E(t *testing.T) { AccessToken: "mock_access_token", TokenType: "Bearer", } - return token.WithExtra(map[string]interface{}{"id_token": "mock_id_token_from_user_login"}), nil + return token.WithExtra(map[string]any{"id_token": "mock_id_token_from_user_login"}), nil }, }) if err != nil { @@ -305,7 +305,7 @@ func setupIdPServer(t *testing.T) *httptest.Server { // OAuth/OIDC metadata endpoint - uses closure to get server URL mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ + json.NewEncoder(w).Encode(map[string]any{ "issuer": server.URL, "token_endpoint": server.URL + "/token", "authorization_endpoint": server.URL + "/authorize", @@ -350,7 +350,7 @@ func setupIdPServer(t *testing.T) *httptest.Server { // Return ID-JAG (Identity Assertion JWT Authorization Grant) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ + json.NewEncoder(w).Encode(map[string]any{ "access_token": "id-jag-token-from-idp", "issued_token_type": oauthex.TokenTypeIDJAG, "token_type": "N_A", @@ -375,7 +375,7 @@ func setupMCPAuthServer(t *testing.T) *httptest.Server { // OAuth metadata endpoint - uses closure to get server URL mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ + json.NewEncoder(w).Encode(map[string]any{ "issuer": server.URL, "token_endpoint": server.URL + "/token", "code_challenge_methods_supported": []string{"S256"}, @@ -412,7 +412,7 @@ func setupMCPAuthServer(t *testing.T) *httptest.Server { // Return access token w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ + json.NewEncoder(w).Encode(map[string]any{ "access_token": "mcp_access_token_from_jwt_bearer", "token_type": "Bearer", "expires_in": 3600, @@ -480,7 +480,7 @@ func TestEnterpriseHandler_TokenSource_BeforeAuthorization(t *testing.T) { AccessToken: "mock_access_token", TokenType: "Bearer", } - return token.WithExtra(map[string]interface{}{"id_token": "mock_id_token"}), nil + return token.WithExtra(map[string]any{"id_token": "mock_id_token"}), nil }, }) if err != nil { diff --git a/auth/extauth/oidc_login_test.go b/auth/extauth/oidc_login_test.go index 298158b..53d0bb8 100644 --- a/auth/extauth/oidc_login_test.go +++ b/auth/extauth/oidc_login_test.go @@ -327,7 +327,7 @@ func createMockOIDCServer(t *testing.T) *httptest.Server { // Handle OIDC discovery if r.URL.Path == "/.well-known/openid-configuration" { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ + json.NewEncoder(w).Encode(map[string]any{ "issuer": serverURL, "authorization_endpoint": serverURL + "/authorize", "token_endpoint": serverURL + "/token", @@ -351,7 +351,7 @@ func createMockOIDCServerWithToken(t *testing.T) *httptest.Server { // Handle OIDC discovery if r.URL.Path == "/.well-known/openid-configuration" { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ + json.NewEncoder(w).Encode(map[string]any{ "issuer": serverURL, "authorization_endpoint": serverURL + "/authorize", "token_endpoint": serverURL + "/token", @@ -376,7 +376,7 @@ func createMockOIDCServerWithToken(t *testing.T) *httptest.Server { // Create mock ID token (JWT) now := time.Now().Unix() idToken := fmt.Sprintf("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.%s.mock-signature", - base64EncodeClaims(map[string]interface{}{ + base64EncodeClaims(map[string]any{ "iss": serverURL, "sub": "test-user", "aud": "test-client", @@ -386,7 +386,7 @@ func createMockOIDCServerWithToken(t *testing.T) *httptest.Server { })) // Return token response w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ + json.NewEncoder(w).Encode(map[string]any{ "access_token": "mock-access-token", "token_type": "Bearer", "expires_in": 3600, @@ -402,7 +402,7 @@ func createMockOIDCServerWithToken(t *testing.T) *httptest.Server { } // base64EncodeClaims encodes JWT claims for testing. -func base64EncodeClaims(claims map[string]interface{}) string { +func base64EncodeClaims(claims map[string]any) string { claimsJSON, _ := json.Marshal(claims) return base64.RawURLEncoding.EncodeToString(claimsJSON) } diff --git a/examples/client/loadtest/main.go b/examples/client/loadtest/main.go index cbf7cd6..31b74a6 100644 --- a/examples/client/loadtest/main.go +++ b/examples/client/loadtest/main.go @@ -69,9 +69,7 @@ func main() { // Run the test. var wg sync.WaitGroup for range *workers { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { client := mcp.NewClient(&mcp.Implementation{Name: "mcp-client", Version: "v1.0.0"}, nil) cs, err := client.Connect(parentCtx, &mcp.StreamableClientTransport{Endpoint: args[0]}, nil) if err != nil { @@ -108,7 +106,7 @@ func main() { } } } - }() + }) } wg.Wait() stop() // restore the interrupt signal diff --git a/examples/server/distributed/main.go b/examples/server/distributed/main.go index b144040..0e5dfe5 100644 --- a/examples/server/distributed/main.go +++ b/examples/server/distributed/main.go @@ -89,16 +89,14 @@ func parent() { cmd.Env = append(os.Environ(), fmt.Sprintf("%s=%s", childPortVar, port)) cmd.Stderr = os.Stderr - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { log.Printf("starting child %d at %s", i, childURL) if err := cmd.Run(); err != nil && ctx.Err() == nil { log.Printf("child %d failed: %v", i, err) } else { log.Printf("child %d exited gracefully", i) } - }() + }) } // Start a reverse proxy that round-robin's requests to each backend. @@ -116,13 +114,11 @@ func parent() { }, } - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { if err := server.ListenAndServe(); err != nil && ctx.Err() == nil { log.Printf("Server failed: %v", err) } - }() + }) log.Printf("Serving at %s (CTRL-C to cancel)", *httpAddr) diff --git a/internal/oauthtest/fake_authorization_server.go b/internal/oauthtest/fake_authorization_server.go index 1e81f10..456c639 100644 --- a/internal/oauthtest/fake_authorization_server.go +++ b/internal/oauthtest/fake_authorization_server.go @@ -282,7 +282,7 @@ func (s *FakeAuthorizationServer) handleToken(w http.ResponseWriter, r *http.Req } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ + json.NewEncoder(w).Encode(map[string]any{ "access_token": "test_access_token", "token_type": "Bearer", "expires_in": 3600, diff --git a/mcp/cmd_test.go b/mcp/cmd_test.go index 57f45a2..7c98207 100644 --- a/mcp/cmd_test.go +++ b/mcp/cmd_test.go @@ -200,8 +200,7 @@ func TestStdioContextCancellation(t *testing.T) { func TestCmdTransport(t *testing.T) { requireExec(t) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + ctx := t.Context() cmd := createServerCommand(t, "default") @@ -287,8 +286,7 @@ func TestCommandTransportTerminateDuration(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + ctx := t.Context() // Use a command that won't exit when stdin is closed cmd := exec.Command("sleep", "20") diff --git a/mcp/content_nil_test.go b/mcp/content_nil_test.go index 8cc7bdb..ba263e6 100644 --- a/mcp/content_nil_test.go +++ b/mcp/content_nil_test.go @@ -22,8 +22,8 @@ func TestContentUnmarshalNil(t *testing.T) { tests := []struct { name string json string - content interface{} - want interface{} + content any + want any }{ { name: "CallToolResult nil Content", @@ -83,7 +83,7 @@ func TestContentUnmarshalNilWithDifferentTypes(t *testing.T) { tests := []struct { name string json string - content interface{} + content any expectError bool }{ { @@ -145,7 +145,7 @@ func TestContentUnmarshalNilWithEmptyContent(t *testing.T) { tests := []struct { name string json string - content interface{} + content any expectError bool }{ { @@ -186,7 +186,7 @@ func TestContentUnmarshalNilWithInvalidContent(t *testing.T) { tests := []struct { name string json string - content interface{} + content any expectError bool }{ { diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 7380ad5..5a20e4c 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -676,13 +676,11 @@ func TestServerClosing(t *testing.T) { ctx := context.Background() var wg sync.WaitGroup - wg.Add(1) - go func() { + wg.Go(func() { if err := cs.Wait(); err != nil { t.Errorf("server connection failed: %v", err) } - wg.Done() - }() + }) if _, err := cs.CallTool(ctx, &CallToolParams{ Name: "greet", Arguments: map[string]any{"Name": "user"}, diff --git a/mcp/schema_cache_test.go b/mcp/schema_cache_test.go index bba5e24..b4d6ef6 100644 --- a/mcp/schema_cache_test.go +++ b/mcp/schema_cache_test.go @@ -194,7 +194,7 @@ func TestAddToolWithSharedCache(t *testing.T) { } // Simulate stateless server pattern: new server per request, shared cache. - for i := 0; i < 3; i++ { + for range 3 { s := NewServer(&Implementation{Name: "test", Version: "1.0"}, &ServerOptions{ SchemaCache: cache, }) diff --git a/mcp/streamable_bench_test.go b/mcp/streamable_bench_test.go index 6157e98..fc7b0ef 100644 --- a/mcp/streamable_bench_test.go +++ b/mcp/streamable_bench_test.go @@ -5,7 +5,6 @@ package mcp_test import ( - "context" "flag" "log" "net/http" @@ -51,8 +50,7 @@ func BenchmarkStreamableServing(b *testing.B) { httpServer := httptest.NewServer(handler) defer httpServer.Close() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + ctx := b.Context() session, err := mcp.NewClient(testImpl, nil).Connect(ctx, &mcp.StreamableClientTransport{Endpoint: httpServer.URL}, nil) if err != nil { b.Fatal(err) @@ -84,8 +82,7 @@ func BenchmarkStreamableServing_BadSessions(b *testing.B) { httpServer := httptest.NewServer(handler) defer httpServer.Close() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + ctx := b.Context() if *streamableHeap != "" { writeHeap := func(file string) { diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index ea75940..3da9055 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -108,9 +108,7 @@ func TestStreamableTransports(t *testing.T) { } // Now, spin off a goroutine that runs after the sampling request, to // check behavior when the client request has completed. - sampleWG.Add(1) - go func() { - defer sampleWG.Done() + sampleWG.Go(func() { <-sampleDone // Test that sampling requests in the tool context fail outside of // tool handling, but succeed on the background context. @@ -120,7 +118,7 @@ func TestStreamableTransports(t *testing.T) { } { testSample(test) } - }() + }) return &CallToolResult{}, nil, nil }) @@ -268,9 +266,7 @@ func TestStreamableConcurrentHandling(t *testing.T) { client := NewClient(testImpl, nil) var wg sync.WaitGroup for range 100 { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { clientSession, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, nil) if err != nil { t.Errorf("Connect failed: %v", err) @@ -287,7 +283,7 @@ func TestStreamableConcurrentHandling(t *testing.T) { t.Errorf("got count %d, want %d", got, i) } } - }() + }) } wg.Wait() } @@ -1293,9 +1289,7 @@ func testStreamableHandler(t *testing.T, handler http.Handler, requests []stream ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { for m := range out { if req, ok := m.(*jsonrpc.Request); ok && req.IsCall() { @@ -1315,7 +1309,7 @@ func testStreamableHandler(t *testing.T, handler http.Handler, requests []stream cancel() } } - }() + }) gotSessionID, gotStatusCode, gotBody, err := request.do(ctx, httpServer.URL, sessionID.Load().(string), out) @@ -1353,11 +1347,9 @@ func testStreamableHandler(t *testing.T, handler http.Handler, requests []stream var wg sync.WaitGroup for i, request := range requests { if request.async || request.onRequest > 0 { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { doStep(t, i, request) - }() + }) } else { doStep(t, i, request) } @@ -2071,18 +2063,15 @@ func TestStreamableSessionTimeout(t *testing.T) { // Spin up two goroutines, each making a request every 10ms. These requests // should keep the server from timing out. var wg sync.WaitGroup - wg.Add(2) for range 2 { - go func() { - defer wg.Done() - + wg.Go(func() { for range 20 { if _, err := session.ListTools(ctx, nil); err != nil { t.Errorf("ListTools failed: %v", err) } time.Sleep(10 * time.Millisecond) } - }() + }) } wg.Wait() @@ -2154,8 +2143,7 @@ data: keepalive } // Create a minimal streamableClientConn for testing - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + ctx := t.Context() incoming := make(chan jsonrpc.Message, 10) done := make(chan struct{}) diff --git a/oauthex/token_exchange_test.go b/oauthex/token_exchange_test.go index d5a8a16..71edf46 100644 --- a/oauthex/token_exchange_test.go +++ b/oauthex/token_exchange_test.go @@ -97,7 +97,7 @@ func TestExchangeToken(t *testing.T) { } // Return successful token exchange response per SEP-990 Section 4.2 - resp := map[string]interface{}{ + resp := map[string]any{ "issued_token_type": TokenTypeIDJAG, "access_token": "fake-id-jag-token", "token_type": "N_A", From c1a12cfeda4983a995486bea167becbf8329d7df Mon Sep 17 00:00:00 2001 From: Kalvin C Date: Tue, 31 Mar 2026 02:17:49 -0700 Subject: [PATCH 237/418] mcp: accept parameterized Accept media types (#853) Normalize accept tokens before validation so valid headers with media type parameters still match their base media types. This allows the streamable mcp server to validate the following header `Accept: application/json;charset=utf-8, text/event-stream` Co-authored-by: Maciej Kisiel --- mcp/streamable.go | 34 +++++++++++++++---------- mcp/streamable_test.go | 57 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 75 insertions(+), 16 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index ceb1742..0482c79 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -274,19 +274,7 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque // Allow multiple 'Accept' headers. // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept#syntax - accept := strings.Split(strings.Join(req.Header.Values("Accept"), ","), ",") - var jsonOK, streamOK bool - for _, c := range accept { - switch strings.TrimSpace(c) { - case "application/json", "application/*": - jsonOK = true - case "text/event-stream", "text/*": - streamOK = true - case "*/*": - jsonOK = true - streamOK = true - } - } + jsonOK, streamOK := streamableAccepts(req.Header.Values("Accept")) if req.Method == http.MethodGet { if !streamOK { @@ -551,6 +539,26 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque sessInfo.transport.ServeHTTP(w, req) } +func streamableAccepts(values []string) (jsonOK, streamOK bool) { + for _, value := range values { + for _, raw := range strings.Split(value, ",") { + token := strings.TrimSpace(raw) + // Ignore Accept parameters like ";charset=utf-8"; match the base media type. + base, _, _ := strings.Cut(token, ";") + switch strings.ToLower(strings.TrimSpace(base)) { + case "application/json", "application/*": + jsonOK = true + case "text/event-stream", "text/*": + streamOK = true + case "*/*": + jsonOK = true + streamOK = true + } + } + } + return jsonOK, streamOK +} + // A StreamableServerTransport implements the server side of the MCP streamable // transport. // diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 3da9055..d6eb8e9 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -892,10 +892,24 @@ func TestStreamableServerTransport(t *testing.T) { }, { method: "POST", - headers: http.Header{"Accept": {"text/*, application/*"}}, - messages: []jsonrpc.Message{req(4, "tools/call", &CallToolParams{Name: "tool"})}, + headers: http.Header{"Accept": {"application/json;charset=utf-8, text/event-stream"}}, + messages: []jsonrpc.Message{req(5, "tools/call", &CallToolParams{Name: "tool"})}, wantStatusCode: http.StatusOK, - wantMessages: []jsonrpc.Message{resp(4, &CallToolResult{Content: []Content{}}, nil)}, + wantMessages: []jsonrpc.Message{resp(5, &CallToolResult{Content: []Content{}}, nil)}, + }, + { + method: "POST", + headers: http.Header{"Accept": {"application/json;charset=utf-8", "text/event-stream"}}, + messages: []jsonrpc.Message{req(6, "tools/call", &CallToolParams{Name: "tool"})}, + wantStatusCode: http.StatusOK, + wantMessages: []jsonrpc.Message{resp(6, &CallToolResult{Content: []Content{}}, nil)}, + }, + { + method: "POST", + headers: http.Header{"Accept": {"text/*, application/*"}}, + messages: []jsonrpc.Message{req(7, "tools/call", &CallToolParams{Name: "tool"})}, + wantStatusCode: http.StatusOK, + wantMessages: []jsonrpc.Message{resp(7, &CallToolResult{Content: []Content{}}, nil)}, }, }, wantSessions: 1, @@ -1952,12 +1966,49 @@ func TestStreamableGETWithoutSession(t *testing.T) { t.Fatal(err) } defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } // GET without session should return 400 Bad Request, not 405 Method Not Allowed, // because GET is a valid method - it just requires a session ID. if got, want := resp.StatusCode, http.StatusBadRequest; got != want { t.Errorf("status code: got %d, want %d", got, want) } + if got, want := strings.TrimSpace(string(body)), "Bad Request: GET requires an Mcp-Session-Id header"; got != want { + t.Errorf("body: got %q, want %q", got, want) + } +} + +func TestStreamableGETWithoutEventStreamAccept(t *testing.T) { + server := NewServer(testImpl, nil) + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + defer httpServer.Close() + + req, err := http.NewRequest("GET", httpServer.URL, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + + if got, want := resp.StatusCode, http.StatusBadRequest; got != want { + t.Errorf("status code: got %d, want %d", got, want) + } + if got, want := strings.TrimSpace(string(body)), "Accept must contain 'text/event-stream' for GET requests"; got != want { + t.Errorf("body: got %q, want %q", got, want) + } } func TestStreamableClientContextPropagation(t *testing.T) { From 638d404e0dc257162cc5e909a68a2445f795a59d Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Tue, 31 Mar 2026 12:36:31 +0200 Subject: [PATCH 238/418] all: stabilize client OAuth support (#861) Also fix lint errors that were hidden because of the build tag. --- .github/workflows/conformance.yml | 2 +- auth/authorization_code.go | 7 +- auth/authorization_code_test.go | 2 - auth/client_private.go | 135 ------------- auth/client_test.go | 153 --------------- auth/extauth/enterprise_handler.go | 2 - auth/extauth/enterprise_handler_test.go | 2 - auth/extauth/oidc_login.go | 6 +- auth/extauth/oidc_login_test.go | 4 +- auth/shared.go | 2 - auth/shared_test.go | 2 - .../everything-client/client_private.go | 139 -------------- conformance/everything-client/main.go | 118 ++++++++++++ docs/protocol.md | 5 - examples/auth/client/main.go | 3 - examples/auth/enterprise/main.go | 3 - internal/docs/protocol.src.md | 5 - .../oauthtest/fake_authorization_server.go | 2 - mcp/streamable_client_auth_test.go | 179 ------------------ mcp/streamable_client_test.go | 158 ++++++++++++++++ oauthex/auth_meta.go | 2 - oauthex/auth_meta_test.go | 2 - oauthex/client.go | 2 - oauthex/dcr.go | 2 - oauthex/dcr_test.go | 2 - oauthex/oauth2.go | 2 - oauthex/oauthex_test.go | 2 - oauthex/resource_meta.go | 155 ++++++++------- oauthex/resource_meta_public.go | 105 ---------- .../{oauth2_test.go => resource_meta_test.go} | 84 ++------ oauthex/token_exchange.go | 2 - oauthex/token_exchange_test.go | 2 - oauthex/url_scheme_test.go | 11 +- scripts/client-conformance.sh | 2 +- 34 files changed, 388 insertions(+), 916 deletions(-) delete mode 100644 auth/client_private.go delete mode 100644 auth/client_test.go delete mode 100644 conformance/everything-client/client_private.go delete mode 100644 mcp/streamable_client_auth_test.go delete mode 100644 oauthex/resource_meta_public.go rename oauthex/{oauth2_test.go => resource_meta_test.go} (70%) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index c33a0f9..d4329ed 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -50,7 +50,7 @@ jobs: uses: modelcontextprotocol/conformance@a2855b03582a6c0b31065ad4d9af248316ce61a3 # v0.1.15 with: mode: client - command: go run -tags mcp_go_client_oauth ./conformance/everything-client + command: go run ./conformance/everything-client suite: core expected-failures: ./conformance/baseline.yml node-version: 22 diff --git a/auth/authorization_code.go b/auth/authorization_code.go index 1389ec2..846feb7 100644 --- a/auth/authorization_code.go +++ b/auth/authorization_code.go @@ -2,8 +2,6 @@ // Use of this source code is governed by the license // that can be found in the LICENSE file. -//go:build mcp_go_client_oauth - package auth import ( @@ -142,7 +140,7 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho dCfg := config.DynamicClientRegistrationConfig if dCfg != nil { if dCfg.Metadata == nil { - return nil, errors.New("Metadata is required for dynamic client registration") + return nil, errors.New("dynamic client registration requires non-nil Metadata") } if len(dCfg.Metadata.RedirectURIs) == 0 { return nil, errors.New("Metadata.RedirectURIs is required for dynamic client registration") @@ -283,17 +281,14 @@ func errorFromChallenges(cs []oauthex.Challenge) string { // If no metadata was found or the fetched metadata fails security checks, // it returns an error. func (h *AuthorizationCodeHandler) getProtectedResourceMetadata(ctx context.Context, wwwChallenges []oauthex.Challenge, mcpServerURL string) (*oauthex.ProtectedResourceMetadata, error) { - var errs []error // Use MCP server URL as the resource URI per // https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#canonical-server-uri. for _, url := range protectedResourceMetadataURLs(resourceMetadataURLFromChallenges(wwwChallenges), mcpServerURL) { prm, err := oauthex.GetProtectedResourceMetadata(ctx, url.URL, url.Resource, h.config.Client) if err != nil { - errs = append(errs, err) continue } if prm == nil { - errs = append(errs, fmt.Errorf("protected resource metadata is nil")) continue } if len(prm.AuthorizationServers) == 0 { diff --git a/auth/authorization_code_test.go b/auth/authorization_code_test.go index 879031a..cd4741a 100644 --- a/auth/authorization_code_test.go +++ b/auth/authorization_code_test.go @@ -2,8 +2,6 @@ // Use of this source code is governed by the license // that can be found in the LICENSE file. -//go:build mcp_go_client_oauth - package auth import ( diff --git a/auth/client_private.go b/auth/client_private.go deleted file mode 100644 index 767c59e..0000000 --- a/auth/client_private.go +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright 2025 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -//go:build mcp_go_client_oauth - -package auth - -import ( - "bytes" - "errors" - "io" - "net/http" - "sync" - - "golang.org/x/oauth2" -) - -// An OAuthHandlerLegacy conducts an OAuth flow and returns a [oauth2.TokenSource] if the authorization -// is approved, or an error if not. -// The handler receives the HTTP request and response that triggered the authentication flow. -// To obtain the protected resource metadata, call [oauthex.GetProtectedResourceMetadataFromHeader]. -// -// Deprecated: Please use the new [OAuthHandler] abstraction that is built -// into the streamable transport. This struct will be removed in v1.5.0. -type OAuthHandlerLegacy func(req *http.Request, res *http.Response) (oauth2.TokenSource, error) - -// HTTPTransport is an [http.RoundTripper] that follows the MCP -// OAuth protocol when it encounters a 401 Unauthorized response. -// -// Deprecated: Please use the new [OAuthHandler] abstraction that is built -// into the streamable transport. This struct will be removed in v1.5.0. -type HTTPTransport struct { - handler OAuthHandlerLegacy - mu sync.Mutex // protects opts.Base - opts HTTPTransportOptions -} - -// NewHTTPTransport returns a new [*HTTPTransport]. -// The handler is invoked when an HTTP request results in a 401 Unauthorized status. -// It is called only once per transport. Once a TokenSource is obtained, it is used -// for the lifetime of the transport; subsequent 401s are not processed. -// -// Deprecated: Please use the new [OAuthHandler] abstraction that is built -// into the streamable transport. This struct will be removed in v1.5.0. -func NewHTTPTransport(handler OAuthHandlerLegacy, opts *HTTPTransportOptions) (*HTTPTransport, error) { - if handler == nil { - return nil, errors.New("handler cannot be nil") - } - t := &HTTPTransport{ - handler: handler, - } - if opts != nil { - t.opts = *opts - } - if t.opts.Base == nil { - t.opts.Base = http.DefaultTransport - } - return t, nil -} - -// HTTPTransportOptions are options to [NewHTTPTransport]. -// -// Deprecated: Please use the new [OAuthHandler] abstraction that is built -// into the streamable transport. This struct will be removed in v1.5.0. -type HTTPTransportOptions struct { - // Base is the [http.RoundTripper] to use. - // If nil, [http.DefaultTransport] is used. - Base http.RoundTripper -} - -func (t *HTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { - t.mu.Lock() - base := t.opts.Base - t.mu.Unlock() - - var ( - // If haveBody is set, the request has a nontrivial body, and we need avoid - // reading (or closing) it multiple times. In that case, bodyBytes is its - // content. - haveBody bool - bodyBytes []byte - ) - if req.Body != nil && req.Body != http.NoBody { - // if we're setting Body, we must mutate first. - req = req.Clone(req.Context()) - haveBody = true - var err error - bodyBytes, err = io.ReadAll(req.Body) - if err != nil { - return nil, err - } - // Now that we've read the request body, http.RoundTripper requires that we - // close it. - req.Body.Close() // ignore error - req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) - } - - resp, err := base.RoundTrip(req) - if err != nil { - return nil, err - } - if resp.StatusCode != http.StatusUnauthorized { - return resp, nil - } - if _, ok := base.(*oauth2.Transport); ok { - // We failed to authorize even with a token source; give up. - return resp, nil - } - - resp.Body.Close() - // Try to authorize. - t.mu.Lock() - defer t.mu.Unlock() - // If we don't have a token source, get one by following the OAuth flow. - // (We may have obtained one while t.mu was not held above.) - // TODO: We hold the lock for the entire OAuth flow. This could be a long - // time. Is there a better way? - if _, ok := t.opts.Base.(*oauth2.Transport); !ok { - ts, err := t.handler(req, resp) - if err != nil { - return nil, err - } - t.opts.Base = &oauth2.Transport{Base: t.opts.Base, Source: ts} - } - - // If we don't have a body, the request is reusable, though it will be cloned - // by the base. However, if we've had to read the body, we must clone. - if haveBody { - req = req.Clone(req.Context()) - req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) - } - - return t.opts.Base.RoundTrip(req) -} diff --git a/auth/client_test.go b/auth/client_test.go deleted file mode 100644 index 4f3172d..0000000 --- a/auth/client_test.go +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright 2025 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -//go:build mcp_go_client_oauth - -package auth - -import ( - "errors" - "fmt" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "golang.org/x/oauth2" -) - -// A basicReader is an io.Reader to be used as a non-rereadable request body. -// -// net/http has special handling for strings.Reader that we want to avoid. -type basicReader struct { - r *strings.Reader -} - -func (r *basicReader) Read(p []byte) (n int, err error) { return r.r.Read(p) } - -// TestHTTPTransport validates the OAuth HTTPTransport. -func TestHTTPTransport(t *testing.T) { - const testToken = "test-token-123" - fakeTokenSource := oauth2.StaticTokenSource(&oauth2.Token{ - AccessToken: testToken, - TokenType: "Bearer", - }) - - // authServer simulates a resource that requires OAuth. - authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - // Ensure that the body was properly cloned, by reading it completely. - // If the body is not cloned, reading it the second time may yield no - // bytes. - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - if len(body) == 0 { - http.Error(w, "empty body", http.StatusBadRequest) - return - } - } - authHeader := r.Header.Get("Authorization") - if authHeader == fmt.Sprintf("Bearer %s", testToken) { - w.WriteHeader(http.StatusOK) - return - } - - w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="http://metadata.example.com"`) - w.WriteHeader(http.StatusUnauthorized) - })) - defer authServer.Close() - - t.Run("successful auth flow", func(t *testing.T) { - var handlerCalls int - handler := func(req *http.Request, res *http.Response) (oauth2.TokenSource, error) { - handlerCalls++ - // Verify that the response has the expected WWW-Authenticate header - if res.Header.Get("WWW-Authenticate") != `Bearer resource_metadata="http://metadata.example.com"` { - t.Errorf("handler got WWW-Authenticate header %q", res.Header.Get("WWW-Authenticate")) - } - return fakeTokenSource, nil - } - - transport, err := NewHTTPTransport(handler, nil) - if err != nil { - t.Fatalf("NewHTTPTransport() failed: %v", err) - } - client := &http.Client{Transport: transport} - - resp, err := client.Get(authServer.URL) - if err != nil { - t.Fatalf("client.Get() failed: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - t.Errorf("got status %d, want %d", resp.StatusCode, http.StatusOK) - } - if handlerCalls != 1 { - t.Errorf("handler was called %d times, want 1", handlerCalls) - } - - // Second request should reuse the token and not call the handler again. - resp2, err := client.Get(authServer.URL) - if err != nil { - t.Fatalf("second client.Get() failed: %v", err) - } - defer resp2.Body.Close() - - if resp2.StatusCode != http.StatusOK { - t.Errorf("second request got status %d, want %d", resp2.StatusCode, http.StatusOK) - } - if handlerCalls != 1 { - t.Errorf("handler should still be called only once, but was %d", handlerCalls) - } - }) - - t.Run("request body is cloned", func(t *testing.T) { - handler := func(req *http.Request, res *http.Response) (oauth2.TokenSource, error) { - // Verify that the response has the expected WWW-Authenticate header - if res.Header.Get("WWW-Authenticate") != `Bearer resource_metadata="http://metadata.example.com"` { - t.Errorf("handler got WWW-Authenticate header %q", res.Header.Get("WWW-Authenticate")) - } - return fakeTokenSource, nil - } - - transport, err := NewHTTPTransport(handler, nil) - if err != nil { - t.Fatalf("NewHTTPTransport() failed: %v", err) - } - client := &http.Client{Transport: transport} - - resp, err := client.Post(authServer.URL, "application/json", &basicReader{strings.NewReader("{}")}) - if err != nil { - t.Fatalf("client.Post() failed: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - t.Errorf("got status %d, want %d", resp.StatusCode, http.StatusOK) - } - }) - - t.Run("handler returns error", func(t *testing.T) { - handlerErr := errors.New("user rejected auth") - handler := func(req *http.Request, res *http.Response) (oauth2.TokenSource, error) { - return nil, handlerErr - } - - transport, err := NewHTTPTransport(handler, nil) - if err != nil { - t.Fatalf("NewHTTPTransport() failed: %v", err) - } - client := &http.Client{Transport: transport} - - _, err = client.Get(authServer.URL) - if !errors.Is(err, handlerErr) { - t.Errorf("client.Get() returned error %v, want %v", err, handlerErr) - } - }) -} diff --git a/auth/extauth/enterprise_handler.go b/auth/extauth/enterprise_handler.go index 1d45d4a..934af53 100644 --- a/auth/extauth/enterprise_handler.go +++ b/auth/extauth/enterprise_handler.go @@ -5,8 +5,6 @@ // Package extauth provides OAuth handler implementations for MCP authorization extensions. // This package implements Enterprise Managed Authorization as defined in SEP-990. -//go:build mcp_go_client_oauth - package extauth import ( diff --git a/auth/extauth/enterprise_handler_test.go b/auth/extauth/enterprise_handler_test.go index 7f1d521..c19b5a2 100644 --- a/auth/extauth/enterprise_handler_test.go +++ b/auth/extauth/enterprise_handler_test.go @@ -2,8 +2,6 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. -//go:build mcp_go_client_oauth - package extauth import ( diff --git a/auth/extauth/oidc_login.go b/auth/extauth/oidc_login.go index 848f020..d9d259b 100644 --- a/auth/extauth/oidc_login.go +++ b/auth/extauth/oidc_login.go @@ -6,8 +6,6 @@ // as part of Enterprise Managed Authorization (SEP-990). // See https://openid.net/specs/openid-connect-core-1_0.html -//go:build mcp_go_client_oauth - package extauth import ( @@ -121,11 +119,11 @@ func initiateOIDCLogin( return nil, nil, fmt.Errorf("RedirectURL is required") } if len(config.Scopes) == 0 { - return nil, nil, fmt.Errorf("Scopes is required (must include 'openid')") + return nil, nil, fmt.Errorf("at least one scope is required (must include 'openid')") } if !slices.Contains(config.Scopes, "openid") { - return nil, nil, fmt.Errorf("Scopes must include 'openid' for OIDC") + return nil, nil, fmt.Errorf("the 'openid' scope is required for OIDC") } if err := checkURLScheme(config.IssuerURL); err != nil { diff --git a/auth/extauth/oidc_login_test.go b/auth/extauth/oidc_login_test.go index 53d0bb8..eb5a3eb 100644 --- a/auth/extauth/oidc_login_test.go +++ b/auth/extauth/oidc_login_test.go @@ -2,8 +2,6 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. -//go:build mcp_go_client_oauth - package extauth import ( @@ -161,7 +159,7 @@ func TestInitiateOIDCLogin(t *testing.T) { c.Credentials = &oauthex.ClientCredentials{ClientID: "test"} c.RedirectURL = "http://localhost:8080/callback" }, - expectErr: "Scopes is required", + expectErr: "at least one scope is required", }, } for _, tt := range tests { diff --git a/auth/shared.go b/auth/shared.go index 569c5a1..8c5e10c 100644 --- a/auth/shared.go +++ b/auth/shared.go @@ -4,8 +4,6 @@ // This file contains shared utilities for OAuth handlers. -//go:build mcp_go_client_oauth - package auth import ( diff --git a/auth/shared_test.go b/auth/shared_test.go index 9f51d7e..16b5054 100644 --- a/auth/shared_test.go +++ b/auth/shared_test.go @@ -2,8 +2,6 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. -//go:build mcp_go_client_oauth - package auth import ( diff --git a/conformance/everything-client/client_private.go b/conformance/everything-client/client_private.go deleted file mode 100644 index 6cddc95..0000000 --- a/conformance/everything-client/client_private.go +++ /dev/null @@ -1,139 +0,0 @@ -// Copyright 2025 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -// The conformance client implements features required for MCP conformance testing. -// It mirrors the functionality of the TypeScript conformance client at -// https://github.com/modelcontextprotocol/typescript-sdk/blob/main/src/conformance/everything-client.ts - -//go:build mcp_go_client_oauth - -package main - -import ( - "context" - "fmt" - "net/http" - "net/url" - - "github.com/modelcontextprotocol/go-sdk/auth" - "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/modelcontextprotocol/go-sdk/oauthex" -) - -func init() { - authScenarios := []string{ - "auth/2025-03-26-oauth-metadata-backcompat", - "auth/2025-03-26-oauth-endpoint-fallback", - "auth/basic-cimd", - "auth/metadata-default", - "auth/metadata-var1", - "auth/metadata-var2", - "auth/metadata-var3", - "auth/pre-registration", - "auth/resource-mismatch", - "auth/scope-from-www-authenticate", - "auth/scope-from-scopes-supported", - "auth/scope-omitted-when-undefined", - "auth/scope-step-up", - "auth/scope-retry-limit", - "auth/token-endpoint-auth-basic", - "auth/token-endpoint-auth-post", - "auth/token-endpoint-auth-none", - } - for _, scenario := range authScenarios { - registerScenario(scenario, runAuthClient) - } -} - -// ============================================================================ -// Auth scenarios -// ============================================================================ - -func fetchAuthorizationCodeAndState(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { - client := &http.Client{ - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - } - req, err := http.NewRequestWithContext(ctx, "GET", args.URL, nil) - if err != nil { - return nil, err - } - - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - // In conformance tests the authorization server immediately redirects - // to the callback URL with the authorization code and state. - locURL, err := url.Parse(resp.Header.Get("Location")) - if err != nil { - return nil, fmt.Errorf("parse location: %v", err) - } - - return &auth.AuthorizationResult{ - Code: locURL.Query().Get("code"), - State: locURL.Query().Get("state"), - }, nil -} - -func runAuthClient(ctx context.Context, serverURL string, configCtx map[string]any) error { - authConfig := &auth.AuthorizationCodeHandlerConfig{ - RedirectURL: "http://localhost:3000/callback", - AuthorizationCodeFetcher: fetchAuthorizationCodeAndState, - // Try client ID metadata document based registration. - ClientIDMetadataDocumentConfig: &auth.ClientIDMetadataDocumentConfig{ - URL: "https://conformance-test.local/client-metadata.json", - }, - // Try dynamic client registration. - DynamicClientRegistrationConfig: &auth.DynamicClientRegistrationConfig{ - Metadata: &oauthex.ClientRegistrationMetadata{ - RedirectURIs: []string{"http://localhost:3000/callback"}, - }, - }, - } - // Try pre-registered client information if provided in the context. - if clientID, ok := configCtx["client_id"].(string); ok { - if clientSecret, ok := configCtx["client_secret"].(string); ok { - authConfig.PreregisteredClient = &oauthex.ClientCredentials{ - ClientID: clientID, - ClientSecretAuth: &oauthex.ClientSecretAuth{ - ClientSecret: clientSecret, - }, - } - } - } - - authHandler, err := auth.NewAuthorizationCodeHandler(authConfig) - if err != nil { - return fmt.Errorf("failed to create auth handler: %w", err) - } - - session, err := connectToServer(ctx, serverURL, withOAuthHandler(authHandler)) - if err != nil { - return err - } - defer session.Close() - - if _, err := session.ListTools(ctx, nil); err != nil { - return fmt.Errorf("session.ListTools(): %v", err) - } - - if _, err := session.CallTool(ctx, &mcp.CallToolParams{ - Name: "test-tool", - Arguments: map[string]any{}, - }); err != nil { - return fmt.Errorf("session.CallTool('test-tool'): %v", err) - } - - return nil -} - -func withOAuthHandler(handler auth.OAuthHandler) connectOption { - return func(c *connectConfig) { - c.oauthHandler = handler - } -} diff --git a/conformance/everything-client/main.go b/conformance/everything-client/main.go index c05fa6f..947b095 100644 --- a/conformance/everything-client/main.go +++ b/conformance/everything-client/main.go @@ -12,6 +12,8 @@ import ( "encoding/json" "fmt" "log" + "net/http" + "net/url" "os" "slices" "sort" @@ -19,6 +21,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/auth" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/modelcontextprotocol/go-sdk/oauthex" ) // scenarioHandler is the function signature for all conformance test scenarios. @@ -44,6 +47,29 @@ func init() { registerScenario("tools_call", runToolsCallClient) registerScenario("elicitation-sep1034-client-defaults", runElicitationDefaultsClient) registerScenario("sse-retry", runSSERetryClient) + + authScenarios := []string{ + "auth/2025-03-26-oauth-metadata-backcompat", + "auth/2025-03-26-oauth-endpoint-fallback", + "auth/basic-cimd", + "auth/metadata-default", + "auth/metadata-var1", + "auth/metadata-var2", + "auth/metadata-var3", + "auth/pre-registration", + "auth/resource-mismatch", + "auth/scope-from-www-authenticate", + "auth/scope-from-scopes-supported", + "auth/scope-omitted-when-undefined", + "auth/scope-step-up", + "auth/scope-retry-limit", + "auth/token-endpoint-auth-basic", + "auth/token-endpoint-auth-post", + "auth/token-endpoint-auth-none", + } + for _, scenario := range authScenarios { + registerScenario(scenario, runAuthClient) + } } // ============================================================================ @@ -175,6 +201,92 @@ func runSSERetryClient(ctx context.Context, serverURL string, _ map[string]any) return nil } +// ============================================================================ +// Auth scenarios +// ============================================================================ + +func fetchAuthorizationCodeAndState(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + req, err := http.NewRequestWithContext(ctx, "GET", args.URL, nil) + if err != nil { + return nil, err + } + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // In conformance tests the authorization server immediately redirects + // to the callback URL with the authorization code and state. + locURL, err := url.Parse(resp.Header.Get("Location")) + if err != nil { + return nil, fmt.Errorf("parse location: %v", err) + } + + return &auth.AuthorizationResult{ + Code: locURL.Query().Get("code"), + State: locURL.Query().Get("state"), + }, nil +} + +func runAuthClient(ctx context.Context, serverURL string, configCtx map[string]any) error { + authConfig := &auth.AuthorizationCodeHandlerConfig{ + RedirectURL: "http://localhost:3000/callback", + AuthorizationCodeFetcher: fetchAuthorizationCodeAndState, + // Try client ID metadata document based registration. + ClientIDMetadataDocumentConfig: &auth.ClientIDMetadataDocumentConfig{ + URL: "https://conformance-test.local/client-metadata.json", + }, + // Try dynamic client registration. + DynamicClientRegistrationConfig: &auth.DynamicClientRegistrationConfig{ + Metadata: &oauthex.ClientRegistrationMetadata{ + RedirectURIs: []string{"http://localhost:3000/callback"}, + }, + }, + } + // Try pre-registered client information if provided in the context. + if clientID, ok := configCtx["client_id"].(string); ok { + if clientSecret, ok := configCtx["client_secret"].(string); ok { + authConfig.PreregisteredClient = &oauthex.ClientCredentials{ + ClientID: clientID, + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: clientSecret, + }, + } + } + } + + authHandler, err := auth.NewAuthorizationCodeHandler(authConfig) + if err != nil { + return fmt.Errorf("failed to create auth handler: %w", err) + } + + session, err := connectToServer(ctx, serverURL, withOAuthHandler(authHandler)) + if err != nil { + return err + } + defer session.Close() + + if _, err := session.ListTools(ctx, nil); err != nil { + return fmt.Errorf("session.ListTools(): %v", err) + } + + if _, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "test-tool", + Arguments: map[string]any{}, + }); err != nil { + return fmt.Errorf("session.CallTool('test-tool'): %v", err) + } + + return nil +} + // ============================================================================ // Main entry point // ============================================================================ @@ -237,6 +349,12 @@ func withClientOptions(opts *mcp.ClientOptions) connectOption { } } +func withOAuthHandler(handler auth.OAuthHandler) connectOption { + return func(c *connectConfig) { + c.oauthHandler = handler + } +} + // connectToServer connects to the MCP server and returns a client session. // The caller is responsible for closing the session. func connectToServer(ctx context.Context, serverURL string, opts ...connectOption) (*mcp.ClientSession, error) { diff --git a/docs/protocol.md b/docs/protocol.md index 70f5f41..f50bbfb 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -307,11 +307,6 @@ The [_auth middleware example_](https://github.com/modelcontextprotocol/go-sdk/ ### Client -> [!IMPORTANT] -> Client-side OAuth support is currently experimental and requires the `mcp_go_client_oauth` build tag to compile. -> API changes may still be made, based on developer feedback. The build tag will be removed in `v1.5.0`, which -> is planned to be released by the end of March 2026. - Client-side authorization is supported via the [`StreamableClientTransport.OAuthHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#StreamableClientTransport.OAuthHandler) field. If the handler is provided, the transport will automatically use it to diff --git a/examples/auth/client/main.go b/examples/auth/client/main.go index a245a8a..f514eba 100644 --- a/examples/auth/client/main.go +++ b/examples/auth/client/main.go @@ -2,8 +2,6 @@ // Use of this source code is governed by the license // that can be found in the LICENSE file. -//go:build mcp_go_client_oauth - package main import ( @@ -29,7 +27,6 @@ var ( type codeReceiver struct { authChan chan *auth.AuthorizationResult errChan chan error - listener net.Listener server *http.Server } diff --git a/examples/auth/enterprise/main.go b/examples/auth/enterprise/main.go index 4698f25..fb58aad 100644 --- a/examples/auth/enterprise/main.go +++ b/examples/auth/enterprise/main.go @@ -2,8 +2,6 @@ // Use of this source code is governed by the license // that can be found in the LICENSE file. -//go:build mcp_go_client_oauth - package main import ( @@ -45,7 +43,6 @@ var ( type codeReceiver struct { authChan chan *auth.AuthorizationResult errChan chan error - listener net.Listener server *http.Server } diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index 7d132d2..bfaf654 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -232,11 +232,6 @@ The [_auth middleware example_](https://github.com/modelcontextprotocol/go-sdk/ ### Client -> [!IMPORTANT] -> Client-side OAuth support is currently experimental and requires the `mcp_go_client_oauth` build tag to compile. -> API changes may still be made, based on developer feedback. The build tag will be removed in `v1.5.0`, which -> is planned to be released by the end of March 2026. - Client-side authorization is supported via the [`StreamableClientTransport.OAuthHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#StreamableClientTransport.OAuthHandler) field. If the handler is provided, the transport will automatically use it to diff --git a/internal/oauthtest/fake_authorization_server.go b/internal/oauthtest/fake_authorization_server.go index 456c639..6fed39b 100644 --- a/internal/oauthtest/fake_authorization_server.go +++ b/internal/oauthtest/fake_authorization_server.go @@ -2,8 +2,6 @@ // Use of this source code is governed by the license // that can be found in the LICENSE file. -//go:build mcp_go_client_oauth - package oauthtest import ( diff --git a/mcp/streamable_client_auth_test.go b/mcp/streamable_client_auth_test.go deleted file mode 100644 index a1211e4..0000000 --- a/mcp/streamable_client_auth_test.go +++ /dev/null @@ -1,179 +0,0 @@ -// Copyright 2026 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -//go:build mcp_go_client_oauth - -package mcp - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/modelcontextprotocol/go-sdk/auth" - "golang.org/x/oauth2" -) - -type mockOAuthHandler struct { - // Embed to satisfy the interface. - auth.AuthorizationCodeHandler - - token *oauth2.Token - authorizeErr error - authorizeCalled bool -} - -func (h *mockOAuthHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) { - if h.token == nil { - return nil, nil - } - return oauth2.StaticTokenSource(h.token), nil -} - -func (h *mockOAuthHandler) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error { - h.authorizeCalled = true - return h.authorizeErr -} - -func TestStreamableClientOAuth_AuthorizationHeader(t *testing.T) { - ctx := context.Background() - token := &oauth2.Token{AccessToken: "test-token"} - oauthHandler := &mockOAuthHandler{token: token} - - fake := &fakeStreamableServer{ - t: t, - responses: fakeResponses{ - {"POST", "", methodInitialize, ""}: { - header: header{ - "Content-Type": "application/json", - sessionIDHeader: "123", - }, - body: jsonBody(t, initResp), - }, - {"POST", "123", notificationInitialized, ""}: { - status: http.StatusAccepted, - wantProtocolVersion: latestProtocolVersion, - }, - {"GET", "123", "", ""}: { - header: header{ - "Content-Type": "text/event-stream", - }, - }, - {"DELETE", "123", "", ""}: {}, - }, - } - verifier := func(ctx context.Context, token string, req *http.Request) (*auth.TokenInfo, error) { - if token != "test-token" { - return nil, auth.ErrInvalidToken - } - return &auth.TokenInfo{Expiration: time.Now().Add(time.Hour)}, nil - } - httpServer := httptest.NewServer(auth.RequireBearerToken(verifier, nil)(fake)) - t.Cleanup(httpServer.Close) - - transport := &StreamableClientTransport{ - Endpoint: httpServer.URL, - OAuthHandler: oauthHandler, - } - client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, nil) - if err != nil { - t.Fatalf("client.Connect() failed: %v", err) - } - session.Close() -} - -func TestStreamableClientOAuth_401(t *testing.T) { - ctx := context.Background() - oauthHandler := &mockOAuthHandler{token: nil} - - fake := &fakeStreamableServer{ - t: t, - responses: fakeResponses{ - {"POST", "", methodInitialize, ""}: { - header: header{ - "Content-Type": "application/json", - sessionIDHeader: "123", - }, - body: jsonBody(t, initResp), - }, - }, - } - verifier := func(ctx context.Context, token string, req *http.Request) (*auth.TokenInfo, error) { - // Accept any token. - return &auth.TokenInfo{Expiration: time.Now().Add(time.Hour)}, nil - } - httpServer := httptest.NewServer(auth.RequireBearerToken(verifier, nil)(fake)) - t.Cleanup(httpServer.Close) - - transport := &StreamableClientTransport{ - Endpoint: httpServer.URL, - OAuthHandler: oauthHandler, - } - client := NewClient(testImpl, nil) - _, err := client.Connect(ctx, transport, nil) - if err == nil || !strings.Contains(err.Error(), "Unauthorized") { - t.Fatalf("client.Connect() error does not contain 'Unauthorized': %v", err) - } - - if !oauthHandler.authorizeCalled { - t.Errorf("expected Authorize to be called") - } -} - -func TestTokenInfo(t *testing.T) { - ctx := context.Background() - - // Create a server with a tool that returns TokenInfo. - tokenInfo := func(ctx context.Context, req *CallToolRequest, _ struct{}) (*CallToolResult, any, error) { - return &CallToolResult{Content: []Content{&TextContent{Text: fmt.Sprintf("%v", req.Extra.TokenInfo)}}}, nil, nil - } - server := NewServer(testImpl, nil) - AddTool(server, &Tool{Name: "tokenInfo", Description: "return token info"}, tokenInfo) - - streamHandler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) - verifier := func(ctx context.Context, token string, req *http.Request) (*auth.TokenInfo, error) { - if token != "test-token" { - return nil, auth.ErrInvalidToken - } - return &auth.TokenInfo{ - Scopes: []string{"scope"}, - // Expiration is far, far in the future. - Expiration: time.Date(5000, 1, 2, 3, 4, 5, 0, time.UTC), - }, nil - } - handler := auth.RequireBearerToken(verifier, nil)(streamHandler) - httpServer := httptest.NewServer(mustNotPanic(t, handler)) - defer httpServer.Close() - - transport := &StreamableClientTransport{ - Endpoint: httpServer.URL, - OAuthHandler: &mockOAuthHandler{token: &oauth2.Token{AccessToken: "test-token"}}, - } - client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, nil) - if err != nil { - t.Fatalf("client.Connect() failed: %v", err) - } - defer session.Close() - - res, err := session.CallTool(ctx, &CallToolParams{Name: "tokenInfo"}) - if err != nil { - t.Fatal(err) - } - if len(res.Content) == 0 { - t.Fatal("missing content") - } - tc, ok := res.Content[0].(*TextContent) - if !ok { - t.Fatal("not TextContent") - } - if g, w := tc.Text, "&{[scope] 5000-01-02 03:04:05 +0000 UTC map[]}"; g != w { - t.Errorf("got %q, want %q", g, w) - } -} diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index e805aa5..57407d4 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -17,8 +17,10 @@ import ( "time" "github.com/google/go-cmp/cmp" + "github.com/modelcontextprotocol/go-sdk/auth" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "golang.org/x/oauth2" ) type streamableRequestKey struct { @@ -908,3 +910,159 @@ func TestStreamableClientDisableStandaloneSSE(t *testing.T) { }) } } + +type mockOAuthHandler struct { + token *oauth2.Token + authorizeErr error + authorizeCalled bool +} + +func (h *mockOAuthHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) { + if h.token == nil { + return nil, nil + } + return oauth2.StaticTokenSource(h.token), nil +} + +func (h *mockOAuthHandler) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error { + h.authorizeCalled = true + return h.authorizeErr +} + +func TestStreamableClientOAuth_AuthorizationHeader(t *testing.T) { + ctx := context.Background() + token := &oauth2.Token{AccessToken: "test-token"} + oauthHandler := &mockOAuthHandler{token: token} + + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodInitialize, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "123", + }, + body: jsonBody(t, initResp), + }, + {"POST", "123", notificationInitialized, ""}: { + status: http.StatusAccepted, + wantProtocolVersion: latestProtocolVersion, + }, + {"GET", "123", "", ""}: { + header: header{ + "Content-Type": "text/event-stream", + }, + }, + {"DELETE", "123", "", ""}: {}, + }, + } + verifier := func(ctx context.Context, token string, req *http.Request) (*auth.TokenInfo, error) { + if token != "test-token" { + return nil, auth.ErrInvalidToken + } + return &auth.TokenInfo{Expiration: time.Now().Add(time.Hour)}, nil + } + httpServer := httptest.NewServer(auth.RequireBearerToken(verifier, nil)(fake)) + t.Cleanup(httpServer.Close) + + transport := &StreamableClientTransport{ + Endpoint: httpServer.URL, + OAuthHandler: oauthHandler, + } + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, nil) + if err != nil { + t.Fatalf("client.Connect() failed: %v", err) + } + session.Close() +} + +func TestStreamableClientOAuth_401(t *testing.T) { + ctx := context.Background() + oauthHandler := &mockOAuthHandler{token: nil} + + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodInitialize, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "123", + }, + body: jsonBody(t, initResp), + }, + }, + } + verifier := func(ctx context.Context, token string, req *http.Request) (*auth.TokenInfo, error) { + // Accept any token. + return &auth.TokenInfo{Expiration: time.Now().Add(time.Hour)}, nil + } + httpServer := httptest.NewServer(auth.RequireBearerToken(verifier, nil)(fake)) + t.Cleanup(httpServer.Close) + + transport := &StreamableClientTransport{ + Endpoint: httpServer.URL, + OAuthHandler: oauthHandler, + } + client := NewClient(testImpl, nil) + _, err := client.Connect(ctx, transport, nil) + if err == nil || !strings.Contains(err.Error(), "Unauthorized") { + t.Fatalf("client.Connect() error does not contain 'Unauthorized': %v", err) + } + + if !oauthHandler.authorizeCalled { + t.Errorf("expected Authorize to be called") + } +} + +func TestTokenInfo(t *testing.T) { + ctx := context.Background() + + // Create a server with a tool that returns TokenInfo. + tokenInfo := func(ctx context.Context, req *CallToolRequest, _ struct{}) (*CallToolResult, any, error) { + return &CallToolResult{Content: []Content{&TextContent{Text: fmt.Sprintf("%v", req.Extra.TokenInfo)}}}, nil, nil + } + server := NewServer(testImpl, nil) + AddTool(server, &Tool{Name: "tokenInfo", Description: "return token info"}, tokenInfo) + + streamHandler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) + verifier := func(ctx context.Context, token string, req *http.Request) (*auth.TokenInfo, error) { + if token != "test-token" { + return nil, auth.ErrInvalidToken + } + return &auth.TokenInfo{ + Scopes: []string{"scope"}, + // Expiration is far, far in the future. + Expiration: time.Date(5000, 1, 2, 3, 4, 5, 0, time.UTC), + }, nil + } + handler := auth.RequireBearerToken(verifier, nil)(streamHandler) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + defer httpServer.Close() + + transport := &StreamableClientTransport{ + Endpoint: httpServer.URL, + OAuthHandler: &mockOAuthHandler{token: &oauth2.Token{AccessToken: "test-token"}}, + } + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, nil) + if err != nil { + t.Fatalf("client.Connect() failed: %v", err) + } + defer session.Close() + + res, err := session.CallTool(ctx, &CallToolParams{Name: "tokenInfo"}) + if err != nil { + t.Fatal(err) + } + if len(res.Content) == 0 { + t.Fatal("missing content") + } + tc, ok := res.Content[0].(*TextContent) + if !ok { + t.Fatal("not TextContent") + } + if g, w := tc.Text, "&{[scope] 5000-01-02 03:04:05 +0000 UTC map[]}"; g != w { + t.Errorf("got %q, want %q", g, w) + } +} diff --git a/oauthex/auth_meta.go b/oauthex/auth_meta.go index 3621057..df1a0aa 100644 --- a/oauthex/auth_meta.go +++ b/oauthex/auth_meta.go @@ -5,8 +5,6 @@ // This file implements Authorization Server Metadata. // See https://www.rfc-editor.org/rfc/rfc8414.html. -//go:build mcp_go_client_oauth - package oauthex import ( diff --git a/oauthex/auth_meta_test.go b/oauthex/auth_meta_test.go index 8b67e59..f390676 100644 --- a/oauthex/auth_meta_test.go +++ b/oauthex/auth_meta_test.go @@ -2,8 +2,6 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. -//go:build mcp_go_client_oauth - package oauthex import ( diff --git a/oauthex/client.go b/oauthex/client.go index 8fd2f76..e8f9918 100644 --- a/oauthex/client.go +++ b/oauthex/client.go @@ -2,8 +2,6 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. -//go:build mcp_go_client_oauth - package oauthex import "errors" diff --git a/oauthex/dcr.go b/oauthex/dcr.go index 6db3025..653cf5b 100644 --- a/oauthex/dcr.go +++ b/oauthex/dcr.go @@ -5,8 +5,6 @@ // This file implements Authorization Server Metadata. // See https://www.rfc-editor.org/rfc/rfc8414.html. -//go:build mcp_go_client_oauth - package oauthex import ( diff --git a/oauthex/dcr_test.go b/oauthex/dcr_test.go index 7c5551e..ec94c49 100644 --- a/oauthex/dcr_test.go +++ b/oauthex/dcr_test.go @@ -2,8 +2,6 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. -//go:build mcp_go_client_oauth - package oauthex import ( diff --git a/oauthex/oauth2.go b/oauthex/oauth2.go index ba6b774..689f17a 100644 --- a/oauthex/oauth2.go +++ b/oauthex/oauth2.go @@ -4,8 +4,6 @@ // Package oauthex implements extensions to OAuth2. -//go:build mcp_go_client_oauth - package oauthex import ( diff --git a/oauthex/oauthex_test.go b/oauthex/oauthex_test.go index ca19ace..d028037 100644 --- a/oauthex/oauthex_test.go +++ b/oauthex/oauthex_test.go @@ -2,8 +2,6 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. -//go:build mcp_go_client_oauth - package oauthex import ( diff --git a/oauthex/resource_meta.go b/oauthex/resource_meta.go index 4680c15..7506634 100644 --- a/oauthex/resource_meta.go +++ b/oauthex/resource_meta.go @@ -5,8 +5,6 @@ // This file implements Protected Resource Metadata. // See https://www.rfc-editor.org/rfc/rfc9728.html. -//go:build mcp_go_client_oauth - package oauthex import ( @@ -14,81 +12,108 @@ import ( "errors" "fmt" "net/http" - "net/url" - "path" "strings" "unicode" "github.com/modelcontextprotocol/go-sdk/internal/util" ) -const defaultProtectedResourceMetadataURI = "/.well-known/oauth-protected-resource" +// ProtectedResourceMetadata is the metadata for an OAuth 2.0 protected resource, +// as defined in section 2 of https://www.rfc-editor.org/rfc/rfc9728.html. +// +// The following features are not supported: +// - additional keys (§2, last sentence) +// - human-readable metadata (§2.1) +// - signed metadata (§2.2) +type ProtectedResourceMetadata struct { + // Resource (resource) is the protected resource's resource identifier. + // Required. + Resource string `json:"resource"` -// GetProtectedResourceMetadataFromID issues a GET request to retrieve protected resource -// metadata from a resource server by its ID. -// The resource ID is an HTTPS URL, typically with a host:port and possibly a path. -// For example: -// -// https://example.com/server -// -// This function, following the spec (§3), inserts the default well-known path into the -// URL. In our example, the result would be -// -// https://example.com/.well-known/oauth-protected-resource/server -// -// It then retrieves the metadata at that location using the given client (or the -// default client if nil) and validates its resource field against resourceID. -// -// Deprecated: Use [GetProtectedResourceMetadata] instead. This function will be removed in v1.5.0. -func GetProtectedResourceMetadataFromID(ctx context.Context, resourceID string, c *http.Client) (_ *ProtectedResourceMetadata, err error) { - defer util.Wrapf(&err, "GetProtectedResourceMetadataFromID(%q)", resourceID) + // AuthorizationServers (authorization_servers) is an optional slice containing a list of + // OAuth authorization server issuer identifiers (as defined in RFC 8414) that can be + // used with this protected resource. + AuthorizationServers []string `json:"authorization_servers,omitempty"` - u, err := url.Parse(resourceID) - if err != nil { - return nil, err - } - // Insert well-known URI into URL. - u.Path = path.Join(defaultProtectedResourceMetadataURI, u.Path) - return GetProtectedResourceMetadata(ctx, u.String(), resourceID, c) + // JWKSURI (jwks_uri) is an optional URL of the protected resource's JSON Web Key (JWK) Set + // document. This contains public keys belonging to the protected resource, such as + // signing key(s) that the resource server uses to sign resource responses. + JWKSURI string `json:"jwks_uri,omitempty"` + + // ScopesSupported (scopes_supported) is a recommended slice containing a list of scope + // values (as defined in RFC 6749) used in authorization requests to request access + // to this protected resource. + ScopesSupported []string `json:"scopes_supported,omitempty"` + + // BearerMethodsSupported (bearer_methods_supported) is an optional slice containing + // a list of the supported methods of sending an OAuth 2.0 bearer token to the + // protected resource. Defined values are "header", "body", and "query". + BearerMethodsSupported []string `json:"bearer_methods_supported,omitempty"` + + // ResourceSigningAlgValuesSupported (resource_signing_alg_values_supported) is an optional + // slice of JWS signing algorithms (alg values) supported by the protected + // resource for signing resource responses. + ResourceSigningAlgValuesSupported []string `json:"resource_signing_alg_values_supported,omitempty"` + + // ResourceName (resource_name) is a human-readable name of the protected resource + // intended for display to the end user. It is RECOMMENDED that this field be included. + // This value may be internationalized. + ResourceName string `json:"resource_name,omitempty"` + + // ResourceDocumentation (resource_documentation) is an optional URL of a page containing + // human-readable information for developers using the protected resource. + // This value may be internationalized. + ResourceDocumentation string `json:"resource_documentation,omitempty"` + + // ResourcePolicyURI (resource_policy_uri) is an optional URL of a page containing + // human-readable policy information on how a client can use the data provided. + // This value may be internationalized. + ResourcePolicyURI string `json:"resource_policy_uri,omitempty"` + + // ResourceTOSURI (resource_tos_uri) is an optional URL of a page containing the protected + // resource's human-readable terms of service. This value may be internationalized. + ResourceTOSURI string `json:"resource_tos_uri,omitempty"` + + // TLSClientCertificateBoundAccessTokens (tls_client_certificate_bound_access_tokens) is an + // optional boolean indicating support for mutual-TLS client certificate-bound + // access tokens (RFC 8705). Defaults to false if omitted. + TLSClientCertificateBoundAccessTokens bool `json:"tls_client_certificate_bound_access_tokens,omitempty"` + + // AuthorizationDetailsTypesSupported (authorization_details_types_supported) is an optional + // slice of 'type' values supported by the resource server for the + // 'authorization_details' parameter (RFC 9396). + AuthorizationDetailsTypesSupported []string `json:"authorization_details_types_supported,omitempty"` + + // DPOPSigningAlgValuesSupported (dpop_signing_alg_values_supported) is an optional + // slice of JWS signing algorithms supported by the resource server for validating + // DPoP proof JWTs (RFC 9449). + DPOPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"` + + // DPOPBoundAccessTokensRequired (dpop_bound_access_tokens_required) is an optional boolean + // specifying whether the protected resource always requires the use of DPoP-bound + // access tokens (RFC 9449). Defaults to false if omitted. + DPOPBoundAccessTokensRequired bool `json:"dpop_bound_access_tokens_required,omitempty"` + + // SignedMetadata (signed_metadata) is an optional JWT containing metadata parameters + // about the protected resource as claims. If present, these values take precedence + // over values conveyed in plain JSON. + // TODO:implement. + // Note that §2.2 says it's okay to ignore this. + // SignedMetadata string `json:"signed_metadata,omitempty"` } -// GetProtectedResourceMetadataFromHeader retrieves protected resource metadata -// using information in the given header, using the given client (or the default -// client if nil). -// It issues a GET request to a URL discovered by parsing the WWW-Authenticate headers in the given request. -// Per RFC 9728 section 3.3, it validates that the resource field of the resulting metadata -// matches the serverURL (the URL that the client used to make the original request to the resource server). -// If there is no metadata URL in the header, it returns nil, nil. -// -// Deprecated: Use [GetProtectedResourceMetadata] instead. This function will be removed in v1.5.0. -func GetProtectedResourceMetadataFromHeader(ctx context.Context, serverURL string, header http.Header, c *http.Client) (_ *ProtectedResourceMetadata, err error) { - headers := header[http.CanonicalHeaderKey("WWW-Authenticate")] - if len(headers) == 0 { - return nil, nil - } - cs, err := ParseWWWAuthenticate(headers) - if err != nil { - return nil, err - } - metadataURL := resourceMetadataURL(cs) - if metadataURL == "" { - return nil, nil - } - return GetProtectedResourceMetadata(ctx, metadataURL, serverURL, c) +// Challenge represents a single authentication challenge from a WWW-Authenticate header. +// As per RFC 9110, Section 11.6.1, a challenge consists of a scheme and optional parameters. +type Challenge struct { + // Scheme is the authentication scheme (e.g., "Bearer", "Basic"). + // It is case-insensitive. A parsed value will always be lower-case. + Scheme string + // Params is a map of authentication parameters. + // Keys are case-insensitive. Parsed keys are always lower-case. + Params map[string]string } -// resourceMetadataURL returns a resource metadata URL from the given "WWW-Authenticate" header challenges, -// or the empty string if there is none. -func resourceMetadataURL(cs []Challenge) string { - for _, c := range cs { - if u := c.Params["resource_metadata"]; u != "" { - return u - } - } - return "" -} - -// GetProtectedResourceMetadataFromID issues a GET request to retrieve protected resource +// GetProtectedResourceMetadata issues a GET request to retrieve protected resource // metadata from a resource server. // The metadataURL is typically a URL with a host:port and possibly a path. // The resourceURL is the resource URI the metadataURL is for. diff --git a/oauthex/resource_meta_public.go b/oauthex/resource_meta_public.go deleted file mode 100644 index 3bf7d9a..0000000 --- a/oauthex/resource_meta_public.go +++ /dev/null @@ -1,105 +0,0 @@ -// Copyright 2025 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -// This file implements Protected Resource Metadata. -// See https://www.rfc-editor.org/rfc/rfc9728.html. - -// This is a temporary file to expose the required objects to the main package. - -package oauthex - -// ProtectedResourceMetadata is the metadata for an OAuth 2.0 protected resource, -// as defined in section 2 of https://www.rfc-editor.org/rfc/rfc9728.html. -// -// The following features are not supported: -// - additional keys (§2, last sentence) -// - human-readable metadata (§2.1) -// - signed metadata (§2.2) -type ProtectedResourceMetadata struct { - // Resource (resource) is the protected resource's resource identifier. - // Required. - Resource string `json:"resource"` - - // AuthorizationServers (authorization_servers) is an optional slice containing a list of - // OAuth authorization server issuer identifiers (as defined in RFC 8414) that can be - // used with this protected resource. - AuthorizationServers []string `json:"authorization_servers,omitempty"` - - // JWKSURI (jwks_uri) is an optional URL of the protected resource's JSON Web Key (JWK) Set - // document. This contains public keys belonging to the protected resource, such as - // signing key(s) that the resource server uses to sign resource responses. - JWKSURI string `json:"jwks_uri,omitempty"` - - // ScopesSupported (scopes_supported) is a recommended slice containing a list of scope - // values (as defined in RFC 6749) used in authorization requests to request access - // to this protected resource. - ScopesSupported []string `json:"scopes_supported,omitempty"` - - // BearerMethodsSupported (bearer_methods_supported) is an optional slice containing - // a list of the supported methods of sending an OAuth 2.0 bearer token to the - // protected resource. Defined values are "header", "body", and "query". - BearerMethodsSupported []string `json:"bearer_methods_supported,omitempty"` - - // ResourceSigningAlgValuesSupported (resource_signing_alg_values_supported) is an optional - // slice of JWS signing algorithms (alg values) supported by the protected - // resource for signing resource responses. - ResourceSigningAlgValuesSupported []string `json:"resource_signing_alg_values_supported,omitempty"` - - // ResourceName (resource_name) is a human-readable name of the protected resource - // intended for display to the end user. It is RECOMMENDED that this field be included. - // This value may be internationalized. - ResourceName string `json:"resource_name,omitempty"` - - // ResourceDocumentation (resource_documentation) is an optional URL of a page containing - // human-readable information for developers using the protected resource. - // This value may be internationalized. - ResourceDocumentation string `json:"resource_documentation,omitempty"` - - // ResourcePolicyURI (resource_policy_uri) is an optional URL of a page containing - // human-readable policy information on how a client can use the data provided. - // This value may be internationalized. - ResourcePolicyURI string `json:"resource_policy_uri,omitempty"` - - // ResourceTOSURI (resource_tos_uri) is an optional URL of a page containing the protected - // resource's human-readable terms of service. This value may be internationalized. - ResourceTOSURI string `json:"resource_tos_uri,omitempty"` - - // TLSClientCertificateBoundAccessTokens (tls_client_certificate_bound_access_tokens) is an - // optional boolean indicating support for mutual-TLS client certificate-bound - // access tokens (RFC 8705). Defaults to false if omitted. - TLSClientCertificateBoundAccessTokens bool `json:"tls_client_certificate_bound_access_tokens,omitempty"` - - // AuthorizationDetailsTypesSupported (authorization_details_types_supported) is an optional - // slice of 'type' values supported by the resource server for the - // 'authorization_details' parameter (RFC 9396). - AuthorizationDetailsTypesSupported []string `json:"authorization_details_types_supported,omitempty"` - - // DPOPSigningAlgValuesSupported (dpop_signing_alg_values_supported) is an optional - // slice of JWS signing algorithms supported by the resource server for validating - // DPoP proof JWTs (RFC 9449). - DPOPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"` - - // DPOPBoundAccessTokensRequired (dpop_bound_access_tokens_required) is an optional boolean - // specifying whether the protected resource always requires the use of DPoP-bound - // access tokens (RFC 9449). Defaults to false if omitted. - DPOPBoundAccessTokensRequired bool `json:"dpop_bound_access_tokens_required,omitempty"` - - // SignedMetadata (signed_metadata) is an optional JWT containing metadata parameters - // about the protected resource as claims. If present, these values take precedence - // over values conveyed in plain JSON. - // TODO:implement. - // Note that §2.2 says it's okay to ignore this. - // SignedMetadata string `json:"signed_metadata,omitempty"` -} - -// Challenge represents a single authentication challenge from a WWW-Authenticate header. -// As per RFC 9110, Section 11.6.1, a challenge consists of a scheme and optional parameters. -type Challenge struct { - // Scheme is the authentication scheme (e.g., "Bearer", "Basic"). - // It is case-insensitive. A parsed value will always be lower-case. - Scheme string - // Params is a map of authentication parameters. - // Keys are case-insensitive. Parsed keys are always lower-case. - Params map[string]string -} diff --git a/oauthex/oauth2_test.go b/oauthex/resource_meta_test.go similarity index 70% rename from oauthex/oauth2_test.go rename to oauthex/resource_meta_test.go index 36f732e..81e1ebe 100644 --- a/oauthex/oauth2_test.go +++ b/oauthex/resource_meta_test.go @@ -2,14 +2,10 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. -//go:build mcp_go_client_oauth - package oauthex import ( - "context" "encoding/json" - "fmt" "net/http" "net/http/httptest" "reflect" @@ -206,21 +202,14 @@ func TestParseSingleChallenge(t *testing.T) { } func TestGetProtectedResourceMetadata(t *testing.T) { - ctx := context.Background() - t.Run("FromHeader", func(t *testing.T) { - h := &fakeResourceHandler{serveWWWAuthenticate: true} + ctx := t.Context() + t.Run("Success", func(t *testing.T) { + h := &fakeResourceHandler{} server := httptest.NewTLSServer(h) h.installHandlers(server.URL) client := server.Client() - serverURL := server.URL + "/resource" - res, err := client.Get(serverURL) - if err != nil { - t.Fatal(err) - } - if res.StatusCode != http.StatusUnauthorized { - t.Fatal("want unauth") - } - prm, err := GetProtectedResourceMetadataFromHeader(ctx, serverURL, res.Header, client) + metadataURL := server.URL + "/.well-known/oauth-protected-resource" + prm, err := GetProtectedResourceMetadata(ctx, metadataURL, server.URL, client) if err != nil { t.Fatal(err) } @@ -228,53 +217,13 @@ func TestGetProtectedResourceMetadata(t *testing.T) { t.Fatal("nil prm") } }) - t.Run("FromID", func(t *testing.T) { - h := &fakeResourceHandler{serveWWWAuthenticate: false} + t.Run("RejectsIncorrectResource", func(t *testing.T) { + h := &fakeResourceHandler{resourceOverride: "https://attacker.com/evil"} server := httptest.NewTLSServer(h) h.installHandlers(server.URL) client := server.Client() - prm, err := GetProtectedResourceMetadataFromID(ctx, server.URL, client) - if err != nil { - t.Fatal(err) - } - if prm == nil { - t.Fatal("nil prm") - } - }) - // Test that metadata URL and resource identifier are properly distinguished (issue #560). - t.Run("FromHeaderValidatesAgainstServerURL", func(t *testing.T) { - h := &fakeResourceHandler{serveWWWAuthenticate: true} - server := httptest.NewTLSServer(h) - h.installHandlers(server.URL) - client := server.Client() - serverURL := server.URL + "/resource" - res, err := client.Get(serverURL) - if err != nil { - t.Fatal(err) - } - // This should succeed because we validate against serverURL, not metadataURL - prm, err := GetProtectedResourceMetadataFromHeader(ctx, serverURL, res.Header, client) - if err != nil { - t.Fatalf("Expected validation to succeed, got error: %v", err) - } - if prm == nil { - t.Fatal("Expected non-nil prm") - } - if prm.Resource != serverURL { - t.Errorf("Expected resource %q, got %q", serverURL, prm.Resource) - } - }) - t.Run("FromHeaderRejectsImpersonation", func(t *testing.T) { - h := &fakeResourceHandler{serveWWWAuthenticate: true, resourceOverride: "https://attacker.com/evil"} - server := httptest.NewTLSServer(h) - h.installHandlers(server.URL) - client := server.Client() - serverURL := server.URL + "/resource" - res, err := client.Get(serverURL) - if err != nil { - t.Fatal(err) - } - prm, err := GetProtectedResourceMetadataFromHeader(ctx, serverURL, res.Header, client) + metadataURL := server.URL + "/.well-known/oauth-protected-resource" + prm, err := GetProtectedResourceMetadata(ctx, metadataURL, server.URL, client) if err == nil { t.Fatal("Expected validation error for mismatched resource, got nil") } @@ -286,28 +235,17 @@ func TestGetProtectedResourceMetadata(t *testing.T) { type fakeResourceHandler struct { http.ServeMux - serveWWWAuthenticate bool - resourceOverride string // If set, use this instead of correct resource (for testing validation) + resourceOverride string // If set, use this instead of correct resource (for testing validation) } func (h *fakeResourceHandler) installHandlers(serverURL string) { path := "/.well-known/oauth-protected-resource" - url := serverURL + path - h.Handle("GET /resource", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if h.serveWWWAuthenticate { - w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer resource_metadata="%s"`, url)) - } - w.WriteHeader(http.StatusUnauthorized) - })) h.Handle("GET "+path, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") // Per RFC 9728 section 3.3, the resource field should contain the actual resource identifier, // which is the URL the client uses to access the resource (serverURL + "/resource" for WWW-Authenticate case). - // For the FromID test case, it's just the serverURL. + // For the well-known URL test case, it's just the serverURL. resource := serverURL - if h.serveWWWAuthenticate { - resource = serverURL + "/resource" - } // Allow testing with custom resource values (e.g., impersonation attacks). if h.resourceOverride != "" { resource = h.resourceOverride diff --git a/oauthex/token_exchange.go b/oauthex/token_exchange.go index f77cafa..0c7b24e 100644 --- a/oauthex/token_exchange.go +++ b/oauthex/token_exchange.go @@ -5,8 +5,6 @@ // This file implements Token Exchange (RFC 8693) for Enterprise Managed Authorization. // See https://datatracker.ietf.org/doc/html/rfc8693 -//go:build mcp_go_client_oauth - package oauthex import ( diff --git a/oauthex/token_exchange_test.go b/oauthex/token_exchange_test.go index 71edf46..eb026ff 100644 --- a/oauthex/token_exchange_test.go +++ b/oauthex/token_exchange_test.go @@ -2,8 +2,6 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. -//go:build mcp_go_client_oauth - package oauthex import ( diff --git a/oauthex/url_scheme_test.go b/oauthex/url_scheme_test.go index 83eeb5e..f8c44a3 100644 --- a/oauthex/url_scheme_test.go +++ b/oauthex/url_scheme_test.go @@ -2,8 +2,6 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. -//go:build mcp_go_client_oauth - package oauthex import ( @@ -240,7 +238,7 @@ func TestGetAuthServerMetaRejectsDangerousURLs(t *testing.T) { } // TestGetProtectedResourceMetadataRejectsDangerousURLs tests that -// GetProtectedResourceMetadataFromID rejects metadata with dangerous authorization server URLs. +// GetProtectedResourceMetadata rejects metadata with dangerous authorization server URLs. func TestGetProtectedResourceMetadataRejectsDangerousURLs(t *testing.T) { server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { serverURL := "https://" + r.Host @@ -254,12 +252,13 @@ func TestGetProtectedResourceMetadataRejectsDangerousURLs(t *testing.T) { defer server.Close() ctx := context.Background() - _, err := GetProtectedResourceMetadataFromID(ctx, server.URL, server.Client()) + metadataURL := server.URL + "/.well-known/oauth-protected-resource" + _, err := GetProtectedResourceMetadata(ctx, metadataURL, server.URL, server.Client()) if err == nil { - t.Fatal("GetProtectedResourceMetadataFromID(): got nil error, want error") + t.Fatal("GetProtectedResourceMetadata(): got nil error, want error") } if !strings.Contains(err.Error(), "disallowed scheme") { - t.Errorf("GetProtectedResourceMetadataFromID(): got error %v, want error containing \"disallowed scheme\"", err) + t.Errorf("GetProtectedResourceMetadata(): got error %v, want error containing \"disallowed scheme\"", err) } } diff --git a/scripts/client-conformance.sh b/scripts/client-conformance.sh index 1752845..fef74ca 100755 --- a/scripts/client-conformance.sh +++ b/scripts/client-conformance.sh @@ -63,7 +63,7 @@ else fi # Build the conformance server. -go build -tags mcp_go_client_oauth -o "$WORKDIR/conformance-client" ./conformance/everything-client +go build -o "$WORKDIR/conformance-client" ./conformance/everything-client # Run conformance tests from the work directory to avoid writing results to the repo. echo "Running conformance tests..." From d3fd25b68a4b966b5f788a0b49e4ddf3e6048992 Mon Sep 17 00:00:00 2001 From: Dan McGee Date: Tue, 31 Mar 2026 06:13:16 -0500 Subject: [PATCH 239/418] mcp: use http.ResponseController to ensure writes are flushed (#870) If the ResponseWriter is a wrapped one (which is quite common when using middleware to log things like response sizes), the direct cast to `http.Flusher` doesn't find the flush capability. Instead, use `http.ResponseController` as introduced in go1.20 which handles unwrapping to find the base response writer. --- mcp/event.go | 8 ++++---- mcp/streamable.go | 6 +++--- mcp/streamable_client_test.go | 5 +++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/mcp/event.go b/mcp/event.go index 80b42f5..41ff4ae 100644 --- a/mcp/event.go +++ b/mcp/event.go @@ -41,7 +41,7 @@ func (e Event) Empty() bool { } // writeEvent writes the event to w, and flushes. -func writeEvent(w io.Writer, evt Event) (int, error) { +func writeEvent(w http.ResponseWriter, evt Event) (int, error) { var b bytes.Buffer if evt.Name != "" { fmt.Fprintf(&b, "event: %s\n", evt.Name) @@ -54,9 +54,9 @@ func writeEvent(w io.Writer, evt Event) (int, error) { } fmt.Fprintf(&b, "data: %s\n\n", string(evt.Data)) n, err := w.Write(b.Bytes()) - if f, ok := w.(http.Flusher); ok { - f.Flush() - } + rc := http.NewResponseController(w) + // Ignore returned error as flushing is best-effort. + _ = rc.Flush() return n, err } diff --git a/mcp/streamable.go b/mcp/streamable.go index 0482c79..b0b1795 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1053,9 +1053,9 @@ func (c *streamableServerConn) acquireStream(ctx context.Context, w http.Respons // Issue #410: the standalone SSE stream is likely not to receive messages // for a long time. Ensure that headers are flushed. w.WriteHeader(http.StatusOK) - if f, ok := w.(http.Flusher); ok { - f.Flush() - } + rc := http.NewResponseController(w) + // Ignore returned error as flushing is best-effort. + _ = rc.Flush() } for _, data := range toReplay { diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 57407d4..43564fd 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -119,14 +119,15 @@ func (s *fakeStreamableServer) ServeHTTP(w http.ResponseWriter, req *http.Reques for k, v := range resp.header { w.Header().Set(k, v) } + rc := http.NewResponseController(w) w.WriteHeader(status) - w.(http.Flusher).Flush() // flush response headers + rc.Flush() // flush response headers if v := req.Header.Get(protocolVersionHeader); v != resp.wantProtocolVersion && resp.wantProtocolVersion != "" { s.t.Errorf("%v: bad protocol version header: got %q, want %q", key, v, resp.wantProtocolVersion) } w.Write([]byte(body)) - w.(http.Flusher).Flush() // flush response + rc.Flush() // flush response if resp.done != nil { <-resp.done From e23b75cd3f35eea408777cca1ef2e5e6aa77ea22 Mon Sep 17 00:00:00 2001 From: Oleksandr Redko Date: Tue, 31 Mar 2026 16:40:06 +0300 Subject: [PATCH 240/418] internal: remove unused util functions (#871) The functions util.Sorted and util.KeySlice are unused. --- internal/util/errors.go | 16 +++++++++++++++ internal/util/util.go | 44 ----------------------------------------- 2 files changed, 16 insertions(+), 44 deletions(-) create mode 100644 internal/util/errors.go delete mode 100644 internal/util/util.go diff --git a/internal/util/errors.go b/internal/util/errors.go new file mode 100644 index 0000000..ebeb614 --- /dev/null +++ b/internal/util/errors.go @@ -0,0 +1,16 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package util + +import ( + "fmt" +) + +// Wrapf wraps *errp with the given formatted message if *errp is not nil. +func Wrapf(errp *error, format string, args ...any) { + if *errp != nil { + *errp = fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), *errp) + } +} diff --git a/internal/util/util.go b/internal/util/util.go deleted file mode 100644 index 4b5c325..0000000 --- a/internal/util/util.go +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright 2025 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -package util - -import ( - "cmp" - "fmt" - "iter" - "slices" -) - -// Helpers below are copied from gopls' moremaps package. - -// Sorted returns an iterator over the entries of m in key order. -func Sorted[M ~map[K]V, K cmp.Ordered, V any](m M) iter.Seq2[K, V] { - // TODO(adonovan): use maps.Sorted if proposal #68598 is accepted. - return func(yield func(K, V) bool) { - keys := KeySlice(m) - slices.Sort(keys) - for _, k := range keys { - if !yield(k, m[k]) { - break - } - } - } -} - -// KeySlice returns the keys of the map M, like slices.Collect(maps.Keys(m)). -func KeySlice[M ~map[K]V, K comparable, V any](m M) []K { - r := make([]K, 0, len(m)) - for k := range m { - r = append(r, k) - } - return r -} - -// Wrapf wraps *errp with the given formatted message if *errp is not nil. -func Wrapf(errp *error, format string, args ...any) { - if *errp != nil { - *errp = fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), *errp) - } -} From d77c67b016b59f4a3f8045bba4761bd22922aa67 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:24:13 +0200 Subject: [PATCH 241/418] build(deps): bump github/codeql-action from 4.32.4 to 4.35.1 (#873) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.32.4 to 4.35.1.
Release notes

Sourced from github/codeql-action's releases.

v4.35.1

v4.35.0

v4.34.1

  • Downgrade default CodeQL bundle version to 2.24.3 due to issues with a small percentage of Actions and JavaScript analyses. #3762

v4.34.0

  • Added an experimental change which disables TRAP caching when improved incremental analysis is enabled, since improved incremental analysis supersedes TRAP caching. This will improve performance and reduce Actions cache usage. We expect to roll this change out to everyone in March. #3569
  • We are rolling out improved incremental analysis to C/C++ analyses that use build mode none. We expect this rollout to be complete by the end of April 2026. #3584
  • Update default CodeQL bundle version to 2.25.0. #3585

v4.33.0

  • Upcoming change: Starting April 2026, the CodeQL Action will skip collecting file coverage information on pull requests to improve analysis performance. File coverage information will still be computed on non-PR analyses. Pull request analyses will log a warning about this upcoming change. #3562

    To opt out of this change:

    • Repositories owned by an organization: Create a custom repository property with the name github-codeql-file-coverage-on-prs and the type "True/false", then set this property to true in the repository's settings. For more information, see Managing custom properties for repositories in your organization. Alternatively, if you are using an advanced setup workflow, you can set the CODEQL_ACTION_FILE_COVERAGE_ON_PRS environment variable to true in your workflow.
    • User-owned repositories using default setup: Switch to an advanced setup workflow and set the CODEQL_ACTION_FILE_COVERAGE_ON_PRS environment variable to true in your workflow.
    • User-owned repositories using advanced setup: Set the CODEQL_ACTION_FILE_COVERAGE_ON_PRS environment variable to true in your workflow.
  • Fixed a bug which caused the CodeQL Action to fail loading repository properties if a "Multi select" repository property was configured for the repository. #3557

  • The CodeQL Action now loads custom repository properties on GitHub Enterprise Server, enabling the customization of features such as github-codeql-disable-overlay that was previously only available on GitHub.com. #3559

  • Once private package registries can be configured with OIDC-based authentication for organizations, the CodeQL Action will now be able to accept such configurations. #3563

  • Fixed the retry mechanism for database uploads. Previously this would fail with the error "Response body object should not be disturbed or locked". #3564

  • A warning is now emitted if the CodeQL Action detects a repository property whose name suggests that it relates to the CodeQL Action, but which is not one of the properties recognised by the current version of the CodeQL Action. #3570

v4.32.6

  • Update default CodeQL bundle version to 2.24.3. #3548

v4.32.5

  • Repositories owned by an organization can now set up the github-codeql-disable-overlay custom repository property to disable improved incremental analysis for CodeQL. First, create a custom repository property with the name github-codeql-disable-overlay and the type "True/false" in the organization's settings. Then in the repository's settings, set this property to true to disable improved incremental analysis. For more information, see Managing custom properties for repositories in your organization. This feature is not yet available on GitHub Enterprise Server. #3507
  • Added an experimental change so that when improved incremental analysis fails on a runner — potentially due to insufficient disk space — the failure is recorded in the Actions cache so that subsequent runs will automatically skip improved incremental analysis until something changes (e.g. a larger runner is provisioned or a new CodeQL version is released). We expect to roll this change out to everyone in March. #3487
  • The minimum memory check for improved incremental analysis is now skipped for CodeQL 2.24.3 and later, which has reduced peak RAM usage. #3515
  • Reduced log levels for best-effort private package registry connection check failures to reduce noise from workflow annotations. #3516
  • Added an experimental change which lowers the minimum disk space requirement for improved incremental analysis, enabling it to run on standard GitHub Actions runners. We expect to roll this change out to everyone in March. #3498
  • Added an experimental change which allows the start-proxy action to resolve the CodeQL CLI version from feature flags instead of using the linked CLI bundle version. We expect to roll this change out to everyone in March. #3512
  • The previously experimental changes from versions 4.32.3, 4.32.4, 3.32.3 and 3.32.4 are now enabled by default. #3503, #3504
Changelog

Sourced from github/codeql-action's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

  • The Git version 2.36.0 requirement for improved incremental analysis now only applies to repositories that contain submodules. #3789

4.35.1 - 27 Mar 2026

4.35.0 - 27 Mar 2026

4.34.1 - 20 Mar 2026

  • Downgrade default CodeQL bundle version to 2.24.3 due to issues with a small percentage of Actions and JavaScript analyses. #3762

4.34.0 - 20 Mar 2026

  • Added an experimental change which disables TRAP caching when improved incremental analysis is enabled, since improved incremental analysis supersedes TRAP caching. This will improve performance and reduce Actions cache usage. We expect to roll this change out to everyone in March. #3569
  • We are rolling out improved incremental analysis to C/C++ analyses that use build mode none. We expect this rollout to be complete by the end of April 2026. #3584
  • Update default CodeQL bundle version to 2.25.0. #3585

4.33.0 - 16 Mar 2026

  • Upcoming change: Starting April 2026, the CodeQL Action will skip collecting file coverage information on pull requests to improve analysis performance. File coverage information will still be computed on non-PR analyses. Pull request analyses will log a warning about this upcoming change. #3562

    To opt out of this change:

    • Repositories owned by an organization: Create a custom repository property with the name github-codeql-file-coverage-on-prs and the type "True/false", then set this property to true in the repository's settings. For more information, see Managing custom properties for repositories in your organization. Alternatively, if you are using an advanced setup workflow, you can set the CODEQL_ACTION_FILE_COVERAGE_ON_PRS environment variable to true in your workflow.
    • User-owned repositories using default setup: Switch to an advanced setup workflow and set the CODEQL_ACTION_FILE_COVERAGE_ON_PRS environment variable to true in your workflow.
    • User-owned repositories using advanced setup: Set the CODEQL_ACTION_FILE_COVERAGE_ON_PRS environment variable to true in your workflow.
  • Fixed a bug which caused the CodeQL Action to fail loading repository properties if a "Multi select" repository property was configured for the repository. #3557

  • The CodeQL Action now loads custom repository properties on GitHub Enterprise Server, enabling the customization of features such as github-codeql-disable-overlay that was previously only available on GitHub.com. #3559

  • Once private package registries can be configured with OIDC-based authentication for organizations, the CodeQL Action will now be able to accept such configurations. #3563

  • Fixed the retry mechanism for database uploads. Previously this would fail with the error "Response body object should not be disturbed or locked". #3564

  • A warning is now emitted if the CodeQL Action detects a repository property whose name suggests that it relates to the CodeQL Action, but which is not one of the properties recognised by the current version of the CodeQL Action. #3570

4.32.6 - 05 Mar 2026

  • Update default CodeQL bundle version to 2.24.3. #3548

4.32.5 - 02 Mar 2026

  • Repositories owned by an organization can now set up the github-codeql-disable-overlay custom repository property to disable improved incremental analysis for CodeQL. First, create a custom repository property with the name github-codeql-disable-overlay and the type "True/false" in the organization's settings. Then in the repository's settings, set this property to true to disable improved incremental analysis. For more information, see Managing custom properties for repositories in your organization. This feature is not yet available on GitHub Enterprise Server. #3507
  • Added an experimental change so that when improved incremental analysis fails on a runner — potentially due to insufficient disk space — the failure is recorded in the Actions cache so that subsequent runs will automatically skip improved incremental analysis until something changes (e.g. a larger runner is provisioned or a new CodeQL version is released). We expect to roll this change out to everyone in March. #3487
  • The minimum memory check for improved incremental analysis is now skipped for CodeQL 2.24.3 and later, which has reduced peak RAM usage. #3515

... (truncated)

Commits
  • c10b806 Merge pull request #3782 from github/update-v4.35.1-d6d1743b8
  • c5ffd06 Update changelog for v4.35.1
  • d6d1743 Merge pull request #3781 from github/henrymercer/update-git-minimum-version
  • 65d2efa Add changelog note
  • 2437b20 Update minimum git version for overlay to 2.36.0
  • ea5f719 Merge pull request #3775 from github/dependabot/npm_and_yarn/node-forge-1.4.0
  • 45ceeea Merge pull request #3777 from github/mergeback/v4.35.0-to-main-b8bb9f28
  • 24448c9 Rebuild
  • 7c51060 Update changelog and version after v4.35.0
  • b8bb9f2 Merge pull request #3776 from github/update-v4.35.0-0078ad667
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github/codeql-action&package-manager=github_actions&previous-version=4.32.4&new-version=4.35.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- .github/workflows/scorecard.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8d8dfb3..6b0a9e9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -38,11 +38,11 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Initialize CodeQL - uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 67694c9..0b46d03 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -73,6 +73,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 with: sarif_file: results.sarif From 7b43bf1533c57b8f8808f0e3609937a0a3670449 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:26:56 +0200 Subject: [PATCH 242/418] build(deps): bump actions/setup-go from 6.3.0 to 6.4.0 (#874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6.3.0 to 6.4.0.
Release notes

Sourced from actions/setup-go's releases.

v6.4.0

What's Changed

Enhancement

Dependency update

Documentation update

New Contributors

Full Changelog: https://github.com/actions/setup-go/compare/v6...v6.4.0

Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/conformance.yml | 4 ++-- .github/workflows/docs-check.yml | 2 +- .github/workflows/nightly.yml | 2 +- .github/workflows/test.yml | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index d4329ed..6221ed1 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -20,7 +20,7 @@ jobs: - name: Check out code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: "^1.26" - name: Start everything-server @@ -43,7 +43,7 @@ jobs: - name: Check out code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: "^1.26" - name: "Run conformance tests" diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index ee6f596..e5bb426 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: "^1.26" - name: Check out code diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 098a9a9..b184c1d 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -25,7 +25,7 @@ jobs: - name: Check out code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: "^1.26" - name: Set up Node.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4c07f0f..94a3ae4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: - name: Check out code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: "^1.26" - name: Check formatting @@ -45,7 +45,7 @@ jobs: - name: Check out code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: ${{ matrix.go }} - name: Test @@ -57,7 +57,7 @@ jobs: - name: Check out code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: "1.26" - name: Test with -race From 7c570719475fb224aa755a6c69058939cf7ad8eb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:29:18 +0200 Subject: [PATCH 243/418] build(deps): bump actions/setup-node from 6.2.0 to 6.3.0 (#875) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.2.0 to 6.3.0.
Release notes

Sourced from actions/setup-node's releases.

v6.3.0

What's Changed

Enhancements:

When using node-version-file: package.json, setup-node now prefers devEngines.runtime over engines.node.

Dependency updates:

Bug fixes:

New Contributors

Full Changelog: https://github.com/actions/setup-node/compare/v6...v6.3.0

Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/nightly.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index b184c1d..8c56e4d 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -29,7 +29,7 @@ jobs: with: go-version: "^1.26" - name: Set up Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: "22" - name: Run server conformance tests From 6537610f4718e1a50b1ee4c7f42d7bf6b6c22901 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:37:35 +0200 Subject: [PATCH 244/418] build(deps): bump dominikh/staticcheck-action from 1.4.0 to 1.4.1 (#872) Bumps [dominikh/staticcheck-action](https://github.com/dominikh/staticcheck-action) from 1.4.0 to 1.4.1.
Release notes

Sourced from dominikh/staticcheck-action's releases.

v1.4.1

Version 1.4.1

Changelog

Sourced from dominikh/staticcheck-action's changelog.

1.4.1

  • Switch from WillAbides/setup-go-faster to actions/setup-go
  • Pin our dependencies

1.4.0

  • Add use-cache option to disable automatic caching
  • Update README to document all options

1.3.1

  • Update README to be clearer
  • Update README to provide timeless examples
  • Update setup-go-faster dependency from v1.8.0 to v1.14.0
  • Update actions/cache dependency from v3 to v4
  • Update installed Go version from 1.19.x to "stable"

1.3.0

  • Add the output-format option
  • Add the output-file option
  • Add the merge-files option
  • Update action/cache dependency from v2 to v3
  • Update setup-go-faster dependency from v1.7.0 to v1.8.0
  • Update installed Go version from 1.17.x to 1.19.x

1.2.0

  • Add the checks option
  • Add the working-directory option

1.1.0

  • The action will store and restore Go's cache if install-go is set to true.

1.0.0

Initial release.

Commits

--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Maciek Kisiel --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 94a3ae4..2008bfd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,7 +31,7 @@ jobs: - name: Run Go vet run: go vet ./... - name: Run staticcheck - uses: dominikh/staticcheck-action@024238d2898c874f26d723e7d0ff4308c35589a2 # v1 + uses: dominikh/staticcheck-action@9716614d4101e79b4340dd97b10e54d68234e431 # v1.4.1 with: install-go: false version: "v0.6.1" From 76d5a54125a3f1f0fa182a90f040ffc0c4164fb8 Mon Sep 17 00:00:00 2001 From: MatyasVondraOutreach Date: Wed, 1 Apr 2026 16:51:18 +0200 Subject: [PATCH 245/418] oauthex: accept 200 in client registration (#877) ## Summary Accepts HTTP 200 as a valid response in Dynamic Client Registration (DCR), in addition to 201. Some authorization servers return 200 instead of the RFC-specified 201 on successful registration. Fixes https://github.com/modelcontextprotocol/go-sdk/issues/876 ## Changes - `oauthex/dcr.go`: accept 200 status code in client registration response - `oauthex/dcr_test.go`: add test coverage for the 200 response case --- oauthex/dcr.go | 2 +- oauthex/dcr_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/oauthex/dcr.go b/oauthex/dcr.go index 653cf5b..ce21467 100644 --- a/oauthex/dcr.go +++ b/oauthex/dcr.go @@ -204,7 +204,7 @@ func RegisterClient(ctx context.Context, registrationEndpoint string, clientMeta return nil, fmt.Errorf("failed to read registration response body: %w", err) } - if resp.StatusCode == http.StatusCreated { + if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK { var regResponse ClientRegistrationResponse if err := internaljson.Unmarshal(body, ®Response); err != nil { return nil, fmt.Errorf("failed to decode successful registration response: %w (%s)", err, string(body)) diff --git a/oauthex/dcr_test.go b/oauthex/dcr_test.go index ec94c49..afff34c 100644 --- a/oauthex/dcr_test.go +++ b/oauthex/dcr_test.go @@ -70,6 +70,30 @@ func TestRegisterClient(t *testing.T) { clientMeta: &ClientRegistrationMetadata{ClientName: "Test App", RedirectURIs: []string{"http://localhost/cb"}}, wantClientID: "test-client-id", }, + { + name: "Success with 200", + handler: func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("Expected POST, got %s", r.Method) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + var receivedMeta ClientRegistrationMetadata + if err := json.Unmarshal(body, &receivedMeta); err != nil { + t.Fatalf("Failed to unmarshal request body: %v", err) + } + if receivedMeta.ClientName != "Test App" { + t.Errorf("Expected ClientName 'Test App', got '%s'", receivedMeta.ClientName) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"client_id":"test-client-id","client_secret":"test-client-secret","client_name":"Test App"}`)) + }, + clientMeta: &ClientRegistrationMetadata{ClientName: "Test App", RedirectURIs: []string{"http://localhost/cb"}}, + wantClientID: "test-client-id", + }, { name: "Missing ClientID in Response", handler: func(w http.ResponseWriter, r *http.Request) { From 27f29c1cef3b6b8dcab41e3e1997fc773595b6d5 Mon Sep 17 00:00:00 2001 From: Rafael Dantas Justo Date: Fri, 3 Apr 2026 12:42:20 -0300 Subject: [PATCH 246/418] mcp: allow Content-Type parameters in streamable transport (#878) Use `mime.ParseMediaType` to parse the `Content-Type` header instead of exact string matching. Previously, the server required an exact match on `application/json`. This allows the server to accept `application/json` with parameters (e.g. `charset=utf-8`), even though [they are not required](https://datatracker.ietf.org/doc/html/rfc4627.html#section-6), following the robustness principle. Per [RFC 8259 (section 8.1)](https://datatracker.ietf.org/doc/html/rfc8259.html#section-8.1), `application/json` is always UTF-8, so such parameters are technically redundant. However, parsing the media type makes the server more tolerant of client behaviour, improving compatibility without compromising security. --- mcp/streamable.go | 5 +++-- mcp/streamable_test.go | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index b0b1795..76f2b0e 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -20,6 +20,7 @@ import ( "maps" "math" "math/rand/v2" + "mime" "net" "net/http" "slices" @@ -264,8 +265,8 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque } // Validate 'Content-Type' header. if req.Method == http.MethodPost { - contentType := req.Header.Get("Content-Type") - if contentType != "application/json" { + mediaType, _, err := mime.ParseMediaType(req.Header.Get("Content-Type")) + if err != nil || mediaType != "application/json" { http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) return } diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index d6eb8e9..a275d3c 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -862,6 +862,14 @@ func TestStreamableServerTransport(t *testing.T) { wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(5, &CallToolResult{Content: []Content{}}, nil)}, }, + { + // Correct Content-Type with parameters should pass. + method: "POST", + headers: http.Header{"Content-Type": {"application/json; charset=utf-8"}}, + messages: []jsonrpc.Message{req(5, "tools/call", &CallToolParams{Name: "tool"})}, + wantStatusCode: http.StatusOK, + wantMessages: []jsonrpc.Message{resp(5, &CallToolResult{Content: []Content{}}, nil)}, + }, }, wantSessions: 1, }, From 34a19dff464092d8b800d2a38faddafdaf43cdb9 Mon Sep 17 00:00:00 2001 From: Ravish Gupta Date: Fri, 3 Apr 2026 11:15:24 -0700 Subject: [PATCH 247/418] mcp: preserve existing Content in SetError (#864) SetError now only populates the Content field when it has not already been populated. This allows callers to provide a user-friendly message while still recording the underlying error for middleware inspection via GetError. The len check (rather than nil check) ensures both nil and empty-slice Content are treated as "not yet populated." Fixes #858 --- mcp/mcp_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ mcp/protocol.go | 22 +++++++++++++++++++--- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 5a20e4c..00c4dc9 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -2228,4 +2228,45 @@ func TestToolErrorMiddleware(t *testing.T) { } } +func TestSetErrorPreservesContent(t *testing.T) { + for _, tt := range []struct { + name string + content []Content + err error + wantContent string + }{ + { + name: "nil content", + err: errors.New("internal failure"), + wantContent: "internal failure", + }, + { + name: "empty slice content", + content: []Content{}, + err: errors.New("internal failure"), + wantContent: "internal failure", + }, + { + name: "existing content preserved", + content: []Content{&TextContent{Text: "user-friendly msg"}}, + err: errors.New("db timeout"), + wantContent: "user-friendly msg", + }, + } { + t.Run(tt.name, func(t *testing.T) { + res := CallToolResult{Content: tt.content} + res.SetError(tt.err) + if !res.IsError { + t.Fatal("want IsError=true") + } + if got := res.Content[0].(*TextContent).Text; got != tt.wantContent { + t.Errorf("Content text = %q, want %q", got, tt.wantContent) + } + if got := res.GetError(); got != tt.err { + t.Errorf("GetError() = %v, want %v", got, tt.err) + } + }) + } +} + var ctrCmpOpts = []cmp.Option{cmp.AllowUnexported(CallToolResult{})} diff --git a/mcp/protocol.go b/mcp/protocol.go index 0b6bf47..1646788 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -10,6 +10,7 @@ import ( "maps" internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" + "github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug" ) // Optional annotations for the client. The client can use annotations to inform @@ -113,10 +114,25 @@ type CallToolResult struct { err error } -// SetError sets the error for the tool result and populates the Content field -// with the error text. It also sets IsError to true. +// seterroroverwrite is a compatibility parameter that restores the pre-1.6.0 +// behavior of [CallToolResult.SetError], where Content was always overwritten +// with the error text. See the documentation for the mcpgodebug package for +// instructions on how to enable it. +// The option will be removed in the 1.8.0 version of the SDK. +var seterroroverwrite = mcpgodebug.Value("seterroroverwrite") + +// SetError sets the error for the tool result and sets IsError to true. +// If Content has not already been populated, it is set to the error text. +// If Content has already been populated, it is left unchanged, allowing callers +// to provide a user-friendly message while still recording the underlying error +// for inspection via [GetError] in server middleware. +// +// To restore the previous behavior where Content was always overwritten, +// set MCPGODEBUG=seterroroverwrite=1. func (r *CallToolResult) SetError(err error) { - r.Content = []Content{&TextContent{Text: err.Error()}} + if len(r.Content) == 0 || seterroroverwrite == "1" { + r.Content = []Content{&TextContent{Text: err.Error()}} + } r.IsError = true r.err = err } From 862d78a265cb1f1b9692ff49cfb8bfd109b6ebc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Egelund-M=C3=BCller?= Date: Fri, 3 Apr 2026 20:17:49 +0200 Subject: [PATCH 248/418] mcp: fix race condition in `ServerSession.startKeepalive` (#856) This PR fixes a race condition where `ServerSession.keepaliveCancel` was accessed in `Close` concurrently with its initialization in `ServerSession.startKeepalive`. It also removes a now redundant test. The fix moves keepalive initialization into `ServerSession.Connect` similar to the implementation in `ClientSession.Connect`. This is safe because the MCP spec allows keepalive pings before the initialization messages. From the MCP [spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle) (emphasis added): > The client SHOULD NOT send requests **other than pings** before the server has responded to the initialize request. The PR includes a reproducer test case, which fails with the following error without this fix: ``` > go test -count=10 -race -run ^TestStreamableStatelessKeepaliveRace$ github.com/modelcontextprotocol/go-sdk/mcp 8s ================== WARNING: DATA RACE Write at 0x00c00007fbf0 by goroutine 1095: github.com/modelcontextprotocol/go-sdk/mcp.startKeepalive() /Users/benjamin/Documents/go-sdk/mcp/shared.go:590 +0x58 github.com/modelcontextprotocol/go-sdk/mcp.(*ServerSession).startKeepalive() /Users/benjamin/Documents/go-sdk/mcp/server.go:1526 +0x130 github.com/modelcontextprotocol/go-sdk/mcp.(*ServerSession).initialized() /Users/benjamin/Documents/go-sdk/mcp/server.go:1059 +0xf8 github.com/modelcontextprotocol/go-sdk/mcp.init.serverSessionMethod[go.shape.*uint8,go.shape.interface { GetMeta() map[string]interface {}; SetMeta(map[string]interface {}); github.com/modelcontextprotocol/go-sdk/mcp.isResult() }].func27() /Users/benjamin/Documents/go-sdk/mcp/shared.go:332 +0x98 github.com/modelcontextprotocol/go-sdk/mcp.newServerMethodInfo[go.shape.*github.com/modelcontextprotocol/go-sdk/mcp.InitializedParams,go.shape.interface { GetMeta() map[string]interface {}; SetMeta(map[string]interface {}); github.com/modelcontextprotocol/go-sdk/mcp.isResult() },go.shape.struct { github.com/modelcontextprotocol/go-sdk/mcp.Meta "json:\"_meta,omitempty\"" }].func2() /Users/benjamin/Documents/go-sdk/mcp/shared.go:272 +0x84 github.com/modelcontextprotocol/go-sdk/mcp.defaultReceivingMethodHandler[go.shape.*uint8]() /Users/benjamin/Documents/go-sdk/mcp/shared.go:152 +0xe8 github.com/modelcontextprotocol/go-sdk/mcp.defaultReceivingMethodHandler[*github.com/modelcontextprotocol/go-sdk/mcp.ServerSession]() /Users/benjamin/Documents/go-sdk/mcp/shared.go:146 +0x60 github.com/modelcontextprotocol/go-sdk/mcp.handleReceive[go.shape.*uint8]() /Users/benjamin/Documents/go-sdk/mcp/shared.go:169 +0x228 github.com/modelcontextprotocol/go-sdk/mcp.(*ServerSession).handle() /Users/benjamin/Documents/go-sdk/mcp/server.go:1445 +0x370 github.com/modelcontextprotocol/go-sdk/mcp.connect[go.shape.*uint8,go.shape.*uint8].func1.1() /Users/benjamin/Documents/go-sdk/mcp/transport.go:170 +0x5c github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2.HandlerFunc.Handle() /Users/benjamin/Documents/go-sdk/internal/jsonrpc2/jsonrpc2.go:84 +0x48 github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2.(*Connection).handleAsync.func3() /Users/benjamin/Documents/go-sdk/internal/jsonrpc2/conn.go:717 +0xd8 Previous read at 0x00c00007fbf0 by goroutine 1089: github.com/modelcontextprotocol/go-sdk/mcp.(*ServerSession).Close() /Users/benjamin/Documents/go-sdk/mcp/server.go:1502 +0x34 github.com/modelcontextprotocol/go-sdk/mcp.(*StreamableHTTPHandler).ServeHTTP.deferwrap1() /Users/benjamin/Documents/go-sdk/mcp/streamable.go:519 +0x34 runtime.deferreturn() /opt/homebrew/Cellar/go/1.25.4/libexec/src/runtime/panic.go:589 +0x5c github.com/modelcontextprotocol/go-sdk/mcp.TestStreamableStatelessKeepaliveRace.mustNotPanic.func2() /Users/benjamin/Documents/go-sdk/mcp/streamable_test.go:2156 +0x94 net/http.HandlerFunc.ServeHTTP() /opt/homebrew/Cellar/go/1.25.4/libexec/src/net/http/server.go:2322 +0x48 net/http.serverHandler.ServeHTTP() /opt/homebrew/Cellar/go/1.25.4/libexec/src/net/http/server.go:3340 +0x270 net/http.(*conn).serve() /opt/homebrew/Cellar/go/1.25.4/libexec/src/net/http/server.go:2109 +0x9b0 net/http.(*Server).Serve.gowrap3() /opt/homebrew/Cellar/go/1.25.4/libexec/src/net/http/server.go:3493 +0x48 Goroutine 1095 (running) created at: github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2.(*Connection).handleAsync() /Users/benjamin/Documents/go-sdk/internal/jsonrpc2/conn.go:715 +0x334 github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2.(*Connection).acceptRequest.func2.gowrap1() /Users/benjamin/Documents/go-sdk/internal/jsonrpc2/conn.go:676 +0x34 Goroutine 1089 (running) created at: net/http.(*Server).Serve() /opt/homebrew/Cellar/go/1.25.4/libexec/src/net/http/server.go:3493 +0x5dc net/http/httptest.(*Server).goServe.func1() /opt/homebrew/Cellar/go/1.25.4/libexec/src/net/http/httptest/server.go:311 +0x98 ================== --- FAIL: TestStreamableStatelessKeepaliveRace (0.14s) testing.go:1617: race detected during execution of test FAIL FAIL github.com/modelcontextprotocol/go-sdk/mcp 1.727s FAIL ``` --------- Co-authored-by: Maciej Kisiel --- mcp/client.go | 3 ++- mcp/server.go | 15 ++++++++----- mcp/server_test.go | 48 ------------------------------------------ mcp/streamable_test.go | 28 ++++++++++++++++++++++++ 4 files changed, 40 insertions(+), 54 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index dc7bef1..4860b62 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -340,7 +340,8 @@ func (cs *ClientSession) ID() string { // Close is idempotent and concurrency safe. func (cs *ClientSession) Close() error { // Note: keepaliveCancel access is safe without a mutex because: - // 1. keepaliveCancel is only written once during startKeepalive (happens-before all Close calls) + // 1. keepaliveCancel is only written once during Client.Connect (through startKeepalive), + // which happens before any code that may call Close from another goroutine // 2. context.CancelFunc is safe to call multiple times and from multiple goroutines // 3. The keepalive goroutine calls Close on ping failure, but this is safe since // Close is idempotent and conn.Close() handles concurrent calls correctly diff --git a/mcp/server.go b/mcp/server.go index 97b5d44..2357959 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1031,6 +1031,13 @@ func (s *Server) Connect(ctx context.Context, t Transport, opts *ServerSessionOp s.opts.Logger.Error("server connect error", "error", err) return nil, err } + + // Start keepalive before returning the session to avoid race conditions with Close. + // This is safe because the spec allows sending pings before initialization (see ServerSession.handle for details). + if s.opts.KeepAlive > 0 { + ss.startKeepalive(ss.server.opts.KeepAlive) + } + return ss, nil } @@ -1058,9 +1065,6 @@ func (ss *ServerSession) initialized(ctx context.Context, params *InitializedPar ss.server.opts.Logger.Error("duplicate initialized notification") return nil, fmt.Errorf("duplicate %q received", notificationInitialized) } - if ss.server.opts.KeepAlive > 0 { - ss.startKeepalive(ss.server.opts.KeepAlive) - } if h := ss.server.opts.InitializedHandler; h != nil { h(ctx, serverRequestFor(ss, params)) } @@ -1110,7 +1114,7 @@ type ServerSession struct { server *Server conn *jsonrpc2.Connection mcpConn Connection - keepaliveCancel context.CancelFunc // TODO: theory around why keepaliveCancel need not be guarded + keepaliveCancel context.CancelFunc mu sync.Mutex state ServerSessionState @@ -1504,7 +1508,8 @@ func (ss *ServerSession) setLevel(_ context.Context, params *SetLoggingLevelPara func (ss *ServerSession) Close() error { if ss.keepaliveCancel != nil { // Note: keepaliveCancel access is safe without a mutex because: - // 1. keepaliveCancel is only written once during startKeepalive (happens-before all Close calls) + // 1. keepaliveCancel is only written once during Server.Connect (through startKeepalive), + // which happens before any code that may call Close from another goroutine // 2. context.CancelFunc is safe to call multiple times and from multiple goroutines // 3. The keepalive goroutine calls Close on ping failure, but this is safe since // Close is idempotent and conn.Close() handles concurrent calls correctly diff --git a/mcp/server_test.go b/mcp/server_test.go index 85c3990..1312e1d 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -508,54 +508,6 @@ func TestServerAddResourceTemplate(t *testing.T) { } } -// TestServerSessionkeepaliveCancelOverwritten is to verify that `ServerSession.keepaliveCancel` is assigned exactly once, -// ensuring that only a single goroutine is responsible for the session's keepalive ping mechanism. -func TestServerSessionkeepaliveCancelOverwritten(t *testing.T) { - // Set KeepAlive to a long duration to ensure the keepalive - // goroutine stays alive for the duration of the test without actually sending - // ping requests, since we don't have a real client connection established. - server := NewServer(testImpl, &ServerOptions{KeepAlive: 5 * time.Second}) - ss := &ServerSession{server: server} - - // 1. Initialize the session. - _, err := ss.initialize(context.Background(), &InitializeParams{}) - if err != nil { - t.Fatalf("ServerSession initialize failed: %v", err) - } - - // 2. Call 'initialized' for the first time. This should start the keepalive mechanism. - _, err = ss.initialized(context.Background(), &InitializedParams{}) - if err != nil { - t.Fatalf("First initialized call failed: %v", err) - } - if ss.keepaliveCancel == nil { - t.Fatalf("expected ServerSession.keepaliveCancel to be set after the first call of initialized") - } - - // Save the cancel function and use defer to ensure resources are cleaned up. - firstCancel := ss.keepaliveCancel - defer firstCancel() - - // 3. Manually set the field to nil. - // Do this to facilitate the test's core assertion. The goal is to verify that - // 'ss.keepaliveCancel' is not assigned a second time. By setting it to nil, - // we can easily check after the next call if a new keepalive goroutine was started. - ss.keepaliveCancel = nil - - // 4. Call 'initialized' for the second time. This should return an error. - _, err = ss.initialized(context.Background(), &InitializedParams{}) - if err == nil { - t.Fatalf("Expected 'duplicate initialized received' error on second call, got nil") - } - - // 5. Re-check the field to ensure it remains nil. - // Since 'initialized' correctly returned an error and did not call - // 'startKeepalive', the field should remain unchanged. - if ss.keepaliveCancel != nil { - t.Fatal("expected ServerSession.keepaliveCancel to be nil after we manually niled it and re-initialized") - } -} - // panicks reports whether f() panics. func panics(f func()) (b bool) { defer func() { diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index a275d3c..3600277 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -363,6 +363,34 @@ func TestStreamableServerShutdown(t *testing.T) { } } +// TestStreamableStatelessKeepaliveRace verifies that there is no data race between +// ServerSession.startKeepalive and ServerSession.Close in stateless servers. +func TestStreamableStatelessKeepaliveRace(t *testing.T) { + ctx := context.Background() + server := NewServer(testImpl, &ServerOptions{KeepAlive: time.Hour}) + AddTool(server, &Tool{Name: "greet"}, sayHi) + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return server }, + &StreamableHTTPOptions{Stateless: true}, + ) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + defer httpServer.Close() + + for range 50 { + cs, err := NewClient(testImpl, nil).Connect(ctx, &StreamableClientTransport{ + Endpoint: httpServer.URL, + }, nil) + if err != nil { + t.Fatalf("NewClient() failed: %v", err) + } + _, _ = cs.CallTool(ctx, &CallToolParams{ + Name: "greet", + Arguments: map[string]any{"Name": "world"}, + }) + _ = cs.Close() + } +} + // TestClientReplay verifies that the client can recover from a mid-stream // network failure and receive replayed messages (if replay is configured). It // uses a proxy that is killed and restarted to simulate a recoverable network From f71c803175d54e3d28bd087bfd90bff50d3655d8 Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Wed, 8 Apr 2026 07:47:40 +0200 Subject: [PATCH 249/418] chore: preserve CNAME when deploying to GitHub pages (#884) --- .github/workflows/publish-docs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index c45111f..fa1eb97 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -22,3 +22,4 @@ jobs: publish_dir: ./docs publish_branch: gh-pages enable_jekyll: true + cname: go.sdk.modelcontextprotocol.io From f36591bf8a677c0e33eb9565f76853c07c3d57ae Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Wed, 8 Apr 2026 13:42:59 +0200 Subject: [PATCH 250/418] extauth: refactor enterprise auth tests (#879) --- auth/extauth/enterprise_handler_test.go | 436 ++++--------- auth/extauth/oidc_login_test.go | 610 ++++++------------ .../oauthtest/fake_authorization_server.go | 51 +- internal/oauthtest/fake_idp_server.go | 258 ++++++++ oauthex/client_test.go | 88 +++ 5 files changed, 689 insertions(+), 754 deletions(-) create mode 100644 internal/oauthtest/fake_idp_server.go create mode 100644 oauthex/client_test.go diff --git a/auth/extauth/enterprise_handler_test.go b/auth/extauth/enterprise_handler_test.go index c19b5a2..86f0414 100644 --- a/auth/extauth/enterprise_handler_test.go +++ b/auth/extauth/enterprise_handler_test.go @@ -1,43 +1,37 @@ // Copyright 2026 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. package extauth import ( "context" - "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" + "github.com/modelcontextprotocol/go-sdk/internal/oauthtest" "github.com/modelcontextprotocol/go-sdk/oauthex" "golang.org/x/oauth2" ) -// TestNewEnterpriseHandler_Validation tests validation in NewEnterpriseHandler. -func TestNewEnterpriseHandler_Validation(t *testing.T) { - validConfig := &EnterpriseHandlerConfig{ - IdPIssuerURL: "https://idp.example.com", - IdPCredentials: &oauthex.ClientCredentials{ - ClientID: "idp_client_id", - }, +func validEnterpriseHandlerConfig() *EnterpriseHandlerConfig { + return &EnterpriseHandlerConfig{ + IdPIssuerURL: "https://idp.example.com", + IdPCredentials: &oauthex.ClientCredentials{ClientID: "idp_client_id"}, MCPAuthServerURL: "https://mcp-auth.example.com", MCPResourceURI: "https://mcp.example.com", - MCPCredentials: &oauthex.ClientCredentials{ - ClientID: "mcp_client_id", - }, + MCPCredentials: &oauthex.ClientCredentials{ClientID: "mcp_client_id"}, IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { - token := &oauth2.Token{ - AccessToken: "mock_access_token", - TokenType: "Bearer", - } + token := &oauth2.Token{AccessToken: "mock", TokenType: "Bearer"} return token.WithExtra(map[string]any{"id_token": "mock_id_token"}), nil }, } +} +func TestNewEnterpriseHandler_Validation(t *testing.T) { tests := []struct { name string config *EnterpriseHandlerConfig @@ -50,151 +44,101 @@ func TestNewEnterpriseHandler_Validation(t *testing.T) { }, { name: "missing IdPIssuerURL", - config: &EnterpriseHandlerConfig{ - IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, - MCPAuthServerURL: "https://mcp-auth.example.com", - MCPResourceURI: "https://mcp.example.com", - MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, - IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil }, - }, + config: func() *EnterpriseHandlerConfig { + c := validEnterpriseHandlerConfig() + c.IdPIssuerURL = "" + return c + }(), wantError: "IdPIssuerURL is required", }, { name: "nil IdPCredentials", - config: &EnterpriseHandlerConfig{ - IdPIssuerURL: "https://idp.example.com", - IdPCredentials: nil, - MCPAuthServerURL: "https://mcp-auth.example.com", - MCPResourceURI: "https://mcp.example.com", - MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, - IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil }, - }, + config: func() *EnterpriseHandlerConfig { + c := validEnterpriseHandlerConfig() + c.IdPCredentials = nil + return c + }(), wantError: "IdPCredentials is required", }, { name: "invalid IdPCredentials - empty ClientID", - config: &EnterpriseHandlerConfig{ - IdPIssuerURL: "https://idp.example.com", - IdPCredentials: &oauthex.ClientCredentials{ - ClientID: "", // Invalid - empty - }, - MCPAuthServerURL: "https://mcp-auth.example.com", - MCPResourceURI: "https://mcp.example.com", - MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, - IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil }, - }, + config: func() *EnterpriseHandlerConfig { + c := validEnterpriseHandlerConfig() + c.IdPCredentials = &oauthex.ClientCredentials{ClientID: ""} + return c + }(), wantError: "invalid IdPCredentials", }, { name: "invalid IdPCredentials - empty ClientSecret in ClientSecretAuth", - config: &EnterpriseHandlerConfig{ - IdPIssuerURL: "https://idp.example.com", - IdPCredentials: &oauthex.ClientCredentials{ - ClientID: "idp_client_id", - ClientSecretAuth: &oauthex.ClientSecretAuth{ - ClientSecret: "", // Invalid - empty secret when ClientSecretAuth is set - }, - }, - MCPAuthServerURL: "https://mcp-auth.example.com", - MCPResourceURI: "https://mcp.example.com", - MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, - IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil }, - }, + config: func() *EnterpriseHandlerConfig { + c := validEnterpriseHandlerConfig() + c.IdPCredentials = &oauthex.ClientCredentials{ + ClientID: "idp_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: ""}, + } + return c + }(), wantError: "invalid IdPCredentials", }, { name: "missing MCPAuthServerURL", - config: &EnterpriseHandlerConfig{ - IdPIssuerURL: "https://idp.example.com", - IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, - MCPAuthServerURL: "", - MCPResourceURI: "https://mcp.example.com", - MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, - IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil }, - }, + config: func() *EnterpriseHandlerConfig { + c := validEnterpriseHandlerConfig() + c.MCPAuthServerURL = "" + return c + }(), wantError: "MCPAuthServerURL is required", }, { name: "missing MCPResourceURI", - config: &EnterpriseHandlerConfig{ - IdPIssuerURL: "https://idp.example.com", - IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, - MCPAuthServerURL: "https://mcp-auth.example.com", - MCPResourceURI: "", - MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, - IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil }, - }, + config: func() *EnterpriseHandlerConfig { + c := validEnterpriseHandlerConfig() + c.MCPResourceURI = "" + return c + }(), wantError: "MCPResourceURI is required", }, { name: "nil MCPCredentials", - config: &EnterpriseHandlerConfig{ - IdPIssuerURL: "https://idp.example.com", - IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, - MCPAuthServerURL: "https://mcp-auth.example.com", - MCPResourceURI: "https://mcp.example.com", - MCPCredentials: nil, - IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil }, - }, + config: func() *EnterpriseHandlerConfig { + c := validEnterpriseHandlerConfig() + c.MCPCredentials = nil + return c + }(), wantError: "MCPCredentials is required", }, { name: "invalid MCPCredentials - empty ClientID", - config: &EnterpriseHandlerConfig{ - IdPIssuerURL: "https://idp.example.com", - IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, - MCPAuthServerURL: "https://mcp-auth.example.com", - MCPResourceURI: "https://mcp.example.com", - MCPCredentials: &oauthex.ClientCredentials{ - ClientID: "", // Invalid - empty - }, - IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil }, - }, + config: func() *EnterpriseHandlerConfig { + c := validEnterpriseHandlerConfig() + c.MCPCredentials = &oauthex.ClientCredentials{ClientID: ""} + return c + }(), wantError: "invalid MCPCredentials", }, { name: "missing IDTokenFetcher", - config: &EnterpriseHandlerConfig{ - IdPIssuerURL: "https://idp.example.com", - IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, - MCPAuthServerURL: "https://mcp-auth.example.com", - MCPResourceURI: "https://mcp.example.com", - MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"}, - IDTokenFetcher: nil, - }, + config: func() *EnterpriseHandlerConfig { + c := validEnterpriseHandlerConfig() + c.IDTokenFetcher = nil + return c + }(), wantError: "IDTokenFetcher is required", }, { - name: "valid config - public clients (no ClientSecretAuth)", - config: validConfig, + name: "valid public clients", + config: validEnterpriseHandlerConfig(), wantError: "", }, { - name: "valid config - confidential clients (with ClientSecretAuth)", - config: &EnterpriseHandlerConfig{ - IdPIssuerURL: "https://idp.example.com", - IdPCredentials: &oauthex.ClientCredentials{ - ClientID: "idp_client_id", - ClientSecretAuth: &oauthex.ClientSecretAuth{ - ClientSecret: "idp_secret", - }, - }, - MCPAuthServerURL: "https://mcp-auth.example.com", - MCPResourceURI: "https://mcp.example.com", - MCPCredentials: &oauthex.ClientCredentials{ - ClientID: "mcp_client_id", - ClientSecretAuth: &oauthex.ClientSecretAuth{ - ClientSecret: "mcp_secret", - }, - }, - IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { - token := &oauth2.Token{ - AccessToken: "mock_access_token", - TokenType: "Bearer", - } - return token.WithExtra(map[string]any{"id_token": "mock_id_token"}), nil - }, - }, + name: "valid confidential clients", + config: func() *EnterpriseHandlerConfig { + c := validEnterpriseHandlerConfig() + c.IdPCredentials.ClientSecretAuth = &oauthex.ClientSecretAuth{ClientSecret: "idp_secret"} + c.MCPCredentials.ClientSecretAuth = &oauthex.ClientSecretAuth{ClientSecret: "mcp_secret"} + return c + }(), wantError: "", }, } @@ -221,45 +165,47 @@ func TestNewEnterpriseHandler_Validation(t *testing.T) { } } -// TestEnterpriseHandler_Authorize_E2E tests the complete enterprise authorization flow. func TestEnterpriseHandler_Authorize_E2E(t *testing.T) { - // Set up IdP (Identity Provider) fake server with token exchange support - idpServer := setupIdPServer(t) + const idJAGToken = "id-jag-token-from-idp" - // Set up MCP authorization server with JWT bearer grant support - mcpAuthServer := setupMCPAuthServer(t) - - // Create enterprise handler - handler, err := NewEnterpriseHandler(&EnterpriseHandlerConfig{ - IdPIssuerURL: idpServer.URL, - IdPCredentials: &oauthex.ClientCredentials{ - ClientID: "idp_client_id", - ClientSecretAuth: &oauthex.ClientSecretAuth{ - ClientSecret: "idp_secret", - }, + idpServer := oauthtest.NewFakeIdPServer(oauthtest.IdPConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "idp_client_id": {Secret: "idp_secret"}, }, - MCPAuthServerURL: mcpAuthServer.URL, - MCPResourceURI: "https://mcp.example.com", - MCPCredentials: &oauthex.ClientCredentials{ - ClientID: "mcp_client_id", - ClientSecretAuth: &oauthex.ClientSecretAuth{ - ClientSecret: "mcp_secret", - }, - }, - MCPScopes: []string{"read", "write"}, - IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { - token := &oauth2.Token{ - AccessToken: "mock_access_token", - TokenType: "Bearer", - } - return token.WithExtra(map[string]any{"id_token": "mock_id_token_from_user_login"}), nil + TokenExchangeConfig: &oauthtest.TokenExchangeConfig{ + IDJAGToken: idJAGToken, }, }) + idpServer.Start(t) + + mcpAuthServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + RegistrationConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "mcp_client_id": {Secret: "mcp_secret"}, + }, + }, + JWTBearerConfig: &oauthtest.JWTBearerConfig{ + ValidAssertions: []string{idJAGToken}, + }, + }) + mcpAuthServer.Start(t) + + config := validEnterpriseHandlerConfig() + config.IdPIssuerURL = idpServer.URL() + config.IdPCredentials.ClientSecretAuth = &oauthex.ClientSecretAuth{ClientSecret: "idp_secret"} + config.MCPAuthServerURL = mcpAuthServer.URL() + config.MCPCredentials.ClientSecretAuth = &oauthex.ClientSecretAuth{ClientSecret: "mcp_secret"} + config.MCPScopes = []string{"read", "write"} + config.IDTokenFetcher = func(ctx context.Context) (*oauth2.Token, error) { + token := &oauth2.Token{AccessToken: "mock_access_token", TokenType: "Bearer"} + return token.WithExtra(map[string]any{"id_token": "mock_id_token_from_user_login"}), nil + } + + handler, err := NewEnterpriseHandler(config) if err != nil { t.Fatalf("NewEnterpriseHandler failed: %v", err) } - // Simulate a 401 response from MCP server req := httptest.NewRequest(http.MethodGet, "https://mcp.example.com/api", nil) resp := &http.Response{ StatusCode: http.StatusUnauthorized, @@ -268,12 +214,10 @@ func TestEnterpriseHandler_Authorize_E2E(t *testing.T) { Request: req, } - // Perform authorization if err := handler.Authorize(context.Background(), req, resp); err != nil { t.Fatalf("Authorize failed: %v", err) } - // Verify token source is set tokenSource, err := handler.TokenSource(context.Background()) if err != nil { t.Fatalf("TokenSource failed: %v", err) @@ -282,164 +226,22 @@ func TestEnterpriseHandler_Authorize_E2E(t *testing.T) { t.Fatal("expected token source to be set after authorization") } - // Verify we can get a token token, err := tokenSource.Token() if err != nil { t.Fatalf("Token() failed: %v", err) } - if token.AccessToken != "mcp_access_token_from_jwt_bearer" { - t.Errorf("unexpected access token: got %q, want %q", - token.AccessToken, "mcp_access_token_from_jwt_bearer") + if token.AccessToken != "test_access_token" { + t.Errorf("AccessToken = %q, want %q", token.AccessToken, "test_access_token") } } -// setupIdPServer creates a fake IdP server that supports token exchange. -func setupIdPServer(t *testing.T) *httptest.Server { - t.Helper() - mux := http.NewServeMux() - - var server *httptest.Server - - // OAuth/OIDC metadata endpoint - uses closure to get server URL - mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "issuer": server.URL, - "token_endpoint": server.URL + "/token", - "authorization_endpoint": server.URL + "/authorize", - "code_challenge_methods_supported": []string{"S256"}, - }) - }) - - // Token endpoint - supports token exchange (RFC 8693) - mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, "failed to parse form", http.StatusBadRequest) - return - } - - grantType := r.Form.Get("grant_type") - if grantType != oauthex.GrantTypeTokenExchange { - http.Error(w, fmt.Sprintf("unsupported grant_type: %s", grantType), http.StatusBadRequest) - return - } - - // Verify client authentication - clientID := r.Form.Get("client_id") - clientSecret := r.Form.Get("client_secret") - if clientID != "idp_client_id" || clientSecret != "idp_secret" { - http.Error(w, "invalid client credentials", http.StatusUnauthorized) - return - } - - // Verify token exchange parameters - if r.Form.Get("requested_token_type") != oauthex.TokenTypeIDJAG { - http.Error(w, "invalid requested_token_type", http.StatusBadRequest) - return - } - if r.Form.Get("subject_token_type") != oauthex.TokenTypeIDToken { - http.Error(w, "invalid subject_token_type", http.StatusBadRequest) - return - } - if r.Form.Get("subject_token") == "" { - http.Error(w, "missing subject_token", http.StatusBadRequest) - return - } - - // Return ID-JAG (Identity Assertion JWT Authorization Grant) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "access_token": "id-jag-token-from-idp", - "issued_token_type": oauthex.TokenTypeIDJAG, - "token_type": "N_A", - "expires_in": 300, - "scope": "read write", - }) - }) - - server = httptest.NewServer(mux) - t.Cleanup(server.Close) - - return server -} - -// setupMCPAuthServer creates a fake MCP authorization server that supports JWT bearer grant. -func setupMCPAuthServer(t *testing.T) *httptest.Server { - t.Helper() - mux := http.NewServeMux() - - var server *httptest.Server - - // OAuth metadata endpoint - uses closure to get server URL - mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "issuer": server.URL, - "token_endpoint": server.URL + "/token", - "code_challenge_methods_supported": []string{"S256"}, - }) - }) - - // Token endpoint - supports JWT bearer grant (RFC 7523) - mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, "failed to parse form", http.StatusBadRequest) - return - } - - grantType := r.Form.Get("grant_type") - if grantType != "urn:ietf:params:oauth:grant-type:jwt-bearer" { - http.Error(w, fmt.Sprintf("unsupported grant_type: %s", grantType), http.StatusBadRequest) - return - } - - // Verify client authentication - clientID := r.Form.Get("client_id") - clientSecret := r.Form.Get("client_secret") - if clientID != "mcp_client_id" || clientSecret != "mcp_secret" { - http.Error(w, "invalid client credentials", http.StatusUnauthorized) - return - } - - // Verify assertion (ID-JAG) - assertion := r.Form.Get("assertion") - if assertion != "id-jag-token-from-idp" { - http.Error(w, "invalid assertion", http.StatusBadRequest) - return - } - - // Return access token - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "access_token": "mcp_access_token_from_jwt_bearer", - "token_type": "Bearer", - "expires_in": 3600, - "scope": "read write", - }) - }) - - server = httptest.NewServer(mux) - t.Cleanup(server.Close) - - return server -} - -// TestEnterpriseHandler_Authorize_IDTokenFetcherError tests error handling when IDTokenFetcher fails. func TestEnterpriseHandler_Authorize_IDTokenFetcherError(t *testing.T) { - handler, err := NewEnterpriseHandler(&EnterpriseHandlerConfig{ - IdPIssuerURL: "https://idp.example.com", - IdPCredentials: &oauthex.ClientCredentials{ - ClientID: "idp_client_id", - }, - MCPAuthServerURL: "https://mcp-auth.example.com", - MCPResourceURI: "https://mcp.example.com", - MCPCredentials: &oauthex.ClientCredentials{ - ClientID: "mcp_client_id", - }, - IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { - return nil, fmt.Errorf("user cancelled login") - }, - }) + config := validEnterpriseHandlerConfig() + config.IDTokenFetcher = func(ctx context.Context) (*oauth2.Token, error) { + return nil, fmt.Errorf("user cancelled login") + } + + handler, err := NewEnterpriseHandler(config) if err != nil { t.Fatalf("NewEnterpriseHandler failed: %v", err) } @@ -457,30 +259,12 @@ func TestEnterpriseHandler_Authorize_IDTokenFetcherError(t *testing.T) { t.Fatal("expected error from Authorize, got nil") } if !strings.Contains(err.Error(), "failed to obtain ID token") { - t.Errorf("expected error about ID token, got: %v", err) + t.Errorf("error = %v, want error containing %q", err, "failed to obtain ID token") } } -// TestEnterpriseHandler_TokenSource_BeforeAuthorization tests TokenSource before authorization. func TestEnterpriseHandler_TokenSource_BeforeAuthorization(t *testing.T) { - handler, err := NewEnterpriseHandler(&EnterpriseHandlerConfig{ - IdPIssuerURL: "https://idp.example.com", - IdPCredentials: &oauthex.ClientCredentials{ - ClientID: "idp_client_id", - }, - MCPAuthServerURL: "https://mcp-auth.example.com", - MCPResourceURI: "https://mcp.example.com", - MCPCredentials: &oauthex.ClientCredentials{ - ClientID: "mcp_client_id", - }, - IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { - token := &oauth2.Token{ - AccessToken: "mock_access_token", - TokenType: "Bearer", - } - return token.WithExtra(map[string]any{"id_token": "mock_id_token"}), nil - }, - }) + handler, err := NewEnterpriseHandler(validEnterpriseHandlerConfig()) if err != nil { t.Fatalf("NewEnterpriseHandler failed: %v", err) } diff --git a/auth/extauth/oidc_login_test.go b/auth/extauth/oidc_login_test.go index eb5a3eb..9cd9b93 100644 --- a/auth/extauth/oidc_login_test.go +++ b/auth/extauth/oidc_login_test.go @@ -1,468 +1,152 @@ // Copyright 2026 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. package extauth import ( "context" - "encoding/base64" - "encoding/json" "fmt" "net/http" - "net/http/httptest" "net/url" "strings" "testing" - "time" "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/internal/oauthtest" "github.com/modelcontextprotocol/go-sdk/oauthex" + "golang.org/x/oauth2" ) -// TestInitiateOIDCLogin tests the OIDC authorization request generation. -func TestInitiateOIDCLogin(t *testing.T) { - // Create mock IdP server - idpServer := createMockOIDCServer(t) - defer idpServer.Close() - config := &OIDCLoginConfig{ - IssuerURL: idpServer.URL, - Credentials: &oauthex.ClientCredentials{ - ClientID: "test-client", - }, - RedirectURL: "http://localhost:8080/callback", - Scopes: []string{"openid", "profile", "email"}, - HTTPClient: idpServer.Client(), - } - t.Run("successful initiation", func(t *testing.T) { - authReq, _, err := initiateOIDCLogin(context.Background(), config) - if err != nil { - t.Fatalf("initiateOIDCLogin failed: %v", err) - } - // Validate authURL - if authReq.authURL == "" { - t.Error("authURL is empty") - } - // Parse and validate URL parameters - u, err := url.Parse(authReq.authURL) - if err != nil { - t.Fatalf("Failed to parse authURL: %v", err) - } - q := u.Query() - if q.Get("response_type") != "code" { - t.Errorf("expected response_type 'code', got '%s'", q.Get("response_type")) - } - if q.Get("client_id") != "test-client" { - t.Errorf("expected client_id 'test-client', got '%s'", q.Get("client_id")) - } - if q.Get("redirect_uri") != "http://localhost:8080/callback" { - t.Errorf("expected redirect_uri 'http://localhost:8080/callback', got '%s'", q.Get("redirect_uri")) - } - if q.Get("scope") != "openid profile email" { - t.Errorf("expected scope 'openid profile email', got '%s'", q.Get("scope")) - } - if q.Get("code_challenge_method") != "S256" { - t.Errorf("expected code_challenge_method 'S256', got '%s'", q.Get("code_challenge_method")) - } - // Validate state is generated - if authReq.state == "" { - t.Error("state is empty") - } - if q.Get("state") != authReq.state { - t.Errorf("state in URL doesn't match returned state") - } - // Validate PKCE parameters - if authReq.codeVerifier == "" { - t.Error("codeVerifier is empty") - } - if q.Get("code_challenge") == "" { - t.Error("code_challenge is empty") - } - }) - t.Run("with login_hint", func(t *testing.T) { - configWithHint := *config - configWithHint.LoginHint = "user@example.com" - authReq, _, err := initiateOIDCLogin(context.Background(), &configWithHint) - if err != nil { - t.Fatalf("initiateOIDCLogin failed: %v", err) - } - u, err := url.Parse(authReq.authURL) - if err != nil { - t.Fatalf("Failed to parse authURL: %v", err) - } - q := u.Query() - if q.Get("login_hint") != "user@example.com" { - t.Errorf("expected login_hint 'user@example.com', got '%s'", q.Get("login_hint")) - } - }) - t.Run("without login_hint", func(t *testing.T) { - authReq, _, err := initiateOIDCLogin(context.Background(), config) - if err != nil { - t.Fatalf("initiateOIDCLogin failed: %v", err) - } - u, err := url.Parse(authReq.authURL) - if err != nil { - t.Fatalf("Failed to parse authURL: %v", err) - } - q := u.Query() - if q.Has("login_hint") { - t.Errorf("expected no login_hint parameter, but got '%s'", q.Get("login_hint")) - } - }) - t.Run("nil config", func(t *testing.T) { - _, _, err := initiateOIDCLogin(context.Background(), nil) - if err == nil { - t.Error("expected error for nil config, got nil") - } - }) - t.Run("missing openid scope", func(t *testing.T) { - badConfig := *config - badConfig.Scopes = []string{"profile", "email"} // Missing "openid" - _, _, err := initiateOIDCLogin(context.Background(), &badConfig) - if err == nil { - t.Error("expected error for missing openid scope, got nil") - } - if !strings.Contains(err.Error(), "openid") { - t.Errorf("expected error about missing 'openid', got: %v", err) - } - }) - t.Run("missing required fields", func(t *testing.T) { - tests := []struct { - name string - mutate func(*OIDCLoginConfig) - expectErr string - }{ - { - name: "missing IssuerURL", - mutate: func(c *OIDCLoginConfig) { c.IssuerURL = "" }, - expectErr: "IssuerURL is required", - }, - { - name: "missing ClientID", - mutate: func(c *OIDCLoginConfig) { c.Credentials.ClientID = "" }, - expectErr: "ClientID is required", - }, - { - name: "missing RedirectURL", - mutate: func(c *OIDCLoginConfig) { - c.RedirectURL = "" - // Ensure ClientID is present to test RedirectURL validation - c.Credentials = &oauthex.ClientCredentials{ClientID: "test"} - }, - expectErr: "RedirectURL is required", - }, - { - name: "missing Scopes", - mutate: func(c *OIDCLoginConfig) { - c.Scopes = nil - // Ensure required fields are present to test Scopes validation - c.Credentials = &oauthex.ClientCredentials{ClientID: "test"} - c.RedirectURL = "http://localhost:8080/callback" - }, - expectErr: "at least one scope is required", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - badConfig := *config - tt.mutate(&badConfig) - _, _, err := initiateOIDCLogin(context.Background(), &badConfig) - if err == nil { - t.Error("expected error, got nil") - } - if !strings.Contains(err.Error(), tt.expectErr) { - t.Errorf("expected error containing '%s', got: %v", tt.expectErr, err) - } - }) - } - }) -} +const testRedirectURL = "http://localhost:18927/callback" -// TestCompleteOIDCLogin tests the authorization code exchange. -func TestCompleteOIDCLogin(t *testing.T) { - // Create mock IdP server - idpServer := createMockOIDCServerWithToken(t) - defer idpServer.Close() - config := &OIDCLoginConfig{ - IssuerURL: idpServer.URL, +func validOIDCLoginConfig(issuerURL string) *OIDCLoginConfig { + return &OIDCLoginConfig{ + IssuerURL: issuerURL, Credentials: &oauthex.ClientCredentials{ ClientID: "test-client", ClientSecretAuth: &oauthex.ClientSecretAuth{ ClientSecret: "test-secret", }, }, - RedirectURL: "http://localhost:8080/callback", + RedirectURL: testRedirectURL, Scopes: []string{"openid", "profile", "email"}, - HTTPClient: idpServer.Client(), } - t.Run("successful code exchange", func(t *testing.T) { - // First initiate to get oauth2Config - _, oauth2Config, err := initiateOIDCLogin(context.Background(), config) - if err != nil { - t.Fatalf("initiateOIDCLogin failed: %v", err) - } +} - token, err := completeOIDCLogin( - context.Background(), - config, - oauth2Config, - "test-auth-code", - "test-code-verifier", - ) - if err != nil { - t.Fatalf("completeOIDCLogin failed: %v", err) +func TestPerformOIDCLogin(t *testing.T) { + idpServer := oauthtest.NewFakeIdPServer(oauthtest.IdPConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "test-client": { + Secret: "test-secret", + RedirectURIs: []string{testRedirectURL}, + }, + }, + }) + idpServer.Start(t) + + config := validOIDCLoginConfig(idpServer.URL()) + + // fetchAuthCode visits the authorization URL on the fake IdP server, + // follows the redirect, and extracts the authorization code and state. + fetchAuthCode := func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, } - // Validate tokens + resp, err := client.Get(args.URL) + if err != nil { + return nil, fmt.Errorf("failed to visit auth URL: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusFound { + return nil, fmt.Errorf("expected redirect, got status %d", resp.StatusCode) + } + loc, err := resp.Location() + if err != nil { + return nil, fmt.Errorf("missing Location header: %w", err) + } + return &auth.AuthorizationResult{ + Code: loc.Query().Get("code"), + State: loc.Query().Get("state"), + }, nil + } + + verifyOIDCToken := func(token *oauth2.Token) error { idToken, ok := token.Extra("id_token").(string) if !ok || idToken == "" { - t.Error("id_token is missing or empty") + return fmt.Errorf("id_token is missing or empty") + } + parts := strings.Split(idToken, ".") + if len(parts) != 3 { + return fmt.Errorf("id_token is not a JWT (expected 3 parts, got %d)", len(parts)) } if token.AccessToken == "" { - t.Error("AccessToken is empty") + return fmt.Errorf("access token is empty") } if token.TokenType != "Bearer" { - t.Errorf("expected TokenType 'Bearer', got '%s'", token.TokenType) + return fmt.Errorf("token type = %q, want %q", token.TokenType, "Bearer") } - if token.Expiry.IsZero() { - t.Error("Expiry is zero") - } - }) - t.Run("missing parameters", func(t *testing.T) { - _, oauth2Config, _ := initiateOIDCLogin(context.Background(), config) - - tests := []struct { - name string - authCode string - codeVerifier string - expectErr string - }{ - { - name: "missing authCode", - authCode: "", - codeVerifier: "test-verifier", - expectErr: "authCode is required", - }, - { - name: "missing codeVerifier", - authCode: "test-code", - codeVerifier: "", - expectErr: "codeVerifier is required", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := completeOIDCLogin( - context.Background(), - config, - oauth2Config, - tt.authCode, - tt.codeVerifier, - ) - if err == nil { - t.Error("expected error, got nil") - } - if !strings.Contains(err.Error(), tt.expectErr) { - t.Errorf("expected error containing '%s', got: %v", tt.expectErr, err) - } - }) - } - }) -} - -// TestOIDCLoginE2E tests the complete OIDC login flow end-to-end. -func TestOIDCLoginE2E(t *testing.T) { - // Create mock IdP server - idpServer := createMockOIDCServerWithToken(t) - defer idpServer.Close() - config := &OIDCLoginConfig{ - IssuerURL: idpServer.URL, - Credentials: &oauthex.ClientCredentials{ - ClientID: "test-client", - ClientSecretAuth: &oauthex.ClientSecretAuth{ - ClientSecret: "test-secret", - }, - }, - RedirectURL: "http://localhost:8080/callback", - Scopes: []string{"openid", "profile", "email"}, - HTTPClient: idpServer.Client(), - } - // Step 1: Initiate login - authReq, oauth2Config, err := initiateOIDCLogin(context.Background(), config) - if err != nil { - t.Fatalf("initiateOIDCLogin failed: %v", err) - } - // Step 2: Simulate user authentication and redirect - // (In real flow, user would visit authReq.authURL and IdP would redirect back) - // Here we just use a mock authorization code - mockAuthCode := "mock-authorization-code" - // Step 3: Complete login with authorization code - token, err := completeOIDCLogin( - context.Background(), - config, - oauth2Config, - mockAuthCode, - authReq.codeVerifier, - ) - if err != nil { - t.Fatalf("completeOIDCLogin failed: %v", err) - } - // Validate we got an ID token - idToken, ok := token.Extra("id_token").(string) - if !ok || idToken == "" { - t.Error("Expected ID token, got empty or missing") - } - // Validate ID token is a JWT (has 3 parts) - parts := strings.Split(idToken, ".") - if len(parts) != 3 { - t.Errorf("Expected JWT with 3 parts, got %d parts", len(parts)) - } -} - -// createMockOIDCServer creates a mock OIDC server for testing initiateOIDCLogin. -func createMockOIDCServer(t *testing.T) *httptest.Server { - var serverURL string - server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Handle OIDC discovery - if r.URL.Path == "/.well-known/openid-configuration" { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "issuer": serverURL, - "authorization_endpoint": serverURL + "/authorize", - "token_endpoint": serverURL + "/token", - "jwks_uri": serverURL + "/.well-known/jwks.json", - "response_types_supported": []string{"code"}, - "code_challenge_methods_supported": []string{"S256"}, - "grant_types_supported": []string{"authorization_code"}, - }) - return - } - http.NotFound(w, r) - })) - serverURL = server.URL - return server -} - -// createMockOIDCServerWithToken creates a mock OIDC server that also handles token exchange. -func createMockOIDCServerWithToken(t *testing.T) *httptest.Server { - var serverURL string - server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Handle OIDC discovery - if r.URL.Path == "/.well-known/openid-configuration" { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "issuer": serverURL, - "authorization_endpoint": serverURL + "/authorize", - "token_endpoint": serverURL + "/token", - "jwks_uri": serverURL + "/.well-known/jwks.json", - "response_types_supported": []string{"code"}, - "code_challenge_methods_supported": []string{"S256"}, - "grant_types_supported": []string{"authorization_code"}, - }) - return - } - // Handle token endpoint - if r.URL.Path == "/token" { - if err := r.ParseForm(); err != nil { - http.Error(w, "failed to parse form", http.StatusBadRequest) - return - } - // Validate grant type - if r.FormValue("grant_type") != "authorization_code" { - http.Error(w, "invalid grant_type", http.StatusBadRequest) - return - } - // Create mock ID token (JWT) - now := time.Now().Unix() - idToken := fmt.Sprintf("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.%s.mock-signature", - base64EncodeClaims(map[string]any{ - "iss": serverURL, - "sub": "test-user", - "aud": "test-client", - "exp": now + 3600, - "iat": now, - "email": "test@example.com", - })) - // Return token response - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "access_token": "mock-access-token", - "token_type": "Bearer", - "expires_in": 3600, - "refresh_token": "mock-refresh-token", - "id_token": idToken, - }) - return - } - http.NotFound(w, r) - })) - serverURL = server.URL - return server -} - -// base64EncodeClaims encodes JWT claims for testing. -func base64EncodeClaims(claims map[string]any) string { - claimsJSON, _ := json.Marshal(claims) - return base64.RawURLEncoding.EncodeToString(claimsJSON) -} - -// TestPerformOIDCLogin tests the combined OIDC login flow with callback. -func TestPerformOIDCLogin(t *testing.T) { - // Create mock IdP server - idpServer := createMockOIDCServerWithToken(t) - defer idpServer.Close() - config := &OIDCLoginConfig{ - IssuerURL: idpServer.URL, - Credentials: &oauthex.ClientCredentials{ - ClientID: "test-client", - ClientSecretAuth: &oauthex.ClientSecretAuth{ - ClientSecret: "test-secret", - }, - }, - RedirectURL: "http://localhost:8080/callback", - Scopes: []string{"openid", "profile", "email"}, - HTTPClient: idpServer.Client(), + return nil } t.Run("successful flow", func(t *testing.T) { - token, err := PerformOIDCLogin(context.Background(), config, + token, err := PerformOIDCLogin(context.Background(), config, fetchAuthCode) + if err != nil { + t.Fatalf("PerformOIDCLogin() error = %v", err) + } + if err := verifyOIDCToken(token); err != nil { + t.Errorf("invalid token returned: %v", err) + } + }) + + t.Run("with login_hint", func(t *testing.T) { + configWithHint := *config + configWithHint.LoginHint = "user@example.com" + + token, err := PerformOIDCLogin(context.Background(), &configWithHint, func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { - // Validate authURL has required parameters u, err := url.Parse(args.URL) if err != nil { return nil, fmt.Errorf("invalid authURL: %w", err) } - q := u.Query() - if q.Get("response_type") != "code" { - return nil, fmt.Errorf("missing response_type") + if got := u.Query().Get("login_hint"); got != "user@example.com" { + t.Errorf("login_hint = %q, want %q", got, "user@example.com") } - if q.Get("state") == "" { - return nil, fmt.Errorf("missing state") - } - - // Simulate successful user authentication - return &auth.AuthorizationResult{ - Code: "mock-auth-code", - State: q.Get("state"), // Return the expected state from URL - }, nil + return fetchAuthCode(ctx, args) }) - if err != nil { - t.Fatalf("PerformOIDCLogin failed: %v", err) + t.Fatalf("PerformOIDCLogin() error = %v", err) } + if err := verifyOIDCToken(token); err != nil { + t.Errorf("invalid token returned: %v", err) + } + }) - idToken, ok := token.Extra("id_token").(string) - if !ok || idToken == "" { - t.Error("id_token is missing or empty") + t.Run("without login_hint", func(t *testing.T) { + token, err := PerformOIDCLogin(context.Background(), config, + func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { + u, err := url.Parse(args.URL) + if err != nil { + return nil, fmt.Errorf("invalid authURL: %w", err) + } + if u.Query().Has("login_hint") { + t.Errorf("login_hint should be absent, got %q", u.Query().Get("login_hint")) + } + return fetchAuthCode(ctx, args) + }) + if err != nil { + t.Fatalf("PerformOIDCLogin() error = %v", err) } - if token.AccessToken == "" { - t.Error("AccessToken is empty") + if err := verifyOIDCToken(token); err != nil { + t.Errorf("invalid token returned: %v", err) } }) t.Run("state mismatch", func(t *testing.T) { _, err := PerformOIDCLogin(context.Background(), config, func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { - // Return wrong state to simulate CSRF attack return &auth.AuthorizationResult{ Code: "mock-auth-code", State: "wrong-state", @@ -470,10 +154,10 @@ func TestPerformOIDCLogin(t *testing.T) { }) if err == nil { - t.Error("expected error for state mismatch, got nil") + t.Fatal("expected error for state mismatch, got nil") } if !strings.Contains(err.Error(), "state mismatch") { - t.Errorf("expected state mismatch error, got: %v", err) + t.Errorf("error = %v, want error containing %q", err, "state mismatch") } }) @@ -484,20 +168,96 @@ func TestPerformOIDCLogin(t *testing.T) { }) if err == nil { - t.Error("expected error, got nil") + t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "user cancelled") { - t.Errorf("expected 'user cancelled' error, got: %v", err) - } - }) - - t.Run("nil fetcher", func(t *testing.T) { - _, err := PerformOIDCLogin(context.Background(), config, nil) - if err == nil { - t.Error("expected error for nil fetcher, got nil") - } - if !strings.Contains(err.Error(), "authCodeFetcher is required") { - t.Errorf("expected 'authCodeFetcher is required' error, got: %v", err) + t.Errorf("error = %v, want error containing %q", err, "user cancelled") } }) } + +func TestPerformOIDCLogin_ValidationErrors(t *testing.T) { + const nonexistentIssuer = "https://idp.example.com" + + noopFetcher := func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { + return nil, fmt.Errorf("should not be called") + } + + tests := []struct { + name string + config *OIDCLoginConfig + fetcher auth.AuthorizationCodeFetcher + wantErr string + }{ + { + name: "nil config", + config: nil, + fetcher: noopFetcher, + wantErr: "config is required", + }, + { + name: "nil fetcher", + config: validOIDCLoginConfig(nonexistentIssuer), + fetcher: nil, + wantErr: "authCodeFetcher is required", + }, + { + name: "missing IssuerURL", + config: validOIDCLoginConfig(""), + fetcher: noopFetcher, + wantErr: "IssuerURL is required", + }, + { + name: "missing ClientID", + config: func() *OIDCLoginConfig { + c := validOIDCLoginConfig(nonexistentIssuer) + c.Credentials = &oauthex.ClientCredentials{} + return c + }(), + fetcher: noopFetcher, + wantErr: "ClientID is required", + }, + { + name: "missing RedirectURL", + config: func() *OIDCLoginConfig { + c := validOIDCLoginConfig(nonexistentIssuer) + c.RedirectURL = "" + return c + }(), + fetcher: noopFetcher, + wantErr: "RedirectURL is required", + }, + { + name: "missing Scopes", + config: func() *OIDCLoginConfig { + c := validOIDCLoginConfig(nonexistentIssuer) + c.Scopes = nil + return c + }(), + fetcher: noopFetcher, + wantErr: "at least one scope is required", + }, + { + name: "missing openid scope", + config: func() *OIDCLoginConfig { + c := validOIDCLoginConfig(nonexistentIssuer) + c.Scopes = []string{"profile", "email"} + return c + }(), + fetcher: noopFetcher, + wantErr: "openid", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := PerformOIDCLogin(context.Background(), tt.config, tt.fetcher) + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error = %v, want error containing %q", err, tt.wantErr) + } + }) + } +} diff --git a/internal/oauthtest/fake_authorization_server.go b/internal/oauthtest/fake_authorization_server.go index 6fed39b..44f937f 100644 --- a/internal/oauthtest/fake_authorization_server.go +++ b/internal/oauthtest/fake_authorization_server.go @@ -49,6 +49,14 @@ type RegistrationConfig struct { DynamicClientRegistrationEnabled bool } +// JWTBearerConfig configures support for the JWT Bearer grant type (RFC 7523) +// on a [FakeAuthorizationServer]. +type JWTBearerConfig struct { + // ValidAssertions is the set of assertion values that are accepted. + // If empty, any non-empty assertion is accepted. + ValidAssertions []string +} + // Config holds configuration for FakeAuthorizationServer. type Config struct { // The optional path component of the issuer URL. @@ -59,6 +67,9 @@ type Config struct { MetadataEndpointConfig *MetadataEndpointConfig // Configuration for client registration. RegistrationConfig *RegistrationConfig + // JWTBearerConfig enables RFC 7523 JWT Bearer grant at the /token endpoint. + // If non-nil, the server accepts grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer. + JWTBearerConfig *JWTBearerConfig } // FakeAuthorizationServer is a fake OAuth 2.0 Authorization Server for testing. @@ -253,10 +264,19 @@ func (s *FakeAuthorizationServer) handleToken(w http.ResponseWriter, r *http.Req http.Error(w, err.Error(), http.StatusUnauthorized) return } - if r.Form.Get("grant_type") != "authorization_code" { - http.Error(w, "invalid grant_type", http.StatusBadRequest) - return + + grantType := r.Form.Get("grant_type") + switch grantType { + case "authorization_code": + s.handleAuthorizationCodeGrant(w, r) + case "urn:ietf:params:oauth:grant-type:jwt-bearer": + s.handleJWTBearerGrant(w, r) + default: + http.Error(w, fmt.Sprintf("unsupported grant_type: %s", grantType), http.StatusBadRequest) } +} + +func (s *FakeAuthorizationServer) handleAuthorizationCodeGrant(w http.ResponseWriter, r *http.Request) { code := r.Form.Get("code") if code == "" { http.Error(w, "missing code", http.StatusBadRequest) @@ -287,6 +307,31 @@ func (s *FakeAuthorizationServer) handleToken(w http.ResponseWriter, r *http.Req }) } +func (s *FakeAuthorizationServer) handleJWTBearerGrant(w http.ResponseWriter, r *http.Request) { + if s.config.JWTBearerConfig == nil { + http.Error(w, "JWT bearer grant not supported", http.StatusBadRequest) + return + } + assertion := r.Form.Get("assertion") + if assertion == "" { + http.Error(w, "missing assertion", http.StatusBadRequest) + return + } + if len(s.config.JWTBearerConfig.ValidAssertions) > 0 { + if !slices.Contains(s.config.JWTBearerConfig.ValidAssertions, assertion) { + http.Error(w, "invalid assertion", http.StatusBadRequest) + return + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "access_token": "test_access_token", + "token_type": "Bearer", + "expires_in": 3600, + }) +} + func (s *FakeAuthorizationServer) authenticateClient(r *http.Request) error { clientID, clientSecret, ok := r.BasicAuth() if !ok { diff --git a/internal/oauthtest/fake_idp_server.go b/internal/oauthtest/fake_idp_server.go new file mode 100644 index 0000000..b7057ff --- /dev/null +++ b/internal/oauthtest/fake_idp_server.go @@ -0,0 +1,258 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package oauthtest + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "maps" + "net/http" + "net/http/httptest" + "slices" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/oauthex" +) + +// TokenExchangeConfig configures RFC 8693 token exchange support on a [FakeIdPServer]. +type TokenExchangeConfig struct { + // IDJAGToken is the ID-JAG value returned from token exchange. + // Defaults to "test-id-jag-token" if empty. + IDJAGToken string +} + +// IdPConfig holds configuration for [FakeIdPServer]. +type IdPConfig struct { + // PreregisteredClients maps client IDs to their info. + PreregisteredClients map[string]ClientInfo + + // TokenExchangeConfig enables RFC 8693 token exchange at the /token endpoint. + // If non-nil, the server accepts grant_type=urn:ietf:params:oauth:grant-type:token-exchange. + TokenExchangeConfig *TokenExchangeConfig +} + +// FakeIdPServer is a fake OIDC Identity Provider for testing. +// It supports: +// - OIDC discovery (/.well-known/openid-configuration) +// - Authorization Code Grant with PKCE +// - ID Token issuance (fake JWTs) +// - RFC 8693 Token Exchange (ID Token → ID-JAG), if configured +type FakeIdPServer struct { + server *httptest.Server + mux *http.ServeMux + config IdPConfig + clients map[string]ClientInfo + codes map[string]codeInfo +} + +// NewFakeIdPServer creates a new FakeIdPServer. +func NewFakeIdPServer(config IdPConfig) *FakeIdPServer { + s := &FakeIdPServer{ + mux: http.NewServeMux(), + config: config, + clients: make(map[string]ClientInfo), + codes: make(map[string]codeInfo), + } + maps.Copy(s.clients, config.PreregisteredClients) + + s.mux.HandleFunc("/.well-known/openid-configuration", s.handleMetadata) + s.mux.HandleFunc("/authorize", s.handleAuthorize) + s.mux.HandleFunc("/token", s.handleToken) + s.server = httptest.NewUnstartedServer(s.mux) + + return s +} + +// Start starts the HTTP server and registers a cleanup function on t. +func (s *FakeIdPServer) Start(t testing.TB) { + s.server.Start() + t.Cleanup(s.server.Close) +} + +// URL returns the base URL of the server (issuer). +func (s *FakeIdPServer) URL() string { + return s.server.URL +} + +func (s *FakeIdPServer) handleMetadata(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + grantTypes := []string{"authorization_code"} + if s.config.TokenExchangeConfig != nil { + grantTypes = append(grantTypes, oauthex.GrantTypeTokenExchange) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "issuer": s.URL(), + "authorization_endpoint": s.URL() + "/authorize", + "token_endpoint": s.URL() + "/token", + "jwks_uri": s.URL() + "/.well-known/jwks.json", + "response_types_supported": []string{"code"}, + "code_challenge_methods_supported": []string{"S256"}, + "grant_types_supported": grantTypes, + }) +} + +func (s *FakeIdPServer) handleAuthorize(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + clientID := r.URL.Query().Get("client_id") + clientInfo, ok := s.clients[clientID] + if !ok { + http.Error(w, "unknown client_id", http.StatusBadRequest) + return + } + redirectURI := r.URL.Query().Get("redirect_uri") + if redirectURI == "" { + http.Error(w, "missing redirect_uri", http.StatusBadRequest) + return + } + if !slices.Contains(clientInfo.RedirectURIs, redirectURI) { + http.Error(w, "invalid redirect_uri", http.StatusBadRequest) + return + } + codeChallenge := r.URL.Query().Get("code_challenge") + if codeChallenge == "" { + http.Error(w, "missing code_challenge", http.StatusBadRequest) + return + } + code := rand.Text() + s.codes[code] = codeInfo{CodeChallenge: codeChallenge} + state := r.URL.Query().Get("state") + redirectURL := fmt.Sprintf("%s?code=%s&state=%s", redirectURI, code, state) + http.Redirect(w, r, redirectURL, http.StatusFound) +} + +func (s *FakeIdPServer) handleToken(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "failed to parse form", http.StatusBadRequest) + return + } + + if err := s.authenticateClient(r); err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + grantType := r.Form.Get("grant_type") + switch grantType { + case "authorization_code": + s.handleAuthorizationCodeGrant(w, r) + case oauthex.GrantTypeTokenExchange: + s.handleTokenExchangeGrant(w, r) + default: + http.Error(w, fmt.Sprintf("unsupported grant_type: %s", grantType), http.StatusBadRequest) + } +} + +func (s *FakeIdPServer) handleAuthorizationCodeGrant(w http.ResponseWriter, r *http.Request) { + code := r.Form.Get("code") + if code == "" { + http.Error(w, "missing code", http.StatusBadRequest) + return + } + ci, ok := s.codes[code] + if !ok { + http.Error(w, "unknown authorization code", http.StatusBadRequest) + return + } + verifier := r.Form.Get("code_verifier") + if verifier == "" { + http.Error(w, "missing code_verifier", http.StatusBadRequest) + return + } + sha := sha256.Sum256([]byte(verifier)) + expectedChallenge := base64.RawURLEncoding.EncodeToString(sha[:]) + if expectedChallenge != ci.CodeChallenge { + http.Error(w, "PKCE verification failed", http.StatusBadRequest) + return + } + + clientID := r.Form.Get("client_id") + now := time.Now().Unix() + idToken := fakeIDToken(s.URL(), clientID, now) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "access_token": "test_access_token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "test_refresh_token", + "id_token": idToken, + }) +} + +func (s *FakeIdPServer) handleTokenExchangeGrant(w http.ResponseWriter, r *http.Request) { + if s.config.TokenExchangeConfig == nil { + http.Error(w, "token exchange not supported", http.StatusBadRequest) + return + } + if r.Form.Get("requested_token_type") != oauthex.TokenTypeIDJAG { + http.Error(w, "invalid requested_token_type", http.StatusBadRequest) + return + } + if r.Form.Get("subject_token_type") != oauthex.TokenTypeIDToken { + http.Error(w, "invalid subject_token_type", http.StatusBadRequest) + return + } + if r.Form.Get("subject_token") == "" { + http.Error(w, "missing subject_token", http.StatusBadRequest) + return + } + + idJAG := s.config.TokenExchangeConfig.IDJAGToken + if idJAG == "" { + idJAG = "test-id-jag-token" + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "access_token": idJAG, + "issued_token_type": oauthex.TokenTypeIDJAG, + "token_type": "N_A", + "expires_in": 300, + }) +} + +func (s *FakeIdPServer) authenticateClient(r *http.Request) error { + clientID := r.Form.Get("client_id") + clientSecret := r.Form.Get("client_secret") + clientInfo, ok := s.clients[clientID] + if !ok { + return fmt.Errorf("unknown client") + } + if clientInfo.Secret != clientSecret { + return fmt.Errorf("invalid client credentials") + } + return nil +} + +// fakeIDToken creates a fake JWT ID token for testing. +// The token has a valid structure but is not cryptographically signed. +func fakeIDToken(issuer, audience string, now int64) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) + claimsJSON, _ := json.Marshal(map[string]any{ + "iss": issuer, + "sub": "test-user", + "aud": audience, + "exp": now + 3600, + "iat": now, + "email": "test@example.com", + }) + claims := base64.RawURLEncoding.EncodeToString(claimsJSON) + return header + "." + claims + ".mock-signature" +} diff --git a/oauthex/client_test.go b/oauthex/client_test.go new file mode 100644 index 0000000..b78e9c8 --- /dev/null +++ b/oauthex/client_test.go @@ -0,0 +1,88 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package oauthex + +import ( + "reflect" + "strings" + "testing" +) + +func TestClientCredentials_Validate(t *testing.T) { + tests := []struct { + name string + creds ClientCredentials + wantErr string + }{ + { + name: "valid public client", + creds: ClientCredentials{ClientID: "my-client"}, + }, + { + name: "valid confidential client", + creds: ClientCredentials{ + ClientID: "my-client", + ClientSecretAuth: &ClientSecretAuth{ + ClientSecret: "my-secret", + }, + }, + }, + { + name: "empty client ID", + creds: ClientCredentials{}, + wantErr: "ClientID is required", + }, + { + name: "empty secret in ClientSecretAuth", + creds: ClientCredentials{ + ClientID: "my-client", + ClientSecretAuth: &ClientSecretAuth{}, + }, + wantErr: "ClientSecret is required", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.creds.Validate() + if tt.wantErr == "" { + if err != nil { + t.Fatalf("Validate() unexpected error: %v", err) + } + return + } + if err == nil { + t.Fatalf("Validate() expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("Validate() error = %v, want error containing %q", err, tt.wantErr) + } + }) + } +} + +// TestClientCredentials_ValidateCoversAllAuthFields uses reflection to detect +// when new authentication method fields are added to ClientCredentials without +// updating Validate. If this test fails, update Validate() to handle the new +// field and increment knownAuthMethods. +func TestClientCredentials_ValidateCoversAllAuthFields(t *testing.T) { + const knownAuthMethods = 1 // ClientSecretAuth + + typ := reflect.TypeOf(ClientCredentials{}) + var pointerFields int + for i := range typ.NumField() { + f := typ.Field(i) + if f.Name == "ClientID" { + continue + } + if f.Type.Kind() != reflect.Ptr { + t.Errorf("field %q is %v, expected a pointer to an auth method struct", f.Name, f.Type.Kind()) + } + pointerFields++ + } + + if pointerFields != knownAuthMethods { + t.Fatalf("ClientCredentials has %d auth method fields but Validate only knows about %d -- update Validate() and this test", pointerFields, knownAuthMethods) + } +} From 12098618587ba15f6b6d6d178c27245c6e9c6ecc Mon Sep 17 00:00:00 2001 From: Dinesh <82709381+Dinesht04@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:25:43 +0000 Subject: [PATCH 251/418] Add documentation backed by mkdocs (#883) Affects /docs - (Refined Documentation for deployment and added Some Supporting documentation) Added a github action for deployment Fixes #835 Added Support for mkdocs Example - https://dinesht04.github.io/go-sdk/ Requires Configuration of Github pages as follows:- 1. Enable Github Pages 2. Select gh-pages branch for deployment gh-pages ## Sources # For mkdocs.yaml 1. https://github.com/modelcontextprotocol/python-sdk/blob/main/mkdocs.yml 2. https://github.com/modelcontextprotocol/java-sdk/blob/main/mkdocs.yml 3. MKDocs official documentation # For Github Action 1. https://github.com/modelcontextprotocol/python-sdk/blob/main/.github/workflows/publish-docs-manually.yml 2. https://github.com/modelcontextprotocol/java-sdk/blob/main/.github/workflows/docs.yml 3. https://github.com/james-willett/material-mkdocs-youtube-2024/blob/main/.github/workflows/ci.yml - From Youtube Tutorial --- .github/workflows/publish-docs.yml | 27 ++-- CONTRIBUTING.md | 20 ++- docs/CNAME | 1 + docs/README.md | 19 ++- docs/contributing.md | 1 + docs/images/logo-dark.svg | 12 ++ docs/images/logo-light.svg | 69 +++++++++ docs/index.html | 15 -- docs/mcpgodebug.md | 2 +- docs/protocol.md | 8 +- docs/quick_start.md | 105 +++++++++++++ docs/server.md | 4 +- internal/docs/README.src.md | 19 ++- internal/docs/doc.go | 1 + internal/docs/mcpgodebug.src.md | 2 +- internal/docs/protocol.src.md | 6 +- internal/docs/quick_start.src.md | 27 ++++ internal/docs/server.src.md | 4 +- internal/readme/contributing.src.md | 222 ++++++++++++++++++++++++++++ internal/readme/doc.go | 4 +- mkdocs.yaml | 98 ++++++++++++ 21 files changed, 624 insertions(+), 42 deletions(-) create mode 100644 docs/CNAME create mode 100644 docs/contributing.md create mode 100644 docs/images/logo-dark.svg create mode 100644 docs/images/logo-light.svg delete mode 100644 docs/index.html create mode 100644 docs/quick_start.md create mode 100644 internal/docs/quick_start.src.md create mode 100644 internal/readme/contributing.src.md create mode 100644 mkdocs.yaml diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index fa1eb97..088fceb 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -1,4 +1,4 @@ -name: Publish Docs to GitHub Pages +name: Publish Docs on: release: @@ -13,13 +13,20 @@ jobs: contents: write runs-on: ubuntu-latest steps: - - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Configure Git Credentials + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./docs - publish_branch: gh-pages - enable_jekyll: true - cname: go.sdk.modelcontextprotocol.io + python-version: 3.x + - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV + - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + key: mkdocs-material-${{ env.cache_id }} + path: .cache + restore-keys: | + mkdocs-material- + - run: pip install mkdocs-material + - run: mkdocs gh-deploy --force diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 303f6c2..b327087 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1,4 @@ + # Contributing to the Go MCP SDK Thank you for your interest in contributing! The Go SDK needs active @@ -6,11 +7,28 @@ new and emerging use-cases. We welcome all forms of contribution, from filing and reviewing issues, to contributing fixes, to proposing and implementing new features. -As described in the [design document](./design/design.md), it is important for +As described in the [design document](https://github.com/modelcontextprotocol/go-sdk/blob/main/design/design.md), it is important for the MCP SDK to remain idiomatic, future-proof, and extensible. The process described here is intended to ensure that the SDK evolves safely and transparently, while adhering to these goals. +1. [Development setup](#development-setup) + 1. [Conformance tests](#conformance-tests) +1. [Filing issues](#filing-issues) + 1. [Bugs](#bugs) + 1. [Proposals](#proposals) + 1. [Design discussion](#design-discussion) +1. [Contributing code](#contributing-code) + 1. [Adding and updating dependencies](#adding-and-updating-dependencies) + 1. [Updating the README](#updating-the-readme) +1. [Timeouts](#timeouts) +1. [Code of conduct](#code-of-conduct) +1. [Governance](#governance) + 1. [Working Group meetings](#working-group-meetings) + 1. [Discord](#discord) + 1. [Antitrust considerations](#antitrust-considerations) + + ## Development setup This module can be built and tested using the standard Go toolchain. Run `go diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 0000000..bd97e60 --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +go.sdk.modelcontextprotocol.io \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index e155e49..d07cfa0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,5 +1,22 @@ -# Features +# Overview + +The SDK consists of several importable packages: + +- The + [`github.com/modelcontextprotocol/go-sdk/mcp`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp) + package defines the primary APIs for constructing and using MCP clients and + servers. +- The + [`github.com/modelcontextprotocol/go-sdk/jsonrpc`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/jsonrpc) package is for users implementing + their own transports. +- The + [`github.com/modelcontextprotocol/go-sdk/auth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth) + package provides some primitives for supporting OAuth. +- The + [`github.com/modelcontextprotocol/go-sdk/oauthex`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/oauthex) + package provides extensions to the OAuth protocol, such as ProtectedResourceMetadata. + These docs mirror the [official MCP spec](https://modelcontextprotocol.io/specification/2025-06-18). Use the index below to learn how the SDK implements a particular aspect of the diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..ea38c9b --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1 @@ +--8<-- "CONTRIBUTING.md" diff --git a/docs/images/logo-dark.svg b/docs/images/logo-dark.svg new file mode 100644 index 0000000..03d9f85 --- /dev/null +++ b/docs/images/logo-dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/docs/images/logo-light.svg b/docs/images/logo-light.svg new file mode 100644 index 0000000..fe5edb7 --- /dev/null +++ b/docs/images/logo-light.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index 8ec5ea6..0000000 --- a/docs/index.html +++ /dev/null @@ -1,15 +0,0 @@ ---- ---- - - - - - MCP Go SDK Documentation - - - - -

Redirecting to MCP Go SDK documentation ({{ site.github.latest_release.tag_name }})...

-

For API reference, see pkg.go.dev.

- - diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index f53ef6d..714abfe 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -1,5 +1,5 @@ - # Backwards compatibility and MCPGODEBUG +# Backwards compatibility and MCPGODEBUG According to our compatibility promise, we can't break backward compatibility of the SDK API. However, sometimes we need to change the behavior of the SDK diff --git a/docs/protocol.md b/docs/protocol.md index f50bbfb..cfcdb85 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -13,7 +13,7 @@ 1. [Security](#security) 1. [Confused Deputy](#confused-deputy) 1. [Token Passthrough](#token-passthrough) - 1. [Server-Side Request Forgery (SSRF)](#server-side-request-forgery-(ssrf)) + 1. [Server-Side Request Forgery](#server-side-request-forgery) 1. [Session Hijacking](#session-hijacking) 1. [Utilities](#utilities) 1. [Cancellation](#cancellation) @@ -218,7 +218,7 @@ to see the logical session > modelcontextprotocol/modelcontextprotocol#1372, or > modelcontextprotocol/modelcontextprotocol#1442 for potential refinements. -_See [examples/server/distributed](../examples/server/distributed/main.go) for +_See [examples/server/distributed](https://github.com/modelcontextprotocol/go-sdk/blob/main/examples/server/distributed/main.go) for an example using stateless mode to implement a server distributed across multiple processes._ @@ -230,7 +230,7 @@ by implementing the [`Transport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Transport) interface: a logical bidirectional stream of JSON-RPC messages. -_Full example: [examples/server/custom-transport](../examples/server/custom-transport/main.go)._ +_Full example: [examples/server/custom-transport](https://github.com/modelcontextprotocol/go-sdk/blob/main//examples/server/custom-transport/main.go)._ ### Concurrency @@ -443,7 +443,7 @@ of tokens and is the responsibility of the provided to [`RequireBearerToken`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#RequireBearerToken). -### Server-Side Request Forgery (SSRF) +### Server-Side Request Forgery The [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) are as follows: diff --git a/docs/quick_start.md b/docs/quick_start.md new file mode 100644 index 0000000..c76c58c --- /dev/null +++ b/docs/quick_start.md @@ -0,0 +1,105 @@ + +# Quick Start + +1. [Installation](#installation) +1. [Getting started](#getting-started) + +## Installation + +``` +go get github.com/modelcontextprotocol/go-sdk +``` + +## Getting started + +To get started creating an MCP server, create an `mcp.Server` instance, add +features to it, and then run it over an `mcp.Transport`. For example, this +server adds a single simple tool, and then connects it to clients over +stdin/stdout: + +```go +package main + +import ( + "context" + "log" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type Input struct { + Name string `json:"name" jsonschema:"the name of the person to greet"` +} + +type Output struct { + Greeting string `json:"greeting" jsonschema:"the greeting to tell to the user"` +} + +func SayHi(ctx context.Context, req *mcp.CallToolRequest, input Input) ( + *mcp.CallToolResult, + Output, + error, +) { + return nil, Output{Greeting: "Hi " + input.Name}, nil +} + +func main() { + // Create a server with a single tool. + server := mcp.NewServer(&mcp.Implementation{Name: "greeter", Version: "v1.0.0"}, nil) + mcp.AddTool(server, &mcp.Tool{Name: "greet", Description: "say hi"}, SayHi) + // Run the server over stdin/stdout, until the client disconnects. + if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil { + log.Fatal(err) + } +} +``` + +To communicate with that server, create an `mcp.Client` and connect it to the +corresponding server, by running the server command and communicating over its +stdin/stdout: + +```go +package main + +import ( + "context" + "log" + "os/exec" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func main() { + ctx := context.Background() + + // Create a new client, with no features. + client := mcp.NewClient(&mcp.Implementation{Name: "mcp-client", Version: "v1.0.0"}, nil) + + // Connect to a server over stdin/stdout. + transport := &mcp.CommandTransport{Command: exec.Command("myserver")} + session, err := client.Connect(ctx, transport, nil) + if err != nil { + log.Fatal(err) + } + defer session.Close() + + // Call a tool on the server. + params := &mcp.CallToolParams{ + Name: "greet", + Arguments: map[string]any{"name": "you"}, + } + res, err := session.CallTool(ctx, params) + if err != nil { + log.Fatalf("CallTool failed: %v", err) + } + if res.IsError { + log.Fatal("tool failed") + } + for _, c := range res.Content { + log.Print(c.(*mcp.TextContent).Text) + } +} +``` + +The [`examples/`](https://github.com/modelcontextprotocol/go-sdk/tree/main/examples) directory contains more example clients and +servers. diff --git a/docs/server.md b/docs/server.md index e8c19f5..c5a7bf9 100644 --- a/docs/server.md +++ b/docs/server.md @@ -399,8 +399,8 @@ mcp.AddTool(server, &mcp.Tool{ }, WeatherTool) ``` -_See [mcp/tool_example_test.go](../mcp/tool_example_test.go) for the full -example, or [examples/server/toolschemas](examples/server/toolschemas/main.go) +_See [mcp/tool_example_test.go](https://github.com/modelcontextprotocol/go-sdk/blob/main/mcp/tool_example_test.go) for the full +example, or [examples/server/toolschemas](https://github.com/modelcontextprotocol/go-sdk/blob/main/examples/server/toolschemas/main.go) for more examples of customizing tool schemas._ **Stateless server deployments:** Some deployments create a new diff --git a/internal/docs/README.src.md b/internal/docs/README.src.md index 16d93db..3283efa 100644 --- a/internal/docs/README.src.md +++ b/internal/docs/README.src.md @@ -1,4 +1,21 @@ -# Features +# Overview + +The SDK consists of several importable packages: + +- The + [`github.com/modelcontextprotocol/go-sdk/mcp`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp) + package defines the primary APIs for constructing and using MCP clients and + servers. +- The + [`github.com/modelcontextprotocol/go-sdk/jsonrpc`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/jsonrpc) package is for users implementing + their own transports. +- The + [`github.com/modelcontextprotocol/go-sdk/auth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth) + package provides some primitives for supporting OAuth. +- The + [`github.com/modelcontextprotocol/go-sdk/oauthex`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/oauthex) + package provides extensions to the OAuth protocol, such as ProtectedResourceMetadata. + These docs mirror the [official MCP spec](https://modelcontextprotocol.io/specification/2025-06-18). Use the index below to learn how the SDK implements a particular aspect of the diff --git a/internal/docs/doc.go b/internal/docs/doc.go index ede0d42..4164060 100644 --- a/internal/docs/doc.go +++ b/internal/docs/doc.go @@ -10,6 +10,7 @@ //go:generate weave -o ../../docs/troubleshooting.md ./troubleshooting.src.md //go:generate weave -o ../../docs/rough_edges.md ./rough_edges.src.md //go:generate weave -o ../../docs/mcpgodebug.md ./mcpgodebug.src.md +//go:generate weave -o ../../docs/quick_start.md ./quick_start.src.md // The doc package generates the documentation at /doc, via go:generate. // diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index 60b321b..e13500b 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -1,4 +1,4 @@ - # Backwards compatibility and MCPGODEBUG +# Backwards compatibility and MCPGODEBUG According to our compatibility promise, we can't break backward compatibility of the SDK API. However, sometimes we need to change the behavior of the SDK diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index bfaf654..593742c 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -143,7 +143,7 @@ to see the logical session > modelcontextprotocol/modelcontextprotocol#1372, or > modelcontextprotocol/modelcontextprotocol#1442 for potential refinements. -_See [examples/server/distributed](../examples/server/distributed/main.go) for +_See [examples/server/distributed](https://github.com/modelcontextprotocol/go-sdk/blob/main/examples/server/distributed/main.go) for an example using stateless mode to implement a server distributed across multiple processes._ @@ -155,7 +155,7 @@ by implementing the [`Transport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Transport) interface: a logical bidirectional stream of JSON-RPC messages. -_Full example: [examples/server/custom-transport](../examples/server/custom-transport/main.go)._ +_Full example: [examples/server/custom-transport](https://github.com/modelcontextprotocol/go-sdk/blob/main//examples/server/custom-transport/main.go)._ ### Concurrency @@ -368,7 +368,7 @@ of tokens and is the responsibility of the provided to [`RequireBearerToken`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#RequireBearerToken). -### Server-Side Request Forgery (SSRF) +### Server-Side Request Forgery The [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) are as follows: diff --git a/internal/docs/quick_start.src.md b/internal/docs/quick_start.src.md new file mode 100644 index 0000000..7b1849f --- /dev/null +++ b/internal/docs/quick_start.src.md @@ -0,0 +1,27 @@ +# Quick Start + +%toc + +## Installation + +``` +go get github.com/modelcontextprotocol/go-sdk +``` + +## Getting started + +To get started creating an MCP server, create an `mcp.Server` instance, add +features to it, and then run it over an `mcp.Transport`. For example, this +server adds a single simple tool, and then connects it to clients over +stdin/stdout: + +%include ../readme/server/server.go - + +To communicate with that server, create an `mcp.Client` and connect it to the +corresponding server, by running the server command and communicating over its +stdin/stdout: + +%include ../readme/client/client.go - + +The [`examples/`](https://github.com/modelcontextprotocol/go-sdk/tree/main/examples) directory contains more example clients and +servers. diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index 694fc0f..2d822c7 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -189,8 +189,8 @@ the `Tool.InputSchema` explicitly: %include ../../mcp/tool_example_test.go customschemas - -_See [mcp/tool_example_test.go](../mcp/tool_example_test.go) for the full -example, or [examples/server/toolschemas](examples/server/toolschemas/main.go) +_See [mcp/tool_example_test.go](https://github.com/modelcontextprotocol/go-sdk/blob/main/mcp/tool_example_test.go) for the full +example, or [examples/server/toolschemas](https://github.com/modelcontextprotocol/go-sdk/blob/main/examples/server/toolschemas/main.go) for more examples of customizing tool schemas._ **Stateless server deployments:** Some deployments create a new diff --git a/internal/readme/contributing.src.md b/internal/readme/contributing.src.md new file mode 100644 index 0000000..08fc9b5 --- /dev/null +++ b/internal/readme/contributing.src.md @@ -0,0 +1,222 @@ +# Contributing to the Go MCP SDK + +Thank you for your interest in contributing! The Go SDK needs active +contributions to keep up with changes in the MCP spec, fix bugs, and accommodate +new and emerging use-cases. We welcome all forms of contribution, from filing +and reviewing issues, to contributing fixes, to proposing and implementing new +features. + +As described in the [design document](https://github.com/modelcontextprotocol/go-sdk/blob/main/design/design.md), it is important for +the MCP SDK to remain idiomatic, future-proof, and extensible. The process +described here is intended to ensure that the SDK evolves safely and +transparently, while adhering to these goals. + +%toc + + +## Development setup + +This module can be built and tested using the standard Go toolchain. Run `go +test ./...` to run all its tests. + +To test changes to this module against another module that uses the SDK, we +recommend using a [`go.work` file](https://go.dev/doc/tutorial/workspaces) to +define a multi-module workspace. For example, if your directory contains a +`project` directory containing your project, and a `go-sdk` directory +containing the SDK, run: + +```sh +go work init ./project ./go-sdk +``` + +### Conformance tests + +The SDK includes a script to run the official MCP conformance tests against the +SDK's conformance server: + +```sh +./scripts/conformance.sh +``` + +By default, results are cleaned up after the script runs. To save results to a +specific directory: + +```sh +./scripts/conformance.sh --result_dir ./conformance-results +``` + +To run against a local checkout of the +[conformance repo](https://github.com/modelcontextprotocol/conformance) instead +of the latest npm release: + +```sh +./scripts/conformance.sh --conformance_repo ~/src/conformance +``` + +Note: you must run `npm install` in the conformance repo first. + +Run `./scripts/conformance.sh --help` for more options. + +## Filing issues + +This project uses the [GitHub issue +tracker](https://github.com/modelcontextprotocol/go-sdk/issues) for issues. The +process for filing bugs and proposals is described below. + +TODO(rfindley): describe a process for asking general questions in the public +MCP discord server. + +### Bugs + +Please [report +bugs](https://github.com/modelcontextprotocol/go-sdk/issues/new). If the SDK is +not working as you expected, it is likely due to a bug or inadequate +documentation, and reporting an issue will help us address this shortcoming. + +When reporting a bug, make sure to answer these five questions: + +1. What did you do? +2. What did you see? +3. What did you expect to see? +4. What version of the Go MCP SDK are you using? +5. What version of Go are you using (`go version`)? + +### Proposals + +A proposal is an issue that proposes a new API for the SDK, or a change to the +signature or behavior of an existing API. Proposals should be labeled with the +'proposal' label, and require an explicit approval from a maintainer before +being accepted (indicated by the 'proposal-accepted' label). Proposals must +remain open for at least a week to allow discussion before being accepted or +declined by a maintainer. + +Proposals that are straightforward and uncontroversial may be approved based on +discussion on the issue tracker or in a GitHub Discussion. However, proposals +that are deemed to be sufficiently unclear or complicated may be deferred to a +regular Working Group meeting (see 'Governance' below). + +This process is similar to the [Go proposal +process](https://github.com/golang/proposal), but is necessarily lighter weight +to accommodate the greater rate of change expected for the SDK. + +### Design discussion + +For open ended design discussion (anything that doesn't fall into the issue +categories above), use [GitHub +Discussions](https://github.com/modelcontextprotocol/go-sdk/discussions). +Ideally, each discussion should be focused on one aspect of the design. For +example: Tool Binding and Session APIs would be two separate discussions. +When discussions reach a consensus, they should be promoted into proposals. + +## Contributing code + +The project uses GitHub pull requests (PRs) to review changes. + +Any significant change should be associated with a GitHub issue. Issues that +are deemed to be good opportunities for contribution are be labeled ['Help +Wanted'](https://github.com/modelcontextprotocol/go-sdk/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22help%20wanted%22). +If you want to work on such an issue, please first comment on the issue to say +that you're interested in contributing. For issues _not_ labeled 'Help Wanted', +it is recommended that you ask (and wait for confirmation) on the issue before +contributing, to avoid duplication of effort or wasted work. For nontrivial +changes that _don't_ relate to an existing issue, please file an issue first. + +Changes should be high quality and well tested, and should generally follow the +[Google Go style guide](https://google.github.io/styleguide/go/). Commit +messages should follow the [format used by the Go +project](https://go.dev/wiki/CommitMessage). + +Unless otherwise noted, the Go source files are distributed under the license +found in the LICENSE file. New contributions are licensed under Apache 2.0. All +Go files in the SDK should have a copyright header following the format below: + +```go +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. +``` + +### Adding and updating dependencies + +In general, the SDK tries to use as few dependencies as possible. Each new +dependency is a potential source for bugs, churn, and conflicts for our users. +Therefore, we require a [proposal](#proposals) for any new module dependency, +including upgrading an existing module to a new major version. New dependencies +should be evaluated for their stability and security, and should be +well-established in the Go ecosystem. + +In general, dependencies should be for internal use by the SDK implementation, +or for testing. Do not include types from dependencies in the SDK API. + +On the other hand, updating existing dependencies can be done at any time +without a proposal, as long as their major version does not change. Prefer to +update dependencies immediately following a release of the SDK, to allow as +much time as possible to find issues with the new version. + +After any change to dependencies, run govulncheck to check them for +vulnerabilities. + +``` +go run golang.org/x/vuln/cmd/govulncheck@latest +``` + +### Updating the README + +The top-level `README.md` file is generated from `internal/readme/README.src.md` +and should not be edited directly. To update the README: + +1. Make your changes to `internal/readme/README.src.md` +2. Run `go generate ./internal/readme` from the repository root to regenerate `README.md` +3. Commit both files together + +The CI system will automatically check that the README is up-to-date by running +`go generate ./internal/readme` and verifying no changes result. If you see a CI failure about the +README being out of sync, follow the steps above to regenerate it. + +## Timeouts + +If a contributor hasn't responded to issue questions or PR comments in two weeks, +the issue or PR may be closed. It can be reopened when the contributor can resume +work. + +## Code of conduct + +This project follows the [Go Community Code of Conduct](https://go.dev/conduct). +If you encounter a conduct-related issue, please mail conduct@golang.org. + +## Governance + +Initially, the Go SDK repository will be administered by the Go team and +Anthropic, and they will be the approvers (the set of people able to merge PRs +to the SDK), also referred to as the 'Working Group'. The policies here are +also intended to satisfy necessary constraints of the Go team's participation +in the project. This may change in the future: see 'Ongoing Evaluation' below. + +### Working Group meetings + +On a regular basis, the Working Group will host a virtual meeting to discuss +outstanding proposals and other changes to the SDK. These meetings and their +agendas will be announced in advance, and open to all. The meetings will be +recorded, and recordings and meeting notes will be made available afterward. +(TODO: decide on a mechanism for tracking these meetings--likely a GitHub +issue.) + +This process is similar to the [Go Tools +call](https://go.dev/wiki/golang-tools), though it is expected that meetings +will at least initially occur on a more frequent basis. + +### Discord + +Discord (either the public or private Anthropic discord servers) should only be +used for logistical coordination or answering questions. For transparency and +durability, design discussion and decisions should occur in GitHub issues, +GitHub discussions, or public steering meetings. + +### Antitrust considerations + +The goal of this repository is to provide a robust and complete Go +implementation of the model context protocol, in an open and transparent +manner, without bias toward specific integration paths or providers. To that +end, the model context protocol organization's [antitrust +policy](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/ANTITRUST.md) +applies to all participation in this project. diff --git a/internal/readme/doc.go b/internal/readme/doc.go index 34bc60c..4461b94 100644 --- a/internal/readme/doc.go +++ b/internal/readme/doc.go @@ -2,7 +2,9 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. -//go:generate go run golang.org/x/example/internal/cmd/weave@latest -o ../../README.md ./README.src.md +//go:generate -command weave go run golang.org/x/example/internal/cmd/weave@latest +//go:generate weave -o ../../README.md ./README.src.md +//go:generate weave -o ../../CONTRIBUTING.md ./contributing.src.md // The readme package is used to generate README.md at the top-level of this // repo. Regenerate the README with go generate. diff --git a/mkdocs.yaml b/mkdocs.yaml new file mode 100644 index 0000000..e978c85 --- /dev/null +++ b/mkdocs.yaml @@ -0,0 +1,98 @@ +site_name: MCP Go SDK +site_url: https://go.sdk.modelcontextprotocol.io/ +site_description: Golang SDK for the Model Context Protocol - standardized integration between AI models and tools + +repo_url: https://github.com/modelcontextprotocol/go-sdk +repo_name: modelcontextprotocol/go-sdk +edit_uri: edit/main/docs/ + +theme: + name: material + favicon: images/logo-dark.svg + logo: images/logo-light.svg + palette: + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: cyan + accent: teal + toggle: + icon: material/lightbulb + name: "Switch to light mode" + - media: "(prefers-color-scheme: light)" + scheme: default + primary: cyan + accent: teal + toggle: + icon: material/lightbulb-outline + name: "Switch to dark mode" + + features: + - navigation.instant + - navigation.instant.progress + - navigation.tabs + - navigation.tabs.sticky + - navigation.sections + - navigation.top + - navigation.path + - navigation.indexes + - toc.follow + - search.suggest + - search.highlight + - content.code.copy + - content.code.annotate + + +nav: + - Documentation: + - Overview: README.md + - Quick Start: quick_start.md + - LifeCycle: protocol.md + - MCP Components: + - MCP Client: client.md + - MCP Server: server.md + - Troubleshooting: troubleshooting.md + - Backwards Compatibility: mcpgodebug.md + - Rough Edges: rough_edges.md + - Contributing: + - Contributing Guide: contributing.md + - API Reference: https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk + + +markdown_extensions: + - admonition + - pymdownx.details + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.tabbed: + alternate_style: true + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + - pymdownx.inlinehilite + - pymdownx.snippets: + base_path: . + - pymdownx.mark + - pymdownx.critic + - pymdownx.caret + - pymdownx.keys + - pymdownx.tilde + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - attr_list + - md_in_html + - tables + - toc: + permalink: true + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/modelcontextprotocol/go-sdk + generator: false + +plugins: + - search \ No newline at end of file From 15e93a231c8693d582ea28f3717bca307a0cf318 Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Tue, 14 Apr 2026 11:36:20 +0200 Subject: [PATCH 252/418] mcp: add DNS rebinding and cross origin protections to SSE transport (#891) Similar protections were already introduced before to the Streamable transport. --- docs/mcpgodebug.md | 7 +- internal/docs/mcpgodebug.src.md | 7 +- mcp/sse.go | 57 ++++++++++- mcp/sse_test.go | 173 ++++++++++++++++++++++++++++++++ mcp/streamable.go | 6 +- 5 files changed, 236 insertions(+), 14 deletions(-) diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index 714abfe..7c6773d 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -24,7 +24,7 @@ Options listed below will be removed in the 1.6.0 version of the SDK. - `disablecrossoriginprotection` added. If set to `1`, newly added cross-origin protection will be disabled. The default behavior was changed to enable - cross-origin protection. + cross-origin protection. **Removal of this option was postponed until 1.7.0.** ### 1.4.0 @@ -37,5 +37,6 @@ Options listed below will be removed in the 1.6.0 version of the SDK. - `disablelocalhostprotection` added. If set to `1`, newly added DNS rebinding protection will be disabled. The default behavior was changed to enable DNS rebinding protection. The protection can also be disabled by setting the - `DisableLocalhostProtection` field in the `StreamableHTTPOptions` struct to - `true`, which is the recommended way to disable the protection long term. + `DisableLocalhostProtection` field in the `StreamableHTTPOptions` or + `SSEOptions` struct to `true`, which is the recommended way to disable + the protection long term. **Removal of this option was postponed until 1.7.0.** diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index e13500b..592e923 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -23,7 +23,7 @@ Options listed below will be removed in the 1.6.0 version of the SDK. - `disablecrossoriginprotection` added. If set to `1`, newly added cross-origin protection will be disabled. The default behavior was changed to enable - cross-origin protection. + cross-origin protection. **Removal of this option was postponed until 1.7.0.** ### 1.4.0 @@ -36,5 +36,6 @@ Options listed below will be removed in the 1.6.0 version of the SDK. - `disablelocalhostprotection` added. If set to `1`, newly added DNS rebinding protection will be disabled. The default behavior was changed to enable DNS rebinding protection. The protection can also be disabled by setting the - `DisableLocalhostProtection` field in the `StreamableHTTPOptions` struct to - `true`, which is the recommended way to disable the protection long term. \ No newline at end of file + `DisableLocalhostProtection` field in the `StreamableHTTPOptions` or + `SSEOptions` struct to `true`, which is the recommended way to disable + the protection long term. **Removal of this option was postponed until 1.7.0.** diff --git a/mcp/sse.go b/mcp/sse.go index e57dad1..f8f156d 100644 --- a/mcp/sse.go +++ b/mcp/sse.go @@ -10,11 +10,13 @@ import ( "crypto/rand" "fmt" "io" + "net" "net/http" "net/url" "sync" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + "github.com/modelcontextprotocol/go-sdk/internal/util" "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) @@ -52,9 +54,25 @@ type SSEHandler struct { } // SSEOptions specifies options for an [SSEHandler]. -// for now, it is empty, but may be extended in future. -// https://github.com/modelcontextprotocol/go-sdk/issues/507 -type SSEOptions struct{} +type SSEOptions struct { + // DisableLocalhostProtection disables automatic DNS rebinding protection. + // By default, requests arriving via a localhost address (127.0.0.1, [::1]) + // that have a non-localhost Host header are rejected with 403 Forbidden. + // This protects against DNS rebinding attacks regardless of whether the + // server is listening on localhost specifically or on 0.0.0.0. + // + // Only disable this if you understand the security implications. + // See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise + DisableLocalhostProtection bool + + // CrossOriginProtection allows to customize cross-origin protection. + // The deny handler set in the CrossOriginProtection through SetDenyHandler + // is ignored. + // If nil, default (zero-value) cross-origin protection will be used. + // Use `disablecrossoriginprotection` MCPGODEBUG compatibility parameter + // to disable the default protection until v1.7.0. + CrossOriginProtection *http.CrossOriginProtection +} // NewSSEHandler returns a new [SSEHandler] that creates and manages MCP // sessions created via incoming HTTP requests. @@ -79,6 +97,10 @@ func NewSSEHandler(getServer func(request *http.Request) *Server, opts *SSEOptio s.opts = *opts } + if s.opts.CrossOriginProtection == nil { + s.opts.CrossOriginProtection = &http.CrossOriginProtection{} + } + return s } @@ -179,9 +201,34 @@ func (t *SSEServerTransport) Connect(context.Context) (Connection, error) { } func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { - sessionID := req.URL.Query().Get("sessionid") + // DNS rebinding protection: auto-enabled for localhost servers. + // See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise + if !h.opts.DisableLocalhostProtection && disablelocalhostprotection != "1" { + if localAddr, ok := req.Context().Value(http.LocalAddrContextKey).(net.Addr); ok && localAddr != nil { + if util.IsLoopback(localAddr.String()) && !util.IsLoopback(req.Host) { + http.Error(w, fmt.Sprintf("Forbidden: invalid Host header %q", req.Host), http.StatusForbidden) + return + } + } + } - // TODO: consider checking Content-Type here. For now, we are lax. + if disablecrossoriginprotection != "1" { + // Verify the 'Origin' header to protect against CSRF attacks. + if err := h.opts.CrossOriginProtection.Check(req); err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + // Validate 'Content-Type' header. + if req.Method == http.MethodPost { + contentType := req.Header.Get("Content-Type") + if contentType != "application/json" { + http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) + return + } + } + } + + sessionID := req.URL.Query().Get("sessionid") // For POST requests, the message body is a message to send to a session. if req.Method == http.MethodPost { diff --git a/mcp/sse_test.go b/mcp/sse_test.go index 8746cc8..fe230a5 100644 --- a/mcp/sse_test.go +++ b/mcp/sse_test.go @@ -9,8 +9,10 @@ import ( "context" "fmt" "io" + "net" "net/http" "net/http/httptest" + "strings" "sync/atomic" "testing" @@ -221,3 +223,174 @@ func TestSSE405AllowHeader(t *testing.T) { }) } } + +// TestSSELocalhostProtection verifies that DNS rebinding protection +// is automatically enabled for localhost servers. +func TestSSELocalhostProtection(t *testing.T) { + server := NewServer(testImpl, nil) + + tests := []struct { + name string + listenAddr string + hostHeader string + disableProtection bool + wantStatus int + }{ + { + name: "127.0.0.1 accepts 127.0.0.1", + listenAddr: "127.0.0.1:0", + hostHeader: "127.0.0.1:1234", + wantStatus: http.StatusOK, + }, + { + name: "127.0.0.1 accepts localhost", + listenAddr: "127.0.0.1:0", + hostHeader: "localhost:1234", + wantStatus: http.StatusOK, + }, + { + name: "127.0.0.1 rejects evil.com", + listenAddr: "127.0.0.1:0", + hostHeader: "evil.com", + wantStatus: http.StatusForbidden, + }, + { + name: "127.0.0.1 rejects evil.com:80", + listenAddr: "127.0.0.1:0", + hostHeader: "evil.com:80", + wantStatus: http.StatusForbidden, + }, + { + name: "127.0.0.1 rejects localhost.evil.com", + listenAddr: "127.0.0.1:0", + hostHeader: "localhost.evil.com", + wantStatus: http.StatusForbidden, + }, + { + name: "0.0.0.0 via localhost rejects evil.com", + listenAddr: "0.0.0.0:0", + hostHeader: "evil.com", + wantStatus: http.StatusForbidden, + }, + { + name: "disabled accepts evil.com", + listenAddr: "127.0.0.1:0", + hostHeader: "evil.com", + disableProtection: true, + wantStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &SSEOptions{ + DisableLocalhostProtection: tt.disableProtection, + } + handler := NewSSEHandler(func(req *http.Request) *Server { return server }, opts) + + listener, err := net.Listen("tcp", tt.listenAddr) + if err != nil { + t.Fatalf("Failed to listen on %s: %v", tt.listenAddr, err) + } + defer listener.Close() + + srv := &http.Server{Handler: handler} + go srv.Serve(listener) + defer srv.Close() + + // Use a GET request since it's the entry point for SSE sessions. + // For accepted requests, the response will be a hanging SSE stream, + // but we only need to check the initial status code. + req, err := http.NewRequest("GET", fmt.Sprintf("http://%s", listener.Addr().String()), nil) + if err != nil { + t.Fatal(err) + } + req.Host = tt.hostHeader + req.Header.Set("Accept", "text/event-stream") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if got := resp.StatusCode; got != tt.wantStatus { + t.Errorf("Status code: got %d, want %d", got, tt.wantStatus) + } + }) + } +} + +func TestSSEOriginProtection(t *testing.T) { + server := NewServer(testImpl, nil) + + tests := []struct { + name string + protection *http.CrossOriginProtection + requestOrigin string + wantStatusCode int + }{ + { + name: "default protection with Origin header", + protection: nil, + requestOrigin: "https://example.com", + wantStatusCode: http.StatusForbidden, + }, + { + name: "custom protection with trusted origin and same Origin", + protection: func() *http.CrossOriginProtection { + p := http.NewCrossOriginProtection() + if err := p.AddTrustedOrigin("https://example.com"); err != nil { + t.Fatal(err) + } + return p + }(), + requestOrigin: "https://example.com", + wantStatusCode: http.StatusNotFound, // origin accepted; session not found + }, + { + name: "custom protection with trusted origin and different Origin", + protection: func() *http.CrossOriginProtection { + p := http.NewCrossOriginProtection() + if err := p.AddTrustedOrigin("https://example.com"); err != nil { + t.Fatal(err) + } + return p + }(), + requestOrigin: "https://malicious.com", + wantStatusCode: http.StatusForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &SSEOptions{ + CrossOriginProtection: tt.protection, + } + handler := NewSSEHandler(func(req *http.Request) *Server { return server }, opts) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + // Use POST with a valid session-like URL to test origin protection + // without creating a hanging GET connection. + reqReader := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"ping"}`) + req, err := http.NewRequest(http.MethodPost, httpServer.URL+"?sessionid=nonexistent", reqReader) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", tt.requestOrigin) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if got := resp.StatusCode; got != tt.wantStatusCode { + body, _ := io.ReadAll(resp.Body) + t.Errorf("Status code: got %d, want %d (body: %s)", got, tt.wantStatusCode, body) + } + }) + } +} diff --git a/mcp/streamable.go b/mcp/streamable.go index 76f2b0e..b4cd693 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -182,7 +182,7 @@ type StreamableHTTPOptions struct { // is ignored. // If nil, default (zero-value) cross-origin protection will be used. // Use `disablecrossoriginprotection` MCPGODEBUG compatibility parameter - // to disable the default protection until v1.6.0. + // to disable the default protection until v1.7.0. CrossOriginProtection *http.CrossOriginProtection } @@ -235,14 +235,14 @@ func (h *StreamableHTTPHandler) closeAll() { // disablelocalhostprotection is a compatibility parameter that allows to disable // DNS rebinding protection, which was added in the 1.4.0 version of the SDK. // See the documentation for the mcpgodebug package for instructions how to enable it. -// The option will be removed in the 1.6.0 version of the SDK. +// The option will be removed in the 1.7.0 version of the SDK. var disablelocalhostprotection = mcpgodebug.Value("disablelocalhostprotection") // disablecrossoriginprotection is a compatibility parameter that allows to disable // the verification of the 'Origin' and 'Content-Type' headers, which was added in // the 1.4.1 version of the SDK. See the documentation for the mcpgodebug package // for instructions how to enable it. -// The option will be removed in the 1.6.0 version of the SDK. +// The option will be removed in the 1.7.0 version of the SDK. var disablecrossoriginprotection = mcpgodebug.Value("disablecrossoriginprotection") func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { From 4c00594be629ca87637f4316a232377f9b0979cd Mon Sep 17 00:00:00 2001 From: Ravish Gupta Date: Tue, 14 Apr 2026 07:53:11 -0700 Subject: [PATCH 253/418] mcp: log out-of-band errors instead of dropping them (#887) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Two error sites in the MCP runtime were previously unobservable: - **`mcp/shared.go` `startKeepalive`** — when a keepalive ping failed, the session was closed silently with no indication of why. - **`mcp/transport.go` `connect`** — `jsonrpc2.OnInternalError` printed via the package-level `log.Printf`, bypassing the `*slog.Logger` configured on `ServerOptions`/`ClientOptions`. Both sites now report through the existing `*slog.Logger` that `Server` and `Client` already guarantee non-nil via `ensureLogger`. The logger is threaded into the unexported `startKeepalive` and `connect` helpers as a parameter — **no new public API surface**. ## Context Per @jba's [suggestion on #865](https://github.com/modelcontextprotocol/go-sdk/pull/865#issuecomment-4156166474): > Maybe we can just use the logger in all the places we currently drop the error. Then it's still reported, but we don't have to add any API. This PR is that smaller alternative. It addresses the concrete observability gap in #218 without expanding `ServerOptions`. PR #865 (the `ErrorHandler` callback proposal) remains open pending community input on whether a structured callback API is also wanted on top of logging. `notifySessions` already logs at `Warn` level on delivery errors and is intentionally left untouched. ## Test plan - [x] New regression test `TestKeepAliveFailure_Logged` asserts the keepalive failure produces a log line on the configured logger. - [x] Verified the test fails when the new `logger.Error(...)` call is removed (sanity check that it pins the behavior). - [x] `go test ./mcp/... -count=1` passes. - [x] `go vet ./...` clean. - [x] `go build ./...` clean. Refs #218 --------- Co-authored-by: Maciej Kisiel --- mcp/client.go | 4 ++-- mcp/mcp_test.go | 46 ++++++++++++++++++++++++++++++++++++++++++++++ mcp/server.go | 4 ++-- mcp/shared.go | 9 +++++++-- mcp/transport.go | 10 +++++++--- 5 files changed, 64 insertions(+), 9 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index 4860b62..c82e189 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -253,7 +253,7 @@ func (c *Client) capabilities(protocolVersion string) *ClientCapabilities { // server, calls or notifications will return an error wrapping // [ErrConnectionClosed]. func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOptions) (cs *ClientSession, err error) { - cs, err = connect(ctx, t, c, (*clientSessionState)(nil), nil) + cs, err = connect(ctx, t, c, (*clientSessionState)(nil), nil, c.opts.Logger) if err != nil { return nil, err } @@ -405,7 +405,7 @@ func (cs *ClientSession) registerElicitationWaiter(elicitationID string) (await // startKeepalive starts the keepalive mechanism for this client session. func (cs *ClientSession) startKeepalive(interval time.Duration) { - startKeepalive(cs, interval, &cs.keepaliveCancel) + startKeepalive(cs, interval, &cs.keepaliveCancel, cs.client.opts.Logger) } // AddRoots adds the given roots to the client, diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 00c4dc9..5df8b38 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -1817,6 +1817,52 @@ func TestKeepAliveFailure(t *testing.T) { }) } +// TestKeepAliveFailure_Logged verifies that a keepalive ping failure is +// reported via the configured slog.Logger instead of being silently dropped. +// Regression test for #218. +func TestKeepAliveFailure_Logged(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := context.Background() + + ct, st := NewInMemoryTransports() + + // Server without keepalive. + s := NewServer(testImpl, nil) + AddTool(s, greetTool(), sayHi) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + + // Client with a short keepalive and a logger that writes to a + // buffer so we can assert on its output. + var buf bytes.Buffer + clientOpts := &ClientOptions{ + KeepAlive: 50 * time.Millisecond, + Logger: slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelError})), + } + c := NewClient(testImpl, clientOpts) + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + synctest.Wait() + + // Trigger a ping failure by closing the server side. + ss.Close() + + time.Sleep(100 * time.Millisecond) + synctest.Wait() + + got := buf.String() // slog serializes Write calls internally + if !strings.Contains(got, "keepalive ping failed") { + t.Errorf("expected keepalive failure to be logged, got log output:\n%s", got) + } + }) +} + func TestAddTool_DuplicateNoPanicAndNoDuplicate(t *testing.T) { // Adding the same tool pointer twice should not panic and should not // produce duplicates in the server's tool list. diff --git a/mcp/server.go b/mcp/server.go index 2357959..2850437 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1026,7 +1026,7 @@ func (s *Server) Connect(ctx context.Context, t Transport, opts *ServerSessionOp } s.opts.Logger.Info("server connecting") - ss, err := connect(ctx, t, s, state, onClose) + ss, err := connect(ctx, t, s, state, onClose, s.opts.Logger) if err != nil { s.opts.Logger.Error("server connect error", "error", err) return nil, err @@ -1531,7 +1531,7 @@ func (ss *ServerSession) Wait() error { // startKeepalive starts the keepalive mechanism for this server session. func (ss *ServerSession) startKeepalive(interval time.Duration) { - startKeepalive(ss, interval, &ss.keepaliveCancel) + startKeepalive(ss, interval, &ss.keepaliveCancel, ss.server.opts.Logger) } // pageToken is the internal structure for the opaque pagination cursor. diff --git a/mcp/shared.go b/mcp/shared.go index 5662b12..05397a1 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -582,7 +582,10 @@ type keepaliveSession interface { // startKeepalive starts the keepalive mechanism for a session. // It assigns the cancel function to the provided cancelPtr and starts a goroutine // that sends ping messages at the specified interval. -func startKeepalive(session keepaliveSession, interval time.Duration, cancelPtr *context.CancelFunc) { +// +// logger must be non-nil; ping failures (which terminate the keepalive loop and +// close the session) are reported via logger so they are not silently dropped. +func startKeepalive(session keepaliveSession, interval time.Duration, cancelPtr *context.CancelFunc, logger *slog.Logger) { ctx, cancel := context.WithCancel(context.Background()) // Assign cancel function before starting goroutine to avoid race condition. // We cannot return it because the caller may need to cancel during the @@ -602,7 +605,9 @@ func startKeepalive(session keepaliveSession, interval time.Duration, cancelPtr err := session.Ping(pingCtx, nil) pingCancel() if err != nil { - // Ping failed, close the session + // Ping failed; log it before closing the session so the + // failure is observable to operators. See #218. + logger.Error("keepalive ping failed; closing session", "error", err) _ = session.Close() return } diff --git a/mcp/transport.go b/mcp/transport.go index 23dccf8..9288b37 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -10,7 +10,7 @@ import ( "errors" "fmt" "io" - "log" + "log/slog" "net" "os" "sync" @@ -152,7 +152,9 @@ type handler interface { handle(ctx context.Context, req *jsonrpc.Request) (any, error) } -func connect[H handler, State any](ctx context.Context, t Transport, b binder[H, State], s State, onClose func()) (H, error) { +// connect wires a transport to a binder. logger must be non-nil; it receives +// jsonrpc2 internal errors that would otherwise be dropped (see #218). +func connect[H handler, State any](ctx context.Context, t Transport, b binder[H, State], s State, onClose func(), logger *slog.Logger) (H, error) { var zero H mcpConn, err := t.Connect(ctx) if err != nil { @@ -178,7 +180,9 @@ func connect[H handler, State any](ctx context.Context, t Transport, b binder[H, OnDone: func() { b.disconnect(h) }, - OnInternalError: func(err error) { log.Printf("jsonrpc2 error: %v", err) }, + OnInternalError: func(err error) { + logger.Error("jsonrpc2 internal error", "error", err) + }, }) assert(preempter.conn != nil, "unbound preempter") return h, nil From b37e497f0a19d4b03881650a985ab27ca741827d Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Wed, 22 Apr 2026 16:35:27 +0200 Subject: [PATCH 254/418] mcp: don't close session when keepalive ping returns method-not-found (#900) Since ping is optional per the MCP spec, a peer that doesn't implement it will respond with a -32601 (method not found) error. Previously, startKeepalive treated all ping errors identically, closing the session even when the peer was perfectly healthy. Now, a method-not-found response stops the keepalive goroutine without closing the session. Fixes #897 --- mcp/mcp_test.go | 56 +++++++++++++++++++++++++++++++++++++++++++++++++ mcp/shared.go | 5 +++++ 2 files changed, 61 insertions(+) diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 5df8b38..1417323 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -25,6 +25,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" ) @@ -938,6 +939,61 @@ func TestKeepAlive(t *testing.T) { }) } +func TestKeepAliveMethodNotFound(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := context.Background() + + ct, st := NewInMemoryTransports() + + // Server that rejects ping with method-not-found, simulating a + // server that does not implement the optional ping method. + s := NewServer(testImpl, nil) + AddTool(s, greetTool(), sayHi) + s.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == "ping" { + return nil, jsonrpc2.ErrMethodNotFound + } + return next(ctx, method, req) + } + }) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + clientOpts := &ClientOptions{ + KeepAlive: 50 * time.Millisecond, + } + c := NewClient(testImpl, clientOpts) + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + // Advance past several keepalive cycles. + time.Sleep(200 * time.Millisecond) + + // The session should still be alive despite the server not + // supporting ping. + result, err := cs.CallTool(ctx, &CallToolParams{ + Name: "greet", + Arguments: map[string]any{"Name": "user"}, + }) + if err != nil { + t.Fatalf("call failed after keepalive with method-not-found: %v", err) + } + if len(result.Content) == 0 { + t.Fatal("expected content in result") + } + if textContent, ok := result.Content[0].(*TextContent); !ok || textContent.Text != "hi user" { + t.Fatalf("unexpected result: %v", result.Content[0]) + } + }) +} + func TestElicitationUnsupportedMethod(t *testing.T) { ctx := context.Background() ct, st := NewInMemoryTransports() diff --git a/mcp/shared.go b/mcp/shared.go index 05397a1..cc28de8 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -14,6 +14,7 @@ package mcp import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "net/http" @@ -605,6 +606,10 @@ func startKeepalive(session keepaliveSession, interval time.Duration, cancelPtr err := session.Ping(pingCtx, nil) pingCancel() if err != nil { + if errors.Is(err, jsonrpc2.ErrMethodNotFound) { + // Peer doesn't support ping, stop the keepalive process. + return + } // Ping failed; log it before closing the session so the // failure is observable to operators. See #218. logger.Error("keepalive ping failed; closing session", "error", err) From 35f1b07e4889adb21c64799ea902ec2e38e1e3d8 Mon Sep 17 00:00:00 2001 From: Kalvin C Date: Thu, 23 Apr 2026 05:51:47 -0700 Subject: [PATCH 255/418] mcp: accept parameterized Content-Type types (#890) Use a shared helper for Content-Type parsing in streamable transport request validation and client response handling. Follow up to https://github.com/modelcontextprotocol/go-sdk/pull/853, where we did this for `Accept` headers. This allows the streamable mcp server to validate the following header `Content-Type: application/json;charset=utf-8` Co-authored-by: Maciej Kisiel --- mcp/streamable.go | 21 +++++++++++++-------- mcp/streamable_client_test.go | 3 ++- mcp/streamable_test.go | 28 +++++++++++++++++++++++++--- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index b4cd693..8deb6c9 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -264,12 +264,9 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque return } // Validate 'Content-Type' header. - if req.Method == http.MethodPost { - mediaType, _, err := mime.ParseMediaType(req.Header.Get("Content-Type")) - if err != nil || mediaType != "application/json" { - http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) - return - } + if req.Method == http.MethodPost && baseMediaType(req.Header.Get("Content-Type")) != "application/json" { + http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) + return } } @@ -560,6 +557,14 @@ func streamableAccepts(values []string) (jsonOK, streamOK bool) { return jsonOK, streamOK } +func baseMediaType(value string) string { + mediaType, _, err := mime.ParseMediaType(value) + if err != nil { + return "" + } + return mediaType +} + // A StreamableServerTransport implements the server side of the MCP streamable // transport. // @@ -1671,7 +1676,7 @@ func (c *streamableClientConn) connectStandaloneSSE() { resp.Body.Close() return } - if resp.Header.Get("Content-Type") != "text/event-stream" { + if baseMediaType(resp.Header.Get("Content-Type")) != "text/event-stream" { // modelcontextprotocol/go-sdk#736: some servers return 200 OK or redirect with // non-SSE content type instead of text/event-stream for the standalone // SSE stream. @@ -1855,7 +1860,7 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e return nil } - contentType := strings.TrimSpace(strings.SplitN(resp.Header.Get("Content-Type"), ";", 2)[0]) + contentType := baseMediaType(resp.Header.Get("Content-Type")) switch contentType { case "application/json": go c.handleJSON(requestSummary, resp) diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 43564fd..9da8aec 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -264,6 +264,7 @@ func TestStreamableClientGETHandling(t *testing.T) { contentType string }{ {http.StatusOK, "", "text/event-stream"}, + {http.StatusOK, "", "text/event-stream; charset=utf-8"}, {http.StatusMethodNotAllowed, "", "text/event-stream"}, //// The client error status code is not treated as an error in non-strict //// mode. @@ -274,7 +275,7 @@ func TestStreamableClientGETHandling(t *testing.T) { } for _, test := range tests { - t.Run(fmt.Sprintf("status=%d", test.status), func(t *testing.T) { + t.Run(fmt.Sprintf("status=%d content_type=%q", test.status, test.contentType), func(t *testing.T) { fake := &fakeStreamableServer{ t: t, responses: fakeResponses{ diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 3600277..592981f 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1506,9 +1506,9 @@ func (s streamableRequest) do(ctx context.Context, serverURL, sessionID string, newSessionID := resp.Header.Get(sessionIDHeader) - contentType := resp.Header.Get("Content-Type") + contentType := baseMediaType(resp.Header.Get("Content-Type")) var respBody []byte - if strings.HasPrefix(contentType, "text/event-stream") { + if contentType == "text/event-stream" { r := readerInto{resp.Body, new(bytes.Buffer)} for evt, err := range scanEvents(r) { if err != nil { @@ -1525,7 +1525,7 @@ func (s streamableRequest) do(ctx context.Context, serverURL, sessionID string, } } respBody = r.w.Bytes() - } else if strings.HasPrefix(contentType, "application/json") { + } else if contentType == "application/json" { data, err := io.ReadAll(resp.Body) if err != nil { return newSessionID, resp.StatusCode, nil, fmt.Errorf("reading json body: %w", err) @@ -2047,6 +2047,28 @@ func TestStreamableGETWithoutEventStreamAccept(t *testing.T) { } } +func TestBaseMediaType(t *testing.T) { + tests := []struct { + name string + value string + want string + }{ + {name: "empty", want: ""}, + {name: "json", value: "application/json", want: "application/json"}, + {name: "json with params", value: "Application/JSON; charset=utf-8", want: "application/json"}, + {name: "event stream with params", value: "Text/Event-Stream; charset=utf-8", want: "text/event-stream"}, + {name: "invalid", value: "application/json; charset", want: ""}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := baseMediaType(test.value); got != test.want { + t.Errorf("baseMediaType(%q) = %q, want %q", test.value, got, test.want) + } + }) + } +} + func TestStreamableClientContextPropagation(t *testing.T) { type contextKey string const testKey = contextKey("test-key") From 41e1f941bbaa1f9de78ac94f89af7b676a440dc8 Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Fri, 24 Apr 2026 11:35:22 +0200 Subject: [PATCH 256/418] mcp: remove default cross origin protection (#906) Fixes #902 --- docs/rough_edges.md | 4 ++ internal/docs/rough_edges.src.md | 4 ++ mcp/sse.go | 30 +++---------- mcp/sse_test.go | 75 -------------------------------- mcp/streamable.go | 42 ++++++++++-------- mcp/streamable_test.go | 4 +- 6 files changed, 41 insertions(+), 118 deletions(-) diff --git a/docs/rough_edges.md b/docs/rough_edges.md index e0e6d73..7ee18b1 100644 --- a/docs/rough_edges.md +++ b/docs/rough_edges.md @@ -59,3 +59,7 @@ v2. wrapper) we need to first unmarshal into a `map[string]any` in order to do server-side validation of required fields. CallToolParams could have just had a map[string]any. + +- `StreamableHTTPOptions.CrossOriginProtection` should not have been part of + the SDK API. Cross-origin protection is a general HTTP concern, not specific + to MCP, and can be applied as standard HTTP middleware. diff --git a/internal/docs/rough_edges.src.md b/internal/docs/rough_edges.src.md index 42e79f7..d573e14 100644 --- a/internal/docs/rough_edges.src.md +++ b/internal/docs/rough_edges.src.md @@ -58,3 +58,7 @@ v2. wrapper) we need to first unmarshal into a `map[string]any` in order to do server-side validation of required fields. CallToolParams could have just had a map[string]any. + +- `StreamableHTTPOptions.CrossOriginProtection` should not have been part of + the SDK API. Cross-origin protection is a general HTTP concern, not specific + to MCP, and can be applied as standard HTTP middleware. diff --git a/mcp/sse.go b/mcp/sse.go index f8f156d..0e1ad79 100644 --- a/mcp/sse.go +++ b/mcp/sse.go @@ -10,6 +10,7 @@ import ( "crypto/rand" "fmt" "io" + "mime" "net" "net/http" "net/url" @@ -64,14 +65,6 @@ type SSEOptions struct { // Only disable this if you understand the security implications. // See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise DisableLocalhostProtection bool - - // CrossOriginProtection allows to customize cross-origin protection. - // The deny handler set in the CrossOriginProtection through SetDenyHandler - // is ignored. - // If nil, default (zero-value) cross-origin protection will be used. - // Use `disablecrossoriginprotection` MCPGODEBUG compatibility parameter - // to disable the default protection until v1.7.0. - CrossOriginProtection *http.CrossOriginProtection } // NewSSEHandler returns a new [SSEHandler] that creates and manages MCP @@ -97,10 +90,6 @@ func NewSSEHandler(getServer func(request *http.Request) *Server, opts *SSEOptio s.opts = *opts } - if s.opts.CrossOriginProtection == nil { - s.opts.CrossOriginProtection = &http.CrossOriginProtection{} - } - return s } @@ -212,20 +201,13 @@ func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { } } - if disablecrossoriginprotection != "1" { - // Verify the 'Origin' header to protect against CSRF attacks. - if err := h.opts.CrossOriginProtection.Check(req); err != nil { - http.Error(w, err.Error(), http.StatusForbidden) + // Validate 'Content-Type' header. + if req.Method == http.MethodPost { + mediaType, _, err := mime.ParseMediaType(req.Header.Get("Content-Type")) + if err != nil || mediaType != "application/json" { + http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) return } - // Validate 'Content-Type' header. - if req.Method == http.MethodPost { - contentType := req.Header.Get("Content-Type") - if contentType != "application/json" { - http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) - return - } - } } sessionID := req.URL.Query().Get("sessionid") diff --git a/mcp/sse_test.go b/mcp/sse_test.go index fe230a5..86b2cf1 100644 --- a/mcp/sse_test.go +++ b/mcp/sse_test.go @@ -12,7 +12,6 @@ import ( "net" "net/http" "net/http/httptest" - "strings" "sync/atomic" "testing" @@ -320,77 +319,3 @@ func TestSSELocalhostProtection(t *testing.T) { }) } } - -func TestSSEOriginProtection(t *testing.T) { - server := NewServer(testImpl, nil) - - tests := []struct { - name string - protection *http.CrossOriginProtection - requestOrigin string - wantStatusCode int - }{ - { - name: "default protection with Origin header", - protection: nil, - requestOrigin: "https://example.com", - wantStatusCode: http.StatusForbidden, - }, - { - name: "custom protection with trusted origin and same Origin", - protection: func() *http.CrossOriginProtection { - p := http.NewCrossOriginProtection() - if err := p.AddTrustedOrigin("https://example.com"); err != nil { - t.Fatal(err) - } - return p - }(), - requestOrigin: "https://example.com", - wantStatusCode: http.StatusNotFound, // origin accepted; session not found - }, - { - name: "custom protection with trusted origin and different Origin", - protection: func() *http.CrossOriginProtection { - p := http.NewCrossOriginProtection() - if err := p.AddTrustedOrigin("https://example.com"); err != nil { - t.Fatal(err) - } - return p - }(), - requestOrigin: "https://malicious.com", - wantStatusCode: http.StatusForbidden, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - opts := &SSEOptions{ - CrossOriginProtection: tt.protection, - } - handler := NewSSEHandler(func(req *http.Request) *Server { return server }, opts) - httpServer := httptest.NewServer(handler) - defer httpServer.Close() - - // Use POST with a valid session-like URL to test origin protection - // without creating a hanging GET connection. - reqReader := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"ping"}`) - req, err := http.NewRequest(http.MethodPost, httpServer.URL+"?sessionid=nonexistent", reqReader) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Origin", tt.requestOrigin) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - - if got := resp.StatusCode; got != tt.wantStatusCode { - body, _ := io.ReadAll(resp.Body) - t.Errorf("Status code: got %d, want %d (body: %s)", got, tt.wantStatusCode, body) - } - }) - } -} diff --git a/mcp/streamable.go b/mcp/streamable.go index 8deb6c9..1455238 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -180,9 +180,15 @@ type StreamableHTTPOptions struct { // CrossOriginProtection allows to customize cross-origin protection. // The deny handler set in the CrossOriginProtection through SetDenyHandler // is ignored. - // If nil, default (zero-value) cross-origin protection will be used. - // Use `disablecrossoriginprotection` MCPGODEBUG compatibility parameter - // to disable the default protection until v1.7.0. + // If nil, no cross-origin protection is applied. Use the `enableoriginverification` + // MCPGODEBUG compatibility parameter to enable the default protection until v1.8.0. + // + // Deprecated: wrap the handler with cross-origin protection middleware + // instead. For example: + // + // handler := mcp.NewStreamableHTTPHandler(...) + // protection := http.NewCrossOriginProtection() + // protectedHandler := protection.Handler(handler) CrossOriginProtection *http.CrossOriginProtection } @@ -202,7 +208,7 @@ func NewStreamableHTTPHandler(getServer func(*http.Request) *Server, opts *Strea h.opts.Logger = ensureLogger(h.opts.Logger) - if h.opts.CrossOriginProtection == nil { + if h.opts.CrossOriginProtection == nil && enableoriginverification == "1" { h.opts.CrossOriginProtection = &http.CrossOriginProtection{} } @@ -235,15 +241,16 @@ func (h *StreamableHTTPHandler) closeAll() { // disablelocalhostprotection is a compatibility parameter that allows to disable // DNS rebinding protection, which was added in the 1.4.0 version of the SDK. // See the documentation for the mcpgodebug package for instructions how to enable it. -// The option will be removed in the 1.7.0 version of the SDK. +// The option will be removed in the 1.6.0 version of the SDK. var disablelocalhostprotection = mcpgodebug.Value("disablelocalhostprotection") -// disablecrossoriginprotection is a compatibility parameter that allows to disable -// the verification of the 'Origin' and 'Content-Type' headers, which was added in -// the 1.4.1 version of the SDK. See the documentation for the mcpgodebug package -// for instructions how to enable it. -// The option will be removed in the 1.7.0 version of the SDK. -var disablecrossoriginprotection = mcpgodebug.Value("disablecrossoriginprotection") +// enableoriginverification is a compatibility parameter that restores the +// default cross-origin protection behavior from v1.4.1-v1.5.0. When set to +// "1", a zero-value CrossOriginProtection will be applied if none is +// explicitly provided in StreamableHTTPOptions. +// See the documentation for the mcpgodebug package for instructions how to enable it. +// The option will be removed in the 1.8.0 version of the SDK. +var enableoriginverification = mcpgodebug.Value("enableoriginverification") func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { // DNS rebinding protection: auto-enabled for localhost servers. @@ -257,17 +264,18 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque } } - if disablecrossoriginprotection != "1" { + if h.opts.CrossOriginProtection != nil { // Verify the 'Origin' header to protect against CSRF attacks. if err := h.opts.CrossOriginProtection.Check(req); err != nil { http.Error(w, err.Error(), http.StatusForbidden) return } - // Validate 'Content-Type' header. - if req.Method == http.MethodPost && baseMediaType(req.Header.Get("Content-Type")) != "application/json" { - http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) - return - } + } + + // Validate 'Content-Type' header. + if req.Method == http.MethodPost && baseMediaType(req.Header.Get("Content-Type")) != "application/json" { + http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) + return } // Allow multiple 'Accept' headers. diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 592981f..8332621 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -2587,10 +2587,10 @@ func TestStreamableOriginProtection(t *testing.T) { wantStatusCode int }{ { - name: "default protection with Origin header", + name: "no protection with Origin header", protection: nil, requestOrigin: "https://example.com", - wantStatusCode: http.StatusForbidden, + wantStatusCode: http.StatusOK, }, { name: "custom protection with trusted origin and same Origin", From 0edc5971cd5dbab661b4122cd754a5c94d899e6a Mon Sep 17 00:00:00 2001 From: Nick Masi <33795684+N-Masi@users.noreply.github.com> Date: Fri, 24 Apr 2026 05:47:17 -0400 Subject: [PATCH 257/418] Update README.md (#896) correct the default address of the locally hosted server, according to `main.go:21` the default is `:8000` not `:8080` Co-authored-by: Maciej Kisiel --- examples/http/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/http/README.md b/examples/http/README.md index 3bd8c93..ae6bccb 100644 --- a/examples/http/README.md +++ b/examples/http/README.md @@ -15,7 +15,7 @@ The example implements: ```bash go run . server ``` -This starts an MCP server on `http://localhost:8080` (default) that provides a `cityTime` tool. +This starts an MCP server on `http://localhost:8000` (default) that provides a `cityTime` tool. To run a client in another terminal: From 5f2cd8fd14bcd3b7f1e693dd741b5f1a3c338051 Mon Sep 17 00:00:00 2001 From: John Pugliesi Date: Fri, 24 Apr 2026 04:20:45 -0700 Subject: [PATCH 258/418] mcp: preserve transport errors in Write error chain (#888) mcp: preserve transport errors in Write error chain Custom RoundTrippers that return typed errors (e.g. for auth failures) have those errors flattened to strings by `%v`, making `errors.As` unusable for callers inspecting transport-level errors. This is inconsistent with the OAuth handler path (line 1807) which already uses `%w` to preserve errors for client inspection. Relates to #579 Co-authored-by: Maciej Kisiel --- mcp/streamable.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 1455238..b791a1f 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1795,14 +1795,14 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e // Failure to set headers means that the request was not sent. // Wrap with ErrRejected so the jsonrpc2 connection doesn't set writeErr // and permanently break the connection. - return nil, nil, fmt.Errorf("%s: %w: %v", requestSummary, jsonrpc2.ErrRejected, err) + return nil, nil, fmt.Errorf("%s: %w: %w", requestSummary, jsonrpc2.ErrRejected, err) } resp, err := c.client.Do(req) if err != nil { // Any error from client.Do means the request didn't reach the server. // Wrap with ErrRejected so the jsonrpc2 connection doesn't set writeErr // and permanently break the connection. - err = fmt.Errorf("%s: %w: %v", requestSummary, jsonrpc2.ErrRejected, err) + err = fmt.Errorf("%s: %w: %w", requestSummary, jsonrpc2.ErrRejected, err) } return req, resp, err } From db50910c70a71ac28abd761e0736799c048a8888 Mon Sep 17 00:00:00 2001 From: Ravish Gupta Date: Fri, 24 Apr 2026 07:58:57 -0700 Subject: [PATCH 259/418] mcp: do not re-prompt OAuth after cancelled Authorize (#885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `OAuthHandler.Authorize` returns a context-cancelled error, the cancellation notification that the call layer sends in response to the same ctx cancellation is routed back through the streamable client transport, re-triggering the `401` → `Authorize` path and prompting the user a second time for a request they have already abandoned. Fix in two layers: 1. **`mcp/transport.go`** — bound the cancellation `Notify` with a fresh `context.Background()`-derived timeout instead of reusing the cancelled caller context. This guarantees the caller never blocks on a doomed notify against a degraded connection. 2. **`mcp/streamable.go`** — when `Authorize` returns a context-cancelled error, mark the streamable client connection as failed via `c.fail()`. The existing failure check in `Write()` then short-circuits the cancellation notification without re-invoking the OAuth handler, matching the bug reporter's expectation that cancelling `Authorize` abandons the connection. Includes a regression test (`TestStreamableClientOAuth_CancelledAuthorize_NoReprompt`) with a blocking `OAuthHandler` that asserts `Authorize` is invoked exactly once. The test was verified to fail on unfixed code (`got 2` calls) and pass with the fix. Fixes #882 --------- Co-authored-by: Claude Opus 4.6 (1M context) --- mcp/streamable.go | 16 +++++++ mcp/streamable_client_test.go | 88 +++++++++++++++++++++++++++++++++++ mcp/transport.go | 14 ++++-- 3 files changed, 115 insertions(+), 3 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index b791a1f..a176789 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1814,6 +1814,22 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e if (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) && c.oauthHandler != nil { if err := c.oauthHandler.Authorize(ctx, req, resp); err != nil { + // If the caller's context was cancelled while we were running the + // authorization flow, treat the connection as failed so subsequent + // operations on it (e.g. the cancellation notify the call layer + // sends in response to ctx cancellation) short-circuit instead of + // re-invoking the OAuth handler. Otherwise the user gets prompted + // to authorize a request they have already abandoned. See #882. + // + // We check ctx.Err() rather than the error returned by Authorize, + // because the handler is user-implemented and may return an error + // that does not wrap context.Canceled (e.g. a custom sentinel or + // a fmt.Errorf with %v). The context itself is the authoritative + // source for whether the caller abandoned the request. + ctxErr := ctx.Err() + if errors.Is(ctxErr, context.Canceled) || errors.Is(ctxErr, context.DeadlineExceeded) { + c.fail(fmt.Errorf("%s: authorization cancelled: %w", requestSummary, err)) + } // Wrap with ErrRejected so the jsonrpc2 connection doesn't set writeErr // and permanently break the connection. // Wrap the authorization error as well for client inspection. diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 9da8aec..9aac7b5 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -1017,6 +1017,94 @@ func TestStreamableClientOAuth_401(t *testing.T) { } } +// blockingCountingOAuthHandler is an OAuthHandler that blocks inside +// Authorize until the caller's context is cancelled, then returns a custom +// error that does NOT wrap context.Canceled. This mirrors real-world OAuth +// handlers that catch the cancellation internally and surface their own +// error type. The fix for #882 checks ctx.Err() directly rather than +// relying on the error from Authorize, so this must still trigger c.fail(). +// It records how many times Authorize is invoked. +type blockingCountingOAuthHandler struct { + mu sync.Mutex + callCount int +} + +func (h *blockingCountingOAuthHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) { + return nil, nil +} + +func (h *blockingCountingOAuthHandler) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error { + h.mu.Lock() + h.callCount++ + h.mu.Unlock() + // Block until the caller's context is cancelled, mirroring an + // interactive OAuth flow that the user has abandoned. + <-ctx.Done() + // Return a custom error that does not wrap context.Canceled, as a + // real-world handler might. The code under test must check ctx.Err() + // to detect the cancellation, not this error. + return fmt.Errorf("oauth flow interrupted") +} + +func (h *blockingCountingOAuthHandler) Calls() int { + h.mu.Lock() + defer h.mu.Unlock() + return h.callCount +} + +// TestStreamableClientOAuth_CancelledAuthorize_NoReprompt is a regression +// test for #882. When OAuthHandler.Authorize returns a context-cancelled +// error, the connection must enter a failed state so that the cancellation +// notification the call layer sends in response to ctx cancellation does +// not flow back through the same broken auth path and re-invoke Authorize. +func TestStreamableClientOAuth_CancelledAuthorize_NoReprompt(t *testing.T) { + handler := &blockingCountingOAuthHandler{} + + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodInitialize, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "123", + }, + body: jsonBody(t, initResp), + }, + }, + } + verifier := func(ctx context.Context, token string, req *http.Request) (*auth.TokenInfo, error) { + return &auth.TokenInfo{Expiration: time.Now().Add(time.Hour)}, nil + } + httpServer := httptest.NewServer(auth.RequireBearerToken(verifier, nil)(fake)) + t.Cleanup(httpServer.Close) + + transport := &StreamableClientTransport{ + Endpoint: httpServer.URL, + OAuthHandler: handler, + } + client := NewClient(testImpl, nil) + + // Use a context with a tight deadline so the cancellation path runs + // while the auth flow is in progress. + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + _, err := client.Connect(ctx, transport, nil) + if err == nil { + t.Fatal("expected client.Connect to fail") + } + + // Give the cancellation Notify path a moment to (try to) run. + time.Sleep(50 * time.Millisecond) + + // Authorize should be invoked exactly once. The bug in #882 caused + // it to be invoked a second time when the call layer sent the + // cancellation notification through the same auth-broken connection. + if got := handler.Calls(); got != 1 { + t.Errorf("expected Authorize to be called exactly 1 time, got %d", got) + } +} + func TestTokenInfo(t *testing.T) { ctx := context.Background() diff --git a/mcp/transport.go b/mcp/transport.go index 9288b37..ea44747 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -14,13 +14,20 @@ import ( "net" "os" "sync" + "time" internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" - "github.com/modelcontextprotocol/go-sdk/internal/xcontext" "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) +// notifyCancellationTimeout bounds the cancellation notification we send to +// the peer when the caller's context is cancelled. The notification is +// best-effort: a degraded connection (e.g. an OAuth flow that has been +// abandoned) must not be able to block the caller's return path or +// re-trigger expensive recovery on its behalf. See issue #882. +const notifyCancellationTimeout = 5 * time.Second + // ErrConnectionClosed is returned when sending a message to a connection that // is closed or in the process of closing. var ErrConnectionClosed = errors.New("connection closed") @@ -220,8 +227,9 @@ func call(ctx context.Context, conn *jsonrpc2.Connection, method string, params case errors.Is(err, jsonrpc2.ErrClientClosing), errors.Is(err, jsonrpc2.ErrServerClosing): return fmt.Errorf("%w: calling %q: %v", ErrConnectionClosed, method, err) case ctx.Err() != nil: - // Notify the peer of cancellation. - err := conn.Notify(xcontext.Detach(ctx), notificationCancelled, &CancelledParams{ + notifyCtx, cancelNotify := context.WithTimeout(context.WithoutCancel(ctx), notifyCancellationTimeout) + defer cancelNotify() + err := conn.Notify(notifyCtx, notificationCancelled, &CancelledParams{ Reason: ctx.Err().Error(), RequestID: call.ID().Raw(), }) From 2643b22a4ecc1197aedcd2d84d58e592bec04dfe Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Mon, 27 Apr 2026 13:02:01 +0200 Subject: [PATCH 260/418] feat: add automatic application_type inference (#904) Implement requirements introduced by [SEP-337](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837) Fixes #903 --------- Co-authored-by: Maciej Kisiel --- auth/authorization_code.go | 41 ++++++++++ auth/authorization_code_test.go | 130 ++++++++++++++++++++++++++++++++ oauthex/dcr.go | 5 ++ 3 files changed, 176 insertions(+) diff --git a/auth/authorization_code.go b/auth/authorization_code.go index 846feb7..758b88e 100644 --- a/auth/authorization_code.go +++ b/auth/authorization_code.go @@ -15,6 +15,7 @@ import ( "slices" "strings" + "github.com/modelcontextprotocol/go-sdk/internal/util" "github.com/modelcontextprotocol/go-sdk/oauthex" "golang.org/x/oauth2" ) @@ -34,6 +35,10 @@ type ClientIDMetadataDocumentConfig struct { type DynamicClientRegistrationConfig struct { // Metadata to be used in dynamic client registration request as per // https://datatracker.ietf.org/doc/html/rfc7591#section-2. + // + // If Metadata.ApplicationType is empty, it will be inferred from + // Metadata.RedirectURIs. When set, it will be validated against the inferred type + // and an error will be returned if they conflict. Metadata *oauthex.ClientRegistrationMetadata } @@ -150,6 +155,12 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho } else if !slices.Contains(dCfg.Metadata.RedirectURIs, config.RedirectURL) { return nil, fmt.Errorf("RedirectURL %q is not in the list of allowed redirect URIs for dynamic client registration", config.RedirectURL) } + applicationType := inferApplicationType(dCfg.Metadata.RedirectURIs) + if dCfg.Metadata.ApplicationType == "" { + dCfg.Metadata.ApplicationType = applicationType + } else if dCfg.Metadata.ApplicationType != applicationType { + return nil, fmt.Errorf("application type %q conflicts with the application type inferred from redirect URIs", dCfg.Metadata.ApplicationType) + } } if config.RedirectURL == "" { // If the RedirectURL was supposed to be set by the dynamic client registration, @@ -170,6 +181,36 @@ func isNonRootHTTPSURL(u string) bool { return pu.Scheme == "https" && pu.Path != "" } +// inferApplicationType returns an application type based on the redirect URIs. +func inferApplicationType(redirectURIs []string) string { + hasNative := false + hasWeb := false + for _, uri := range redirectURIs { + u, err := url.Parse(uri) + if err != nil { + return "" + } + switch u.Scheme { + case "http", "https": + if util.IsLoopback(u.Hostname()) { + hasNative = true + } else { + hasWeb = true + } + default: + hasNative = true + } + } + + if hasNative && hasWeb { + return "" + } + if hasNative { + return "native" + } + return "web" +} + // Authorize performs the authorization flow. // It is designed to perform the whole Authorization Code Grant flow. // On success, [AuthorizationCodeHandler.TokenSource] will return a token source with the fetched token. diff --git a/auth/authorization_code_test.go b/auth/authorization_code_test.go index cd4741a..92b6faf 100644 --- a/auth/authorization_code_test.go +++ b/auth/authorization_code_test.go @@ -608,6 +608,136 @@ func TestDynamicRegistration(t *testing.T) { } } +func TestInferApplicationType(t *testing.T) { + tests := []struct { + name string + redirectURIs []string + want string + }{ + { + name: "localhost", + redirectURIs: []string{"http://localhost:8085/callback"}, + want: "native", + }, + { + name: "127.0.0.1", + redirectURIs: []string{"http://127.0.0.1:8085/callback"}, + want: "native", + }, + { + name: "IPv6 loopback", + redirectURIs: []string{"http://[::1]:8085/callback"}, + want: "native", + }, + { + name: "custom scheme", + redirectURIs: []string{"myapp://callback"}, + want: "native", + }, + { + name: "HTTPS remote", + redirectURIs: []string{"https://myapp.example.com/callback"}, + want: "web", + }, + { + name: "mixed native and web", + redirectURIs: []string{"https://myapp.example.com/callback", "http://localhost:8085/callback"}, + want: "", + }, + { + name: "multiple remote", + redirectURIs: []string{"https://app1.example.com/cb", "https://app2.example.com/cb"}, + want: "web", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := inferApplicationType(tt.redirectURIs) + if got != tt.want { + t.Errorf("inferApplicationType() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestApplicationTypeInference(t *testing.T) { + fetcher := func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + return nil, nil + } + + tests := []struct { + name string + redirectURIs []string + initialAppType string + wantAppType string + wantErr bool + }{ + { + name: "inferred as native for localhost", + redirectURIs: []string{"http://localhost:8085/callback"}, + wantAppType: "native", + }, + { + name: "inferred as web for remote", + redirectURIs: []string{"https://example.com/callback"}, + wantAppType: "web", + }, + { + name: "mixed native and web URIs sets empty application type", + redirectURIs: []string{"https://example.com/callback", "http://localhost:8085/callback"}, + wantAppType: "", + }, + { + name: "explicit value matching inference is preserved", + redirectURIs: []string{"http://localhost:8085/callback"}, + initialAppType: "native", + wantAppType: "native", + }, + { + name: "explicit value conflicts with inference returns error", + redirectURIs: []string{"http://localhost:8085/callback"}, + initialAppType: "web", + wantErr: true, + }, + { + name: "explicit value when inference is ambiguous returns error", + redirectURIs: []string{"https://example.com/callback", "http://localhost:8085/callback"}, + initialAppType: "web", + wantErr: true, + }, + { + name: "invalid URI returns empty application type", + redirectURIs: []string{"http://%/"}, + wantAppType: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &AuthorizationCodeHandlerConfig{ + DynamicClientRegistrationConfig: &DynamicClientRegistrationConfig{ + Metadata: &oauthex.ClientRegistrationMetadata{ + RedirectURIs: tt.redirectURIs, + ApplicationType: tt.initialAppType, + }, + }, + AuthorizationCodeFetcher: fetcher, + } + _, err := NewAuthorizationCodeHandler(cfg) + if (err != nil) != tt.wantErr { + t.Fatalf("NewAuthorizationCodeHandler() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + got := cfg.DynamicClientRegistrationConfig.Metadata.ApplicationType + if got != tt.wantAppType { + t.Errorf("ApplicationType = %q, want %q", got, tt.wantAppType) + } + }) + } +} + // validConfig for test to create an AuthorizationCodeHandler using its constructor. // Values that are relevant to the test should be set explicitly. func validConfig() *AuthorizationCodeHandlerConfig { diff --git a/oauthex/dcr.go b/oauthex/dcr.go index ce21467..f46d0e8 100644 --- a/oauthex/dcr.go +++ b/oauthex/dcr.go @@ -89,6 +89,11 @@ type ClientRegistrationMetadata struct { // SoftwareStatement is an OPTIONAL JWT that asserts client metadata values. // Values in the software statement take precedence over other metadata values. SoftwareStatement string `json:"software_statement,omitempty"` + + // ApplicationType is an OPTIONAL string that indicates the type of application. + // Valid values are "native" and "web". + // If omitted, OIDC-compliant authorization servers default to "web". + ApplicationType string `json:"application_type,omitempty"` } // ClientRegistrationResponse represents the fields returned by the Authorization Server From 2e21834ad33e0b8105bf2e3b6733a27193f97caf Mon Sep 17 00:00:00 2001 From: Ravish Gupta Date: Mon, 27 Apr 2026 04:28:35 -0700 Subject: [PATCH 261/418] extauth: add ClientCredentialsHandler for OAuth client credentials grant (#895) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `ClientCredentialsHandler` implementing `auth.OAuthHandler` using the OAuth 2.0 Client Credentials grant (RFC 6749 Section 4.4) for service-to-service authentication with pre-registered credentials. - Bypasses both dynamic client registration and the authorization code flow — the handler takes a client ID + secret directly and exchanges them at the token endpoint. - Supports two modes: direct `TokenEndpoint` URL, or metadata discovery via `AuthServerURL` (RFC 8414). - Add `client_credentials` grant type support to the fake authorization server in `internal/oauthtest` for testing. ## Context Per @jba's [comment on #627](https://github.com/modelcontextprotocol/go-sdk/issues/627#issuecomment-3640982691): > Our default implementation for that framework should support client ID and client secret as options, to handle that variant. [...] If those are set, our implementation would bypass dynamic client reservation. That is just a couple of lines of code, I believe. The client-side OAuth scaffolding (#785) that this was blocked on is now complete, as @brkane noted. Implements the client credentials variant of [SEP-1046](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1046). The JWT Assertions variant (RFC 7523) is left for a follow-up as its API surface needs more design discussion. ## Test plan - [x] `TestNewClientCredentialsHandler_Validation` — validates all config error cases (nil config, missing fields, mutual exclusivity) - [x] `TestClientCredentialsHandler_Authorize/direct_token_endpoint` — end-to-end with fake auth server - [x] `TestClientCredentialsHandler_Authorize/metadata_discovery` — discovers token endpoint via RFC 8414 metadata - [x] `TestClientCredentialsHandler_Authorize/bad_credentials` — verifies failure with wrong secret - [x] `go test ./... -count=1` passes - [x] `go vet ./...` clean Refs #627 Co-authored-by: Maciej Kisiel --- auth/extauth/client_credentials.go | 251 +++++++++++++++ auth/extauth/client_credentials_test.go | 293 ++++++++++++++++++ .../oauthtest/fake_authorization_server.go | 28 ++ 3 files changed, 572 insertions(+) create mode 100644 auth/extauth/client_credentials.go create mode 100644 auth/extauth/client_credentials_test.go diff --git a/auth/extauth/client_credentials.go b/auth/extauth/client_credentials.go new file mode 100644 index 0000000..fefa29b --- /dev/null +++ b/auth/extauth/client_credentials.go @@ -0,0 +1,251 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package extauth + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "strings" + + "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/oauthex" + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" +) + +// ClientCredentialsHandlerConfig is the configuration for [ClientCredentialsHandler]. +type ClientCredentialsHandlerConfig struct { + // Credentials contains the pre-registered client ID and secret. + // REQUIRED. Both ClientID and ClientSecretAuth must be set, since the + // client credentials grant requires a confidential client. + Credentials *oauthex.ClientCredentials + + // HTTPClient is an optional HTTP client for customization. + // If nil, http.DefaultClient is used. + // OPTIONAL. + HTTPClient *http.Client +} + +// ClientCredentialsHandler is an implementation of [auth.OAuthHandler] that +// uses the OAuth 2.0 Client Credentials grant (RFC 6749 Section 4.4) to +// obtain access tokens. +// +// This handler is intended for service-to-service authentication where the +// client has pre-registered credentials (client ID and secret) and does not +// require user interaction. It bypasses both dynamic client registration and +// the authorization code flow. +// +// The token endpoint and scopes are discovered automatically via Protected +// Resource Metadata (RFC 9728) and Authorization Server Metadata (RFC 8414), +// following the ext-auth specification SEP-1046. +type ClientCredentialsHandler struct { + config *ClientCredentialsHandlerConfig + tokenSource oauth2.TokenSource +} + +// Compile-time check that ClientCredentialsHandler implements auth.OAuthHandler. +var _ auth.OAuthHandler = (*ClientCredentialsHandler)(nil) + +// NewClientCredentialsHandler creates a new ClientCredentialsHandler. +// It validates the configuration and returns an error if invalid. +func NewClientCredentialsHandler(config *ClientCredentialsHandlerConfig) (*ClientCredentialsHandler, error) { + if config == nil { + return nil, fmt.Errorf("config must be provided") + } + if config.Credentials == nil { + return nil, fmt.Errorf("credentials are required") + } + if err := config.Credentials.Validate(); err != nil { + return nil, fmt.Errorf("invalid credentials: %w", err) + } + if config.Credentials.ClientSecretAuth == nil { + return nil, fmt.Errorf("clientSecretAuth is required for client credentials grant") + } + return &ClientCredentialsHandler{config: config}, nil +} + +// TokenSource returns the token source for outgoing requests. +// Returns nil if authorization has not been performed yet. +func (h *ClientCredentialsHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) { + return h.tokenSource, nil +} + +// Authorize performs the Client Credentials grant to obtain an access token. +// It is called when a request fails with 401 or 403. +// +// The flow follows the ext-auth specification SEP-1046: +// 1. Discover Protected Resource Metadata from the request URL +// 2. Discover Authorization Server Metadata from PRM +// 3. Exchange client credentials for an access token at the token endpoint +func (h *ClientCredentialsHandler) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error { + defer resp.Body.Close() + defer io.Copy(io.Discard, resp.Body) + + httpClient := h.config.HTTPClient + if httpClient == nil { + httpClient = http.DefaultClient + } + + // Step 1: Discover Protected Resource Metadata. + wwwChallenges, err := oauthex.ParseWWWAuthenticate(resp.Header[http.CanonicalHeaderKey("WWW-Authenticate")]) + if err != nil { + return fmt.Errorf("failed to parse WWW-Authenticate header: %v", err) + } + + prm, err := getProtectedResourceMetadata(ctx, wwwChallenges, req.URL.String(), httpClient) + if err != nil { + return err + } + + if len(prm.AuthorizationServers) == 0 { + return fmt.Errorf("protected resource metadata has no authorization servers specified") + } + + // Step 2: Discover Authorization Server Metadata. + asm, err := auth.GetAuthServerMetadata(ctx, prm.AuthorizationServers[0], httpClient) + if err != nil { + return fmt.Errorf("failed to get authorization server metadata: %w", err) + } + if asm == nil { + // Fallback to 2025-03-26 spec: predefined endpoints. + authServerURL := prm.AuthorizationServers[0] + asm = &oauthex.AuthServerMeta{ + Issuer: authServerURL, + TokenEndpoint: authServerURL + "/token", + } + } + + // Determine scopes: use PRM's scopes_supported if available. + scopes := scopesFromChallenges(wwwChallenges) + if len(scopes) == 0 && len(prm.ScopesSupported) > 0 { + scopes = prm.ScopesSupported + } + + // Step 3: Exchange client credentials for an access token. + creds := h.config.Credentials + cfg := &clientcredentials.Config{ + ClientID: creds.ClientID, + ClientSecret: creds.ClientSecretAuth.ClientSecret, + TokenURL: asm.TokenEndpoint, + Scopes: scopes, + AuthStyle: selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported), + } + + ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient) + h.tokenSource = cfg.TokenSource(ctxWithClient) + + // Eagerly fetch a token to surface errors immediately. + if _, err := h.tokenSource.Token(); err != nil { + h.tokenSource = nil + return fmt.Errorf("client credentials token request failed: %w", err) + } + return nil +} + +// getProtectedResourceMetadata discovers Protected Resource Metadata (RFC 9728) +// from the request URL. This mirrors the logic in AuthorizationCodeHandler. +func getProtectedResourceMetadata(ctx context.Context, wwwChallenges []oauthex.Challenge, mcpServerURL string, httpClient *http.Client) (*oauthex.ProtectedResourceMetadata, error) { + // Use MCP server URL as the resource URI per + // https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#canonical-server-uri. + for _, u := range protectedResourceMetadataURLs(resourceMetadataURLFromChallenges(wwwChallenges), mcpServerURL) { + prm, err := oauthex.GetProtectedResourceMetadata(ctx, u.url, u.resource, httpClient) + if err != nil { + continue + } + if prm == nil { + continue + } + if len(prm.AuthorizationServers) == 0 { + // If we found PRM, we enforce the 2025-11-25 spec and not search further. + return nil, fmt.Errorf("protected resource metadata has no authorization servers specified") + } + return prm, nil + } + // Fallback to 2025-03-26 spec: MCP server root is the Authorization Server. + // https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#server-metadata-discovery + u, err := url.Parse(mcpServerURL) + if err != nil { + return nil, fmt.Errorf("failed to parse MCP server URL: %v", err) + } + u.Path = "" + return &oauthex.ProtectedResourceMetadata{ + AuthorizationServers: []string{u.String()}, + Resource: mcpServerURL, + }, nil +} + +type prmURL struct { + url string + resource string +} + +// protectedResourceMetadataURLs returns URLs to try for PRM discovery. +// This mirrors the logic in AuthorizationCodeHandler. +func protectedResourceMetadataURLs(metadataURL, resourceURL string) []prmURL { + var urls []prmURL + if metadataURL != "" { + urls = append(urls, prmURL{url: metadataURL, resource: resourceURL}) + } + ru, err := url.Parse(resourceURL) + if err != nil { + return urls + } + mu := *ru + // At the path of the server's MCP endpoint. + mu.Path = "/.well-known/oauth-protected-resource/" + strings.TrimLeft(ru.Path, "/") + urls = append(urls, prmURL{url: mu.String(), resource: resourceURL}) + // At the root. + mu.Path = "/.well-known/oauth-protected-resource" + ru.Path = "" + urls = append(urls, prmURL{url: mu.String(), resource: ru.String()}) + return urls +} + +// resourceMetadataURLFromChallenges returns a resource metadata URL from +// WWW-Authenticate challenges, or the empty string if there is none. +func resourceMetadataURLFromChallenges(cs []oauthex.Challenge) string { + for _, c := range cs { + if u := c.Params["resource_metadata"]; u != "" { + return u + } + } + return "" +} + +// scopesFromChallenges returns scopes from WWW-Authenticate challenges. +// It only looks at challenges with the "Bearer" scheme. +func scopesFromChallenges(cs []oauthex.Challenge) []string { + for _, c := range cs { + if c.Scheme == "bearer" && c.Params["scope"] != "" { + return strings.Fields(c.Params["scope"]) + } + } + return nil +} + +// selectTokenAuthMethod selects the preferred token endpoint auth method based on +// the authorization server's supported methods. Prefers client_secret_post over +// client_secret_basic per the OAuth 2.1 draft. +func selectTokenAuthMethod(supported []string) oauth2.AuthStyle { + prefOrder := []string{ + "client_secret_post", + "client_secret_basic", + } + for _, method := range prefOrder { + if slices.Contains(supported, method) { + switch method { + case "client_secret_post": + return oauth2.AuthStyleInParams + case "client_secret_basic": + return oauth2.AuthStyleInHeader + } + } + } + return oauth2.AuthStyleAutoDetect +} diff --git a/auth/extauth/client_credentials_test.go b/auth/extauth/client_credentials_test.go new file mode 100644 index 0000000..91ae7c0 --- /dev/null +++ b/auth/extauth/client_credentials_test.go @@ -0,0 +1,293 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package extauth + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/internal/oauthtest" + "github.com/modelcontextprotocol/go-sdk/oauthex" + "golang.org/x/oauth2" +) + +func validClientCredentialsConfig() *ClientCredentialsHandlerConfig { + return &ClientCredentialsHandlerConfig{ + Credentials: &oauthex.ClientCredentials{ + ClientID: "test-client", + ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: "test-secret"}, + }, + } +} + +func TestNewClientCredentialsHandler_Validation(t *testing.T) { + tests := []struct { + name string + config *ClientCredentialsHandlerConfig + wantError string + }{ + { + name: "nil config", + config: nil, + wantError: "config must be provided", + }, + { + name: "nil credentials", + config: func() *ClientCredentialsHandlerConfig { + c := validClientCredentialsConfig() + c.Credentials = nil + return c + }(), + wantError: "credentials are required", + }, + { + name: "missing client ID", + config: func() *ClientCredentialsHandlerConfig { + c := validClientCredentialsConfig() + c.Credentials.ClientID = "" + return c + }(), + wantError: "ClientID is required", + }, + { + name: "missing client secret auth", + config: func() *ClientCredentialsHandlerConfig { + c := validClientCredentialsConfig() + c.Credentials.ClientSecretAuth = nil + return c + }(), + wantError: "clientSecretAuth is required", + }, + { + name: "empty client secret", + config: func() *ClientCredentialsHandlerConfig { + c := validClientCredentialsConfig() + c.Credentials.ClientSecretAuth.ClientSecret = "" + return c + }(), + wantError: "ClientSecret is required", + }, + { + name: "valid config", + config: validClientCredentialsConfig(), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := NewClientCredentialsHandler(tc.config) + if tc.wantError != "" { + if err == nil { + t.Errorf("expected error containing %q, got nil", tc.wantError) + } else if !strings.Contains(err.Error(), tc.wantError) { + t.Errorf("error %q does not contain %q", err.Error(), tc.wantError) + } + } else if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +func TestClientCredentialsHandler_Authorize(t *testing.T) { + authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + MetadataEndpointConfig: &oauthtest.MetadataEndpointConfig{ + ServeOAuthInsertedEndpoint: true, + }, + RegistrationConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "test-client": {Secret: "test-secret"}, + }, + }, + ClientCredentialsConfig: &oauthtest.ClientCredentialsConfig{ + Enabled: true, + }, + }) + authServer.Start(t) + + resourceMux := http.NewServeMux() + resourceServer := httptest.NewServer(resourceMux) + t.Cleanup(resourceServer.Close) + resourceURL := resourceServer.URL + "/resource" + + resourceMux.Handle("/.well-known/oauth-protected-resource/resource", auth.ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{ + Resource: resourceURL, + AuthorizationServers: []string{authServer.URL()}, + ScopesSupported: []string{"mcp:read", "mcp:write"}, + })) + + t.Run("successful authorization", func(t *testing.T) { + handler, err := NewClientCredentialsHandler(validClientCredentialsConfig()) + if err != nil { + t.Fatal(err) + } + + // TokenSource should be nil before Authorize. + ts, err := handler.TokenSource(t.Context()) + if err != nil { + t.Fatal(err) + } + if ts != nil { + t.Fatal("expected nil TokenSource before Authorize") + } + + // Simulate a 401 response from the MCP server. + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: http.Header{}, + Body: http.NoBody, + } + req := httptest.NewRequest("GET", resourceURL, nil) + if err := handler.Authorize(t.Context(), req, resp); err != nil { + t.Fatalf("Authorize failed: %v", err) + } + + // TokenSource should now return a valid token. + ts, err = handler.TokenSource(t.Context()) + if err != nil { + t.Fatal(err) + } + if ts == nil { + t.Fatal("expected non-nil TokenSource after Authorize") + } + tok, err := ts.Token() + if err != nil { + t.Fatalf("Token() failed: %v", err) + } + if tok.AccessToken != "test_access_token" { + t.Errorf("got access token %q, want %q", tok.AccessToken, "test_access_token") + } + }) + + t.Run("bad credentials", func(t *testing.T) { + config := validClientCredentialsConfig() + config.Credentials.ClientSecretAuth.ClientSecret = "wrong-secret" + handler, err := NewClientCredentialsHandler(config) + if err != nil { + t.Fatal(err) + } + + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: http.Header{}, + Body: http.NoBody, + } + req := httptest.NewRequest("GET", resourceURL, nil) + if err := handler.Authorize(t.Context(), req, resp); err == nil { + t.Error("expected Authorize to fail with bad credentials") + } + + // TokenSource should still be nil after failed Authorize. + ts, err := handler.TokenSource(t.Context()) + if err != nil { + t.Fatal(err) + } + if ts != nil { + t.Error("expected nil TokenSource after failed Authorize") + } + }) + + t.Run("scopes from WWW-Authenticate challenge", func(t *testing.T) { + handler, err := NewClientCredentialsHandler(validClientCredentialsConfig()) + if err != nil { + t.Fatal(err) + } + + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: http.Header{ + "WWW-Authenticate": []string{`Bearer scope="read write"`}, + }, + Body: http.NoBody, + } + req := httptest.NewRequest("GET", resourceURL, nil) + if err := handler.Authorize(t.Context(), req, resp); err != nil { + t.Fatalf("Authorize failed: %v", err) + } + + ts, err := handler.TokenSource(t.Context()) + if err != nil { + t.Fatal(err) + } + if ts == nil { + t.Fatal("expected non-nil TokenSource") + } + }) + + t.Run("PRM via resource_metadata in challenge", func(t *testing.T) { + prmMux := http.NewServeMux() + prmMux.Handle("/custom-prm", auth.ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{ + Resource: resourceURL, + AuthorizationServers: []string{authServer.URL()}, + })) + prmServer := httptest.NewServer(prmMux) + t.Cleanup(prmServer.Close) + + handler, err := NewClientCredentialsHandler(validClientCredentialsConfig()) + if err != nil { + t.Fatal(err) + } + + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: http.Header{ + "WWW-Authenticate": []string{`Bearer resource_metadata="` + prmServer.URL + `/custom-prm"`}, + }, + Body: http.NoBody, + } + req := httptest.NewRequest("GET", resourceURL, nil) + if err := handler.Authorize(t.Context(), req, resp); err != nil { + t.Fatalf("Authorize with resource_metadata challenge failed: %v", err) + } + + ts, err := handler.TokenSource(t.Context()) + if err != nil { + t.Fatal(err) + } + if ts == nil { + t.Fatal("expected non-nil TokenSource") + } + }) +} + +func TestSelectTokenAuthMethod(t *testing.T) { + tests := []struct { + name string + supported []string + want oauth2.AuthStyle + }{ + { + name: "prefers client_secret_post", + supported: []string{"client_secret_basic", "client_secret_post"}, + want: oauth2.AuthStyleInParams, + }, + { + name: "falls back to client_secret_basic", + supported: []string{"client_secret_basic"}, + want: oauth2.AuthStyleInHeader, + }, + { + name: "auto detect when none supported", + supported: []string{"private_key_jwt"}, + want: oauth2.AuthStyleAutoDetect, + }, + { + name: "auto detect when empty", + supported: nil, + want: oauth2.AuthStyleAutoDetect, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := selectTokenAuthMethod(tc.supported) + if got != tc.want { + t.Errorf("selectTokenAuthMethod(%v) = %v, want %v", tc.supported, got, tc.want) + } + }) + } +} diff --git a/internal/oauthtest/fake_authorization_server.go b/internal/oauthtest/fake_authorization_server.go index 44f937f..33c25e8 100644 --- a/internal/oauthtest/fake_authorization_server.go +++ b/internal/oauthtest/fake_authorization_server.go @@ -57,6 +57,15 @@ type JWTBearerConfig struct { ValidAssertions []string } +// ClientCredentialsConfig configures support for the client_credentials +// grant type (RFC 6749 Section 4.4) on a [FakeAuthorizationServer]. +type ClientCredentialsConfig struct { + // Enabled controls whether the /token endpoint accepts + // grant_type=client_credentials and returns an access token + // if client authentication succeeds. + Enabled bool +} + // Config holds configuration for FakeAuthorizationServer. type Config struct { // The optional path component of the issuer URL. @@ -70,6 +79,9 @@ type Config struct { // JWTBearerConfig enables RFC 7523 JWT Bearer grant at the /token endpoint. // If non-nil, the server accepts grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer. JWTBearerConfig *JWTBearerConfig + // ClientCredentialsConfig enables RFC 6749 Section 4.4 client credentials + // grant at the /token endpoint. + ClientCredentialsConfig *ClientCredentialsConfig } // FakeAuthorizationServer is a fake OAuth 2.0 Authorization Server for testing. @@ -271,6 +283,8 @@ func (s *FakeAuthorizationServer) handleToken(w http.ResponseWriter, r *http.Req s.handleAuthorizationCodeGrant(w, r) case "urn:ietf:params:oauth:grant-type:jwt-bearer": s.handleJWTBearerGrant(w, r) + case "client_credentials": + s.handleClientCredentialsGrant(w, r) default: http.Error(w, fmt.Sprintf("unsupported grant_type: %s", grantType), http.StatusBadRequest) } @@ -332,6 +346,20 @@ func (s *FakeAuthorizationServer) handleJWTBearerGrant(w http.ResponseWriter, r }) } +func (s *FakeAuthorizationServer) handleClientCredentialsGrant(w http.ResponseWriter, r *http.Request) { + if s.config.ClientCredentialsConfig == nil || !s.config.ClientCredentialsConfig.Enabled { + http.Error(w, "client_credentials grant not supported", http.StatusBadRequest) + return + } + // Client was already authenticated in handleToken. + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "access_token": "test_access_token", + "token_type": "Bearer", + "expires_in": 3600, + }) +} + func (s *FakeAuthorizationServer) authenticateClient(r *http.Request) error { clientID, clientSecret, ok := r.BasicAuth() if !ok { From 446beae01800ca510b42345a7330f1b7b6bbbf89 Mon Sep 17 00:00:00 2001 From: Rafael Dantas Justo Date: Tue, 28 Apr 2026 04:51:18 -0300 Subject: [PATCH 262/418] mcp: Upgrade jsonschema-go (#912) Use the [latest release](https://github.com/google/jsonschema-go/releases/tag/v0.4.3) to fix schema validation issues for custom types. Related to: https://github.com/google/jsonschema-go/pull/70 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index a6aae2b..386b393 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.0 require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-cmp v0.7.0 - github.com/google/jsonschema-go v0.4.2 + github.com/google/jsonschema-go v0.4.3 github.com/segmentio/encoding v0.5.4 github.com/yosida95/uritemplate/v3 v3.0.2 golang.org/x/oauth2 v0.35.0 diff --git a/go.sum b/go.sum index a60fd62..ea18ced 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= -github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= From 93a41b2cfa81e6be813feadfd6dc1ed665c06da7 Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Tue, 28 Apr 2026 09:56:15 +0200 Subject: [PATCH 263/418] internal/jsonrpc2: remove unused code (#910) Remove code inherited from x/tools/internal/jsonrpc2_v2 that is not used by MCP transports. MCP transports implement their own framing and connection management, using only the Connection engine and message encode/decode from this package. Removed files: - serve.go: Server, Listener, Dialer, idleListener, Dial, NewServer - net.go: NetListener, NetDialer, NetPipeListener - serve_test.go, jsonrpc2_test.go: tests for removed infrastructure Removed from remaining files: - frame.go: Framer interface, RawFramer, HeaderFramer implementations - conn.go: Binder, BinderFunc, ConnectionOptions, bindConnection - jsonrpc2.go: ErrIdleTimeout, PreempterFunc, defaultHandler, async helper - wire.go: ErrServerOverloaded - conn.go: AsyncCall.IsReady (unused method) --- internal/jsonrpc2/conn.go | 121 ++------- internal/jsonrpc2/frame.go | 208 ---------------- internal/jsonrpc2/jsonrpc2.go | 70 +----- internal/jsonrpc2/jsonrpc2_test.go | 385 ----------------------------- internal/jsonrpc2/net.go | 138 ----------- internal/jsonrpc2/serve.go | 330 ------------------------- internal/jsonrpc2/serve_test.go | 354 -------------------------- internal/jsonrpc2/wire.go | 3 - 8 files changed, 29 insertions(+), 1580 deletions(-) delete mode 100644 internal/jsonrpc2/frame.go delete mode 100644 internal/jsonrpc2/jsonrpc2_test.go delete mode 100644 internal/jsonrpc2/net.go delete mode 100644 internal/jsonrpc2/serve.go delete mode 100644 internal/jsonrpc2/serve_test.go diff --git a/internal/jsonrpc2/conn.go b/internal/jsonrpc2/conn.go index 571df63..72cc740 100644 --- a/internal/jsonrpc2/conn.go +++ b/internal/jsonrpc2/conn.go @@ -16,45 +16,6 @@ import ( "github.com/modelcontextprotocol/go-sdk/internal/json" ) -// Binder builds a connection configuration. -// This may be used in servers to generate a new configuration per connection. -// ConnectionOptions itself implements Binder returning itself unmodified, to -// allow for the simple cases where no per connection information is needed. -type Binder interface { - // Bind returns the ConnectionOptions to use when establishing the passed-in - // Connection. - // - // The connection is not ready to use when Bind is called, - // but Bind may close it without reading or writing to it. - Bind(context.Context, *Connection) ConnectionOptions -} - -// A BinderFunc implements the Binder interface for a standalone Bind function. -type BinderFunc func(context.Context, *Connection) ConnectionOptions - -func (f BinderFunc) Bind(ctx context.Context, c *Connection) ConnectionOptions { - return f(ctx, c) -} - -var _ Binder = BinderFunc(nil) - -// ConnectionOptions holds the options for new connections. -type ConnectionOptions struct { - // Framer allows control over the message framing and encoding. - // If nil, HeaderFramer will be used. - Framer Framer - // Preempter allows registration of a pre-queue message handler. - // If nil, no messages will be preempted. - Preempter Preempter - // Handler is used as the queued message handler for inbound messages. - // If nil, all responses will be ErrNotHandled. - Handler Handler - // OnInternalError, if non-nil, is called with any internal errors that occur - // while serving the connection, such as protocol errors or invariant - // violations. (If nil, internal errors result in panics.) - OnInternalError func(error) -} - // Connection manages the jsonrpc2 protocol, connecting responses back to their // calls. Connection is bidirectional; it does not have a designated server or // client end. @@ -197,9 +158,28 @@ type incomingRequest struct { cancel context.CancelFunc } -// Bind returns the options unmodified. -func (o ConnectionOptions) Bind(context.Context, *Connection) ConnectionOptions { - return o +// Reader abstracts the transport mechanics from the JSON RPC protocol. +// A Connection reads messages from the reader it was provided on construction, +// and assumes that each call to Read fully transfers a single message, +// or returns an error. +// +// A reader is not safe for concurrent use, it is expected it will be used by +// a single Connection in a safe manner. +type Reader interface { + // Read gets the next message from the stream. + Read(context.Context) (Message, error) +} + +// Writer abstracts the transport mechanics from the JSON RPC protocol. +// A Connection writes messages using the writer it was provided on construction, +// and assumes that each call to Write fully transfers a single message, +// or returns an error. +// +// A writer must be safe for concurrent use, as writes may occur concurrently +// in practice: libraries may make calls or respond to requests asynchronously. +type Writer interface { + // Write sends a message to the stream. + Write(context.Context, Message) error } // A ConnectionConfig configures a bidirectional jsonrpc2 connection. @@ -230,59 +210,16 @@ func NewConnection(ctx context.Context, cfg ConnectionConfig) *Connection { return c } -// bindConnection creates a new connection and runs it. -// -// This is used by the Dial and Serve functions to build the actual connection. -// -// The connection is closed automatically (and its resources cleaned up) when -// the last request has completed after the underlying ReadWriteCloser breaks, -// but it may be stopped earlier by calling Close (for a clean shutdown). -func bindConnection(bindCtx context.Context, rwc io.ReadWriteCloser, binder Binder, onDone func()) *Connection { - // TODO: Should we create a new event span here? - // This will propagate cancellation from ctx; should it? - ctx := notDone{bindCtx} - - c := &Connection{ - state: inFlightState{closer: rwc}, - done: make(chan struct{}), - onDone: onDone, - } - // It's tempting to set a finalizer on c to verify that the state has gone - // idle when the connection becomes unreachable. Unfortunately, the Binder - // interface makes that unsafe: it allows the Handler to close over the - // Connection, which could create a reference cycle that would cause the - // Connection to become uncollectable. - - options := binder.Bind(bindCtx, c) - framer := options.Framer - if framer == nil { - framer = HeaderFramer() - } - c.handler = options.Handler - if c.handler == nil { - c.handler = defaultHandler{} - } - c.onInternalError = options.OnInternalError - - c.writer = framer.Writer(rwc) - reader := framer.Reader(rwc) - c.start(ctx, reader, options.Preempter) - return c -} - func (c *Connection) start(ctx context.Context, reader Reader, preempter Preempter) { c.updateInFlight(func(s *inFlightState) { select { case <-c.done: - // Bind already closed the connection; don't start a goroutine to read it. + // The connection was already closed; don't start a goroutine to read it. return default: } // The goroutine started here will continue until the underlying stream is closed. - // - // (If the Binder closed the Connection already, this should error out and - // return almost immediately.) s.reading = true go c.readIncoming(ctx, reader, preempter) }) @@ -439,18 +376,6 @@ type AsyncCall struct { // This can be used to cancel the call if needed. func (ac *AsyncCall) ID() ID { return ac.id } -// IsReady can be used to check if the result is already prepared. -// This is guaranteed to return true on a result for which Await has already -// returned, or a call that failed to send in the first place. -func (ac *AsyncCall) IsReady() bool { - select { - case <-ac.ready: - return true - default: - return false - } -} - // retire processes the response to the call. // // It is an error to call retire more than once: retire is guarded by the diff --git a/internal/jsonrpc2/frame.go b/internal/jsonrpc2/frame.go deleted file mode 100644 index 72527cb..0000000 --- a/internal/jsonrpc2/frame.go +++ /dev/null @@ -1,208 +0,0 @@ -// Copyright 2018 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -package jsonrpc2 - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "io" - "strconv" - "strings" - "sync" -) - -// Reader abstracts the transport mechanics from the JSON RPC protocol. -// A Conn reads messages from the reader it was provided on construction, -// and assumes that each call to Read fully transfers a single message, -// or returns an error. -// -// A reader is not safe for concurrent use, it is expected it will be used by -// a single Conn in a safe manner. -type Reader interface { - // Read gets the next message from the stream. - Read(context.Context) (Message, error) -} - -// Writer abstracts the transport mechanics from the JSON RPC protocol. -// A Conn writes messages using the writer it was provided on construction, -// and assumes that each call to Write fully transfers a single message, -// or returns an error. -// -// A writer must be safe for concurrent use, as writes may occur concurrently -// in practice: libraries may make calls or respond to requests asynchronously. -type Writer interface { - // Write sends a message to the stream. - Write(context.Context, Message) error -} - -// Framer wraps low level byte readers and writers into jsonrpc2 message -// readers and writers. -// It is responsible for the framing and encoding of messages into wire form. -// -// TODO(rfindley): rethink the framer interface, as with JSONRPC2 batching -// there is a need for Reader and Writer to be correlated, and while the -// implementation of framing here allows that, it is not made explicit by the -// interface. -// -// Perhaps a better interface would be -// -// Frame(io.ReadWriteCloser) (Reader, Writer). -type Framer interface { - // Reader wraps a byte reader into a message reader. - Reader(io.Reader) Reader - // Writer wraps a byte writer into a message writer. - Writer(io.Writer) Writer -} - -// RawFramer returns a new Framer. -// The messages are sent with no wrapping, and rely on json decode consistency -// to determine message boundaries. -func RawFramer() Framer { return rawFramer{} } - -type rawFramer struct{} -type rawReader struct{ in *json.Decoder } -type rawWriter struct { - mu sync.Mutex - out io.Writer -} - -func (rawFramer) Reader(rw io.Reader) Reader { - return &rawReader{in: json.NewDecoder(rw)} -} - -func (rawFramer) Writer(rw io.Writer) Writer { - return &rawWriter{out: rw} -} - -func (r *rawReader) Read(ctx context.Context) (Message, error) { - select { - case <-ctx.Done(): - return nil, ctx.Err() - default: - } - var raw json.RawMessage - if err := r.in.Decode(&raw); err != nil { - return nil, err - } - msg, err := DecodeMessage(raw) - return msg, err -} - -func (w *rawWriter) Write(ctx context.Context, msg Message) error { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - data, err := EncodeMessage(msg) - if err != nil { - return fmt.Errorf("marshaling message: %v", err) - } - - w.mu.Lock() - defer w.mu.Unlock() - _, err = w.out.Write(data) - return err -} - -// HeaderFramer returns a new Framer. -// The messages are sent with HTTP content length and MIME type headers. -// This is the format used by LSP and others. -func HeaderFramer() Framer { return headerFramer{} } - -type headerFramer struct{} -type headerReader struct{ in *bufio.Reader } -type headerWriter struct { - mu sync.Mutex - out io.Writer -} - -func (headerFramer) Reader(rw io.Reader) Reader { - return &headerReader{in: bufio.NewReader(rw)} -} - -func (headerFramer) Writer(rw io.Writer) Writer { - return &headerWriter{out: rw} -} - -func (r *headerReader) Read(ctx context.Context) (Message, error) { - select { - case <-ctx.Done(): - return nil, ctx.Err() - default: - } - - firstRead := true // to detect a clean EOF below - var contentLength int64 - // read the header, stop on the first empty line - for { - line, err := r.in.ReadString('\n') - if err != nil { - if err == io.EOF { - if firstRead && line == "" { - return nil, io.EOF // clean EOF - } - err = io.ErrUnexpectedEOF - } - return nil, fmt.Errorf("failed reading header line: %w", err) - } - firstRead = false - - line = strings.TrimSpace(line) - // check we have a header line - if line == "" { - break - } - colon := strings.IndexRune(line, ':') - if colon < 0 { - return nil, fmt.Errorf("invalid header line %q", line) - } - name, value := line[:colon], strings.TrimSpace(line[colon+1:]) - switch { - case strings.EqualFold(name, "Content-Length"): - if contentLength, err = strconv.ParseInt(value, 10, 32); err != nil { - return nil, fmt.Errorf("failed parsing Content-Length: %v", value) - } - if contentLength <= 0 { - return nil, fmt.Errorf("invalid Content-Length: %v", contentLength) - } - default: - // ignoring unknown headers - } - } - if contentLength == 0 { - return nil, fmt.Errorf("missing Content-Length header") - } - data := make([]byte, contentLength) - _, err := io.ReadFull(r.in, data) - if err != nil { - return nil, err - } - msg, err := DecodeMessage(data) - return msg, err -} - -func (w *headerWriter) Write(ctx context.Context, msg Message) error { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - w.mu.Lock() - defer w.mu.Unlock() - - data, err := EncodeMessage(msg) - if err != nil { - return fmt.Errorf("marshaling message: %v", err) - } - _, err = fmt.Fprintf(w.out, "Content-Length: %v\r\n\r\n", len(data)) - if err == nil { - _, err = w.out.Write(data) - } - return err -} diff --git a/internal/jsonrpc2/jsonrpc2.go b/internal/jsonrpc2/jsonrpc2.go index 234e6ee..76a5116 100644 --- a/internal/jsonrpc2/jsonrpc2.go +++ b/internal/jsonrpc2/jsonrpc2.go @@ -12,17 +12,12 @@ import ( "errors" ) -var ( - // ErrIdleTimeout is returned when serving timed out waiting for new connections. - ErrIdleTimeout = errors.New("timed out waiting for new connections") - - // ErrNotHandled is returned from a Handler or Preempter to indicate it did - // not handle the request. - // - // If a Handler returns ErrNotHandled, the server replies with - // ErrMethodNotFound. - ErrNotHandled = errors.New("JSON RPC not handled") -) +// ErrNotHandled is returned from a Handler or Preempter to indicate it did +// not handle the request. +// +// If a Handler returns ErrNotHandled, the server replies with +// ErrMethodNotFound. +var ErrNotHandled = errors.New("JSON RPC not handled") // Preempter handles messages on a connection before they are queued to the main // handler. @@ -40,15 +35,6 @@ type Preempter interface { Preempt(ctx context.Context, req *Request) (result any, err error) } -// A PreempterFunc implements the Preempter interface for a standalone Preempt function. -type PreempterFunc func(ctx context.Context, req *Request) (any, error) - -func (f PreempterFunc) Preempt(ctx context.Context, req *Request) (any, error) { - return f(ctx, req) -} - -var _ Preempter = PreempterFunc(nil) - // Handler handles messages on a connection. type Handler interface { // Handle is invoked sequentially for each incoming request that has not @@ -67,16 +53,6 @@ type Handler interface { Handle(ctx context.Context, req *Request) (result any, err error) } -type defaultHandler struct{} - -func (defaultHandler) Preempt(context.Context, *Request) (any, error) { - return nil, ErrNotHandled -} - -func (defaultHandler) Handle(context.Context, *Request) (any, error) { - return nil, ErrNotHandled -} - // A HandlerFunc implements the Handler interface for a standalone Handle function. type HandlerFunc func(ctx context.Context, req *Request) (any, error) @@ -85,37 +61,3 @@ func (f HandlerFunc) Handle(ctx context.Context, req *Request) (any, error) { } var _ Handler = HandlerFunc(nil) - -// async is a small helper for operations with an asynchronous result that you -// can wait for. -type async struct { - ready chan struct{} // closed when done - firstErr chan error // 1-buffered; contains either nil or the first non-nil error -} - -func newAsync() *async { - var a async - a.ready = make(chan struct{}) - a.firstErr = make(chan error, 1) - a.firstErr <- nil - return &a -} - -func (a *async) done() { - close(a.ready) -} - -func (a *async) wait() error { - <-a.ready - err := <-a.firstErr - a.firstErr <- err - return err -} - -func (a *async) setError(err error) { - storedErr := <-a.firstErr - if storedErr == nil { - storedErr = err - } - a.firstErr <- storedErr -} diff --git a/internal/jsonrpc2/jsonrpc2_test.go b/internal/jsonrpc2/jsonrpc2_test.go deleted file mode 100644 index 8c79300..0000000 --- a/internal/jsonrpc2/jsonrpc2_test.go +++ /dev/null @@ -1,385 +0,0 @@ -// Copyright 2018 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -package jsonrpc2_test - -import ( - "context" - "encoding/json" - "fmt" - "path" - "reflect" - "testing" - "time" - - "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" -) - -var callTests = []invoker{ - call{"no_args", nil, true}, - call{"one_string", "fish", "got:fish"}, - call{"one_number", 10, "got:10"}, - call{"join", []string{"a", "b", "c"}, "a/b/c"}, - sequence{"notify", []invoker{ - notify{"set", 3}, - notify{"add", 5}, - call{"get", nil, 8}, - }}, - sequence{"preempt", []invoker{ - async{"a", "wait", "a"}, - notify{"unblock", "a"}, - collect{"a", true, false}, - }}, - sequence{"basic cancel", []invoker{ - async{"b", "wait", "b"}, - cancel{"b"}, - collect{"b", nil, true}, - }}, - sequence{"queue", []invoker{ - async{"a", "wait", "a"}, - notify{"set", 1}, - notify{"add", 2}, - notify{"add", 3}, - notify{"add", 4}, - call{"peek", nil, 0}, // accumulator will not have any adds yet - notify{"unblock", "a"}, - collect{"a", true, false}, - call{"get", nil, 10}, // accumulator now has all the adds - }}, - sequence{"fork", []invoker{ - async{"a", "fork", "a"}, - notify{"set", 1}, - notify{"add", 2}, - notify{"add", 3}, - notify{"add", 4}, - call{"get", nil, 10}, // fork will not have blocked the adds - notify{"unblock", "a"}, - collect{"a", true, false}, - }}, - sequence{"concurrent", []invoker{ - async{"a", "fork", "a"}, - notify{"unblock", "a"}, - async{"b", "fork", "b"}, - notify{"unblock", "b"}, - collect{"a", true, false}, - collect{"b", true, false}, - }}, -} - -type binder struct { - framer jsonrpc2.Framer - runTest func(*handler) -} - -type handler struct { - conn *jsonrpc2.Connection - accumulator int - waiters chan map[string]chan struct{} - calls map[string]*jsonrpc2.AsyncCall -} - -type invoker interface { - Name() string - Invoke(t *testing.T, ctx context.Context, h *handler) -} - -type notify struct { - method string - params any -} - -type call struct { - method string - params any - expect any -} - -type async struct { - name string - method string - params any -} - -type collect struct { - name string - expect any - fails bool -} - -type cancel struct { - name string -} - -type sequence struct { - name string - tests []invoker -} - -type echo call - -type cancelParams struct{ ID int64 } - -func TestConnectionRaw(t *testing.T) { - testConnection(t, jsonrpc2.RawFramer()) -} - -func TestConnectionHeader(t *testing.T) { - testConnection(t, jsonrpc2.HeaderFramer()) -} - -func testConnection(t *testing.T, framer jsonrpc2.Framer) { - ctx := context.Background() - listener, err := jsonrpc2.NetPipeListener(ctx) - if err != nil { - t.Fatal(err) - } - server := jsonrpc2.NewServer(ctx, listener, binder{framer, nil}) - defer func() { - listener.Close() - server.Wait() - }() - - for _, test := range callTests { - t.Run(test.Name(), func(t *testing.T) { - client, err := jsonrpc2.Dial(ctx, - listener.Dialer(), binder{framer, func(h *handler) { - // Sleep a little to a void a race with setting conn.writer in jsonrpc2.bindConnection. - time.Sleep(50 * time.Millisecond) - defer h.conn.Close() - test.Invoke(t, ctx, h) - if call, ok := test.(*call); ok { - // also run all simple call tests in echo mode - (*echo)(call).Invoke(t, ctx, h) - } - }}, nil) - if err != nil { - t.Fatal(err) - } - client.Wait() - }) - } -} - -func (test notify) Name() string { return test.method } -func (test notify) Invoke(t *testing.T, ctx context.Context, h *handler) { - if err := h.conn.Notify(ctx, test.method, test.params); err != nil { - t.Fatalf("%v:Notify failed: %v", test.method, err) - } -} - -func (test call) Name() string { return test.method } -func (test call) Invoke(t *testing.T, ctx context.Context, h *handler) { - results := newResults(test.expect) - if err := h.conn.Call(ctx, test.method, test.params).Await(ctx, results); err != nil { - t.Fatalf("%v:Call failed: %v", test.method, err) - } - verifyResults(t, test.method, results, test.expect) -} - -func (test echo) Invoke(t *testing.T, ctx context.Context, h *handler) { - results := newResults(test.expect) - if err := h.conn.Call(ctx, "echo", []any{test.method, test.params}).Await(ctx, results); err != nil { - t.Fatalf("%v:Echo failed: %v", test.method, err) - } - verifyResults(t, test.method, results, test.expect) -} - -func (test async) Name() string { return test.name } -func (test async) Invoke(t *testing.T, ctx context.Context, h *handler) { - h.calls[test.name] = h.conn.Call(ctx, test.method, test.params) -} - -func (test collect) Name() string { return test.name } -func (test collect) Invoke(t *testing.T, ctx context.Context, h *handler) { - o := h.calls[test.name] - results := newResults(test.expect) - err := o.Await(ctx, results) - switch { - case test.fails && err == nil: - t.Fatalf("%v:Collect was supposed to fail", test.name) - case !test.fails && err != nil: - t.Fatalf("%v:Collect failed: %v", test.name, err) - } - verifyResults(t, test.name, results, test.expect) -} - -func (test cancel) Name() string { return test.name } -func (test cancel) Invoke(t *testing.T, ctx context.Context, h *handler) { - o := h.calls[test.name] - if err := h.conn.Notify(ctx, "cancel", &cancelParams{o.ID().Raw().(int64)}); err != nil { - t.Fatalf("%v:Collect failed: %v", test.name, err) - } -} - -func (test sequence) Name() string { return test.name } -func (test sequence) Invoke(t *testing.T, ctx context.Context, h *handler) { - for _, child := range test.tests { - child.Invoke(t, ctx, h) - } -} - -// newResults makes a new empty copy of the expected type to put the results into -func newResults(expect any) any { - switch e := expect.(type) { - case []any: - var r []any - for _, v := range e { - r = append(r, reflect.New(reflect.TypeOf(v)).Interface()) - } - return r - case nil: - return nil - default: - return reflect.New(reflect.TypeOf(expect)).Interface() - } -} - -// verifyResults compares the results to the expected values -func verifyResults(t *testing.T, method string, results any, expect any) { - if expect == nil { - if results != nil { - t.Errorf("%v:Got results %+v where none expected", method, expect) - } - return - } - val := reflect.Indirect(reflect.ValueOf(results)).Interface() - if !reflect.DeepEqual(val, expect) { - t.Errorf("%v:Results are incorrect, got %+v expect %+v", method, val, expect) - } -} - -func (b binder) Bind(ctx context.Context, conn *jsonrpc2.Connection) jsonrpc2.ConnectionOptions { - h := &handler{ - conn: conn, - waiters: make(chan map[string]chan struct{}, 1), - calls: make(map[string]*jsonrpc2.AsyncCall), - } - h.waiters <- make(map[string]chan struct{}) - if b.runTest != nil { - go b.runTest(h) - } - return jsonrpc2.ConnectionOptions{ - Framer: b.framer, - Preempter: h, - Handler: h, - } -} - -func (h *handler) waiter(name string) chan struct{} { - waiters := <-h.waiters - defer func() { h.waiters <- waiters }() - waiter, found := waiters[name] - if !found { - waiter = make(chan struct{}) - waiters[name] = waiter - } - return waiter -} - -func (h *handler) Preempt(ctx context.Context, req *jsonrpc2.Request) (any, error) { - switch req.Method { - case "unblock": - var name string - if err := json.Unmarshal(req.Params, &name); err != nil { - return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err) - } - close(h.waiter(name)) - return nil, nil - case "peek": - if len(req.Params) > 0 { - return nil, fmt.Errorf("%w: expected no params", jsonrpc2.ErrInvalidParams) - } - return h.accumulator, nil - case "cancel": - var params cancelParams - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err) - } - h.conn.Cancel(jsonrpc2.Int64ID(params.ID)) - return nil, nil - default: - return nil, jsonrpc2.ErrNotHandled - } -} - -func (h *handler) Handle(ctx context.Context, req *jsonrpc2.Request) (any, error) { - switch req.Method { - case "no_args": - if len(req.Params) > 0 { - return nil, fmt.Errorf("%w: expected no params", jsonrpc2.ErrInvalidParams) - } - return true, nil - case "one_string": - var v string - if err := json.Unmarshal(req.Params, &v); err != nil { - return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err) - } - return "got:" + v, nil - case "one_number": - var v int - if err := json.Unmarshal(req.Params, &v); err != nil { - return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err) - } - return fmt.Sprintf("got:%d", v), nil - case "set": - var v int - if err := json.Unmarshal(req.Params, &v); err != nil { - return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err) - } - h.accumulator = v - return nil, nil - case "add": - var v int - if err := json.Unmarshal(req.Params, &v); err != nil { - return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err) - } - h.accumulator += v - return nil, nil - case "get": - if len(req.Params) > 0 { - return nil, fmt.Errorf("%w: expected no params", jsonrpc2.ErrInvalidParams) - } - return h.accumulator, nil - case "join": - var v []string - if err := json.Unmarshal(req.Params, &v); err != nil { - return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err) - } - return path.Join(v...), nil - case "echo": - var v []any - if err := json.Unmarshal(req.Params, &v); err != nil { - return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err) - } - var result any - err := h.conn.Call(ctx, v[0].(string), v[1]).Await(ctx, &result) - return result, err - case "wait": - var name string - if err := json.Unmarshal(req.Params, &name); err != nil { - return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err) - } - select { - case <-h.waiter(name): - return true, nil - case <-ctx.Done(): - return nil, ctx.Err() - } - case "fork": - var name string - if err := json.Unmarshal(req.Params, &name); err != nil { - return nil, fmt.Errorf("%w: %s", jsonrpc2.ErrParse, err) - } - jsonrpc2.Async(ctx) - waitFor := h.waiter(name) - select { - case <-waitFor: - return true, nil - case <-ctx.Done(): - return nil, ctx.Err() - } - default: - return nil, jsonrpc2.ErrNotHandled - } -} diff --git a/internal/jsonrpc2/net.go b/internal/jsonrpc2/net.go deleted file mode 100644 index 05db062..0000000 --- a/internal/jsonrpc2/net.go +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright 2018 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -package jsonrpc2 - -import ( - "context" - "io" - "net" - "os" -) - -// This file contains implementations of the transport primitives that use the standard network -// package. - -// NetListenOptions is the optional arguments to the NetListen function. -type NetListenOptions struct { - NetListenConfig net.ListenConfig - NetDialer net.Dialer -} - -// NetListener returns a new Listener that listens on a socket using the net package. -func NetListener(ctx context.Context, network, address string, options NetListenOptions) (Listener, error) { - ln, err := options.NetListenConfig.Listen(ctx, network, address) - if err != nil { - return nil, err - } - return &netListener{net: ln}, nil -} - -// netListener is the implementation of Listener for connections made using the net package. -type netListener struct { - net net.Listener -} - -// Accept blocks waiting for an incoming connection to the listener. -func (l *netListener) Accept(context.Context) (io.ReadWriteCloser, error) { - return l.net.Accept() -} - -// Close will cause the listener to stop listening. It will not close any connections that have -// already been accepted. -func (l *netListener) Close() error { - addr := l.net.Addr() - err := l.net.Close() - if addr.Network() == "unix" { - rerr := os.Remove(addr.String()) - if rerr != nil && err == nil { - err = rerr - } - } - return err -} - -// Dialer returns a dialer that can be used to connect to the listener. -func (l *netListener) Dialer() Dialer { - return NetDialer(l.net.Addr().Network(), l.net.Addr().String(), net.Dialer{}) -} - -// NetDialer returns a Dialer using the supplied standard network dialer. -func NetDialer(network, address string, nd net.Dialer) Dialer { - return &netDialer{ - network: network, - address: address, - dialer: nd, - } -} - -type netDialer struct { - network string - address string - dialer net.Dialer -} - -func (n *netDialer) Dial(ctx context.Context) (io.ReadWriteCloser, error) { - return n.dialer.DialContext(ctx, n.network, n.address) -} - -// NetPipeListener returns a new Listener that listens using net.Pipe. -// It is only possibly to connect to it using the Dialer returned by the -// Dialer method, each call to that method will generate a new pipe the other -// side of which will be returned from the Accept call. -func NetPipeListener(ctx context.Context) (Listener, error) { - return &netPiper{ - done: make(chan struct{}), - dialed: make(chan io.ReadWriteCloser), - }, nil -} - -// netPiper is the implementation of Listener build on top of net.Pipes. -type netPiper struct { - done chan struct{} - dialed chan io.ReadWriteCloser -} - -// Accept blocks waiting for an incoming connection to the listener. -func (l *netPiper) Accept(context.Context) (io.ReadWriteCloser, error) { - // Block until the pipe is dialed or the listener is closed, - // preferring the latter if already closed at the start of Accept. - select { - case <-l.done: - return nil, net.ErrClosed - default: - } - select { - case rwc := <-l.dialed: - return rwc, nil - case <-l.done: - return nil, net.ErrClosed - } -} - -// Close will cause the listener to stop listening. It will not close any connections that have -// already been accepted. -func (l *netPiper) Close() error { - // unblock any accept calls that are pending - close(l.done) - return nil -} - -func (l *netPiper) Dialer() Dialer { - return l -} - -func (l *netPiper) Dial(ctx context.Context) (io.ReadWriteCloser, error) { - client, server := net.Pipe() - - select { - case l.dialed <- server: - return client, nil - - case <-l.done: - client.Close() - server.Close() - return nil, net.ErrClosed - } -} diff --git a/internal/jsonrpc2/serve.go b/internal/jsonrpc2/serve.go deleted file mode 100644 index 424163a..0000000 --- a/internal/jsonrpc2/serve.go +++ /dev/null @@ -1,330 +0,0 @@ -// Copyright 2020 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -package jsonrpc2 - -import ( - "context" - "fmt" - "io" - "runtime" - "sync" - "sync/atomic" - "time" -) - -// Listener is implemented by protocols to accept new inbound connections. -type Listener interface { - // Accept accepts an inbound connection to a server. - // It blocks until either an inbound connection is made, or the listener is closed. - Accept(context.Context) (io.ReadWriteCloser, error) - - // Close closes the listener. - // Any blocked Accept or Dial operations will unblock and return errors. - Close() error - - // Dialer returns a dialer that can be used to connect to this listener - // locally. - // If a listener does not implement this it will return nil. - Dialer() Dialer -} - -// Dialer is used by clients to dial a server. -type Dialer interface { - // Dial returns a new communication byte stream to a listening server. - Dial(ctx context.Context) (io.ReadWriteCloser, error) -} - -// Server is a running server that is accepting incoming connections. -type Server struct { - listener Listener - binder Binder - async *async - - shutdownOnce sync.Once - closing int32 // atomic: set to nonzero when Shutdown is called -} - -// Dial uses the dialer to make a new connection, wraps the returned -// reader and writer using the framer to make a stream, and then builds -// a connection on top of that stream using the binder. -// -// The returned Connection will operate independently using the Preempter and/or -// Handler provided by the Binder, and will release its own resources when the -// connection is broken, but the caller may Close it earlier to stop accepting -// (or sending) new requests. -// -// If non-nil, the onDone function is called when the connection is closed. -func Dial(ctx context.Context, dialer Dialer, binder Binder, onDone func()) (*Connection, error) { - // dial a server - rwc, err := dialer.Dial(ctx) - if err != nil { - return nil, err - } - return bindConnection(ctx, rwc, binder, onDone), nil -} - -// NewServer starts a new server listening for incoming connections and returns -// it. -// This returns a fully running and connected server, it does not block on -// the listener. -// You can call Wait to block on the server, or Shutdown to get the sever to -// terminate gracefully. -// To notice incoming connections, use an intercepting Binder. -func NewServer(ctx context.Context, listener Listener, binder Binder) *Server { - server := &Server{ - listener: listener, - binder: binder, - async: newAsync(), - } - go server.run(ctx) - return server -} - -// Wait returns only when the server has shut down. -func (s *Server) Wait() error { - return s.async.wait() -} - -// Shutdown informs the server to stop accepting new connections. -func (s *Server) Shutdown() { - s.shutdownOnce.Do(func() { - atomic.StoreInt32(&s.closing, 1) - s.listener.Close() - }) -} - -// run accepts incoming connections from the listener, -// If IdleTimeout is non-zero, run exits after there are no clients for this -// duration, otherwise it exits only on error. -func (s *Server) run(ctx context.Context) { - defer s.async.done() - - var activeConns sync.WaitGroup - for { - rwc, err := s.listener.Accept(ctx) - if err != nil { - // Only Shutdown closes the listener. If we get an error after Shutdown is - // called, assume that was the cause and don't report the error; - // otherwise, report the error in case it is unexpected. - if atomic.LoadInt32(&s.closing) == 0 { - s.async.setError(err) - } - // We are done generating new connections for good. - break - } - - // A new inbound connection. - activeConns.Add(1) - _ = bindConnection(ctx, rwc, s.binder, activeConns.Done) // unregisters itself when done - } - activeConns.Wait() -} - -// NewIdleListener wraps a listener with an idle timeout. -// -// When there are no active connections for at least the timeout duration, -// calls to Accept will fail with ErrIdleTimeout. -// -// A connection is considered inactive as soon as its Close method is called. -func NewIdleListener(timeout time.Duration, wrap Listener) Listener { - l := &idleListener{ - wrapped: wrap, - timeout: timeout, - active: make(chan int, 1), - timedOut: make(chan struct{}), - idleTimer: make(chan *time.Timer, 1), - } - l.idleTimer <- time.AfterFunc(l.timeout, l.timerExpired) - return l -} - -type idleListener struct { - wrapped Listener - timeout time.Duration - - // Only one of these channels is receivable at any given time. - active chan int // count of active connections; closed when Close is called if not timed out - timedOut chan struct{} // closed when the idle timer expires - idleTimer chan *time.Timer // holds the timer only when idle -} - -// Accept accepts an incoming connection. -// -// If an incoming connection is accepted concurrent to the listener being closed -// due to idleness, the new connection is immediately closed. -func (l *idleListener) Accept(ctx context.Context) (io.ReadWriteCloser, error) { - rwc, err := l.wrapped.Accept(ctx) - - select { - case n, ok := <-l.active: - if err != nil { - if ok { - l.active <- n - } - return nil, err - } - if ok { - l.active <- n + 1 - } else { - // l.wrapped.Close Close has been called, but Accept returned a - // connection. This race can occur with concurrent Accept and Close calls - // with any net.Listener, and it is benign: since the listener was closed - // explicitly, it can't have also timed out. - } - return l.newConn(rwc), nil - - case <-l.timedOut: - if err == nil { - // Keeping the connection open would leave the listener simultaneously - // active and closed due to idleness, which would be contradictory and - // confusing. Close the connection and pretend that it never happened. - rwc.Close() - } else { - // In theory the timeout could have raced with an unrelated error return - // from Accept. However, ErrIdleTimeout is arguably still valid (since we - // would have closed due to the timeout independent of the error), and the - // harm from returning a spurious ErrIdleTimeout is negligible anyway. - } - return nil, ErrIdleTimeout - - case timer := <-l.idleTimer: - if err != nil { - // The idle timer doesn't run until it receives itself from the idleTimer - // channel, so it can't have called l.wrapped.Close yet and thus err can't - // be ErrIdleTimeout. Leave the idle timer as it was and return whatever - // error we got. - l.idleTimer <- timer - return nil, err - } - - if !timer.Stop() { - // Failed to stop the timer — the timer goroutine is in the process of - // firing. Send the timer back to the timer goroutine so that it can - // safely close the timedOut channel, and then wait for the listener to - // actually be closed before we return ErrIdleTimeout. - l.idleTimer <- timer - rwc.Close() - <-l.timedOut - return nil, ErrIdleTimeout - } - - l.active <- 1 - return l.newConn(rwc), nil - } -} - -func (l *idleListener) Close() error { - select { - case _, ok := <-l.active: - if ok { - close(l.active) - } - - case <-l.timedOut: - // Already closed by the timer; take care not to double-close if the caller - // only explicitly invokes this Close method once, since the io.Closer - // interface explicitly leaves doubled Close calls undefined. - return ErrIdleTimeout - - case timer := <-l.idleTimer: - if !timer.Stop() { - // Couldn't stop the timer. It shouldn't take long to run, so just wait - // (so that the Listener is guaranteed to be closed before we return) - // and pretend that this call happened afterward. - // That way we won't leak any timers or goroutines when Close returns. - l.idleTimer <- timer - <-l.timedOut - return ErrIdleTimeout - } - close(l.active) - } - - return l.wrapped.Close() -} - -func (l *idleListener) Dialer() Dialer { - return l.wrapped.Dialer() -} - -func (l *idleListener) timerExpired() { - select { - case n, ok := <-l.active: - if ok { - panic(fmt.Sprintf("jsonrpc2: idleListener idle timer fired with %d connections still active", n)) - } else { - panic("jsonrpc2: Close finished with idle timer still running") - } - - case <-l.timedOut: - panic("jsonrpc2: idleListener idle timer fired more than once") - - case <-l.idleTimer: - // The timer for this very call! - } - - // Close the Listener with all channels still blocked to ensure that this call - // to l.wrapped.Close doesn't race with the one in l.Close. - defer close(l.timedOut) - l.wrapped.Close() -} - -func (l *idleListener) connClosed() { - select { - case n, ok := <-l.active: - if !ok { - // l is already closed, so it can't close due to idleness, - // and we don't need to track the number of active connections any more. - return - } - n-- - if n == 0 { - l.idleTimer <- time.AfterFunc(l.timeout, l.timerExpired) - } else { - l.active <- n - } - - case <-l.timedOut: - panic("jsonrpc2: idleListener idle timer fired before last active connection was closed") - - case <-l.idleTimer: - panic("jsonrpc2: idleListener idle timer active before last active connection was closed") - } -} - -type idleListenerConn struct { - wrapped io.ReadWriteCloser - l *idleListener - closeOnce sync.Once -} - -func (l *idleListener) newConn(rwc io.ReadWriteCloser) *idleListenerConn { - c := &idleListenerConn{ - wrapped: rwc, - l: l, - } - - // A caller that forgets to call Close may disrupt the idleListener's - // accounting, even though the file descriptor for the underlying connection - // may eventually be garbage-collected anyway. - // - // Set a (best-effort) finalizer to verify that a Close call always occurs. - // (We will clear the finalizer explicitly in Close.) - runtime.SetFinalizer(c, func(c *idleListenerConn) { - panic("jsonrpc2: IdleListener connection became unreachable without a call to Close") - }) - - return c -} - -func (c *idleListenerConn) Read(p []byte) (int, error) { return c.wrapped.Read(p) } -func (c *idleListenerConn) Write(p []byte) (int, error) { return c.wrapped.Write(p) } - -func (c *idleListenerConn) Close() error { - defer c.closeOnce.Do(func() { - c.l.connClosed() - runtime.SetFinalizer(c, nil) - }) - return c.wrapped.Close() -} diff --git a/internal/jsonrpc2/serve_test.go b/internal/jsonrpc2/serve_test.go deleted file mode 100644 index 282a684..0000000 --- a/internal/jsonrpc2/serve_test.go +++ /dev/null @@ -1,354 +0,0 @@ -// Copyright 2020 The Go MCP SDK Authors. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -package jsonrpc2_test - -import ( - "context" - "errors" - "fmt" - "runtime" - "runtime/debug" - "testing" - "time" - - "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" -) - -// needsLocalhostNet skips t if networking does not work for ports opened -// with "localhost". -// forked from golang.org/x/tools/internal/testenv. -func needsLocalhostNet(t testing.TB) { - switch runtime.GOOS { - case "js", "wasip1": - t.Skipf(`Listening on "localhost" fails on %s; see https://go.dev/issue/59718`, runtime.GOOS) - } -} - -func TestIdleTimeout(t *testing.T) { - needsLocalhostNet(t) - - // Use a panicking time.AfterFunc instead of context.WithTimeout so that we - // get a goroutine dump on failure. We expect the test to take on the order of - // a few tens of milliseconds at most, so 10s should be several orders of - // magnitude of headroom. - timer := time.AfterFunc(10*time.Second, func() { - debug.SetTraceback("all") - panic("TestIdleTimeout deadlocked") - }) - defer timer.Stop() - - ctx := context.Background() - - try := func(d time.Duration) (longEnough bool) { - listener, err := jsonrpc2.NetListener(ctx, "tcp", "localhost:0", jsonrpc2.NetListenOptions{}) - if err != nil { - t.Fatal(err) - } - - idleStart := time.Now() - listener = jsonrpc2.NewIdleListener(d, listener) - defer listener.Close() - - server := jsonrpc2.NewServer(ctx, listener, jsonrpc2.ConnectionOptions{}) - - // Exercise some connection/disconnection patterns, and then assert that when - // our timer fires, the server exits. - conn1, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}, nil) - if err != nil { - if since := time.Since(idleStart); since < d { - t.Fatalf("conn1 failed to connect after %v: %v", since, err) - } - t.Log("jsonrpc2.Dial:", err) - return false // Took to long to dial, so the failure could have been due to the idle timeout. - } - // On the server side, Accept can race with the connection timing out. - // Send a call and wait for the response to ensure that the connection was - // actually fully accepted. - ac := conn1.Call(ctx, "ping", nil) - if err := ac.Await(ctx, nil); !errors.Is(err, jsonrpc2.ErrMethodNotFound) { - if since := time.Since(idleStart); since < d { - t.Fatalf("conn1 broken after %v: %v", since, err) - } - t.Log(`conn1.Call(ctx, "ping", nil):`, err) - conn1.Close() - return false - } - - // Since conn1 was successfully accepted and remains open, the server is - // definitely non-idle. Dialing another simultaneous connection should - // succeed. - conn2, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}, nil) - if err != nil { - conn1.Close() - t.Fatalf("conn2 failed to connect while non-idle after %v: %v", time.Since(idleStart), err) - return false - } - // Ensure that conn2 is also accepted on the server side before we close - // conn1. Otherwise, the connection can appear idle if the server processes - // the closure of conn1 and the idle timeout before it finally notices conn2 - // in the accept queue. - // (That failure mode may explain the failure noted in - // https://go.dev/issue/49387#issuecomment-1303979877.) - ac = conn2.Call(ctx, "ping", nil) - if err := ac.Await(ctx, nil); !errors.Is(err, jsonrpc2.ErrMethodNotFound) { - t.Fatalf("conn2 broken while non-idle after %v: %v", time.Since(idleStart), err) - } - - if err := conn1.Close(); err != nil { - t.Fatalf("conn1.Close failed with error: %v", err) - } - idleStart = time.Now() - if err := conn2.Close(); err != nil { - t.Fatalf("conn2.Close failed with error: %v", err) - } - - conn3, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}, nil) - if err != nil { - if since := time.Since(idleStart); since < d { - t.Fatalf("conn3 failed to connect after %v: %v", since, err) - } - t.Log("jsonrpc2.Dial:", err) - return false // Took to long to dial, so the failure could have been due to the idle timeout. - } - - ac = conn3.Call(ctx, "ping", nil) - if err := ac.Await(ctx, nil); !errors.Is(err, jsonrpc2.ErrMethodNotFound) { - if since := time.Since(idleStart); since < d { - t.Fatalf("conn3 broken after %v: %v", since, err) - } - t.Log(`conn3.Call(ctx, "ping", nil):`, err) - conn3.Close() - return false - } - - idleStart = time.Now() - if err := conn3.Close(); err != nil { - t.Fatalf("conn3.Close failed with error: %v", err) - } - - serverError := server.Wait() - - if !errors.Is(serverError, jsonrpc2.ErrIdleTimeout) { - t.Errorf("run() returned error %v, want %v", serverError, jsonrpc2.ErrIdleTimeout) - } - if since := time.Since(idleStart); since < d { - t.Errorf("server shut down after %v idle; want at least %v", since, d) - } - return true - } - - d := 1 * time.Millisecond - for { - t.Logf("testing with idle timeout %v", d) - if !try(d) { - d *= 2 - continue - } - break - } -} - -type msg struct { - Msg string -} - -type fakeHandler struct{} - -func (fakeHandler) Handle(ctx context.Context, req *jsonrpc2.Request) (any, error) { - switch req.Method { - case "ping": - return &msg{"pong"}, nil - default: - return nil, jsonrpc2.ErrNotHandled - } -} - -func TestServe(t *testing.T) { - ctx := context.Background() - - tests := []struct { - name string - factory func(context.Context, testing.TB) (jsonrpc2.Listener, error) - }{ - {"tcp", func(ctx context.Context, t testing.TB) (jsonrpc2.Listener, error) { - needsLocalhostNet(t) - return jsonrpc2.NetListener(ctx, "tcp", "localhost:0", jsonrpc2.NetListenOptions{}) - }}, - {"pipe", func(ctx context.Context, t testing.TB) (jsonrpc2.Listener, error) { - return jsonrpc2.NetPipeListener(ctx) - }}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - fake, err := test.factory(ctx, t) - if err != nil { - t.Fatal(err) - } - conn, shutdown, err := newFake(t, ctx, fake) - if err != nil { - t.Fatal(err) - } - defer shutdown() - var got msg - if err := conn.Call(ctx, "ping", &msg{"ting"}).Await(ctx, &got); err != nil { - t.Fatal(err) - } - if want := "pong"; got.Msg != want { - t.Errorf("conn.Call(...): returned %q, want %q", got, want) - } - }) - } -} - -func newFake(t *testing.T, ctx context.Context, l jsonrpc2.Listener) (*jsonrpc2.Connection, func(), error) { - server := jsonrpc2.NewServer(ctx, l, jsonrpc2.ConnectionOptions{ - Handler: fakeHandler{}, - }) - - client, err := jsonrpc2.Dial(ctx, - l.Dialer(), - jsonrpc2.ConnectionOptions{ - Handler: fakeHandler{}, - }, nil) - if err != nil { - return nil, nil, err - } - return client, func() { - if err := l.Close(); err != nil { - t.Fatal(err) - } - if err := client.Close(); err != nil { - t.Fatal(err) - } - server.Wait() - }, nil -} - -// TestIdleListenerAcceptCloseRace checks for the Accept/Close race fixed in CL 388597. -// -// (A bug in the idleListener implementation caused a successful Accept to block -// on sending to a background goroutine that could have already exited.) -func TestIdleListenerAcceptCloseRace(t *testing.T) { - ctx := context.Background() - - n := 10 - - // Each iteration of the loop appears to take around a millisecond, so to - // avoid spurious failures we'll set the watchdog for three orders of - // magnitude longer. When the bug was present, this reproduced the deadlock - // reliably on a Linux workstation when run with -count=100, which should be - // frequent enough to show up on the Go build dashboard if it regresses. - watchdog := time.Duration(n) * 1000 * time.Millisecond - timer := time.AfterFunc(watchdog, func() { - debug.SetTraceback("all") - panic(fmt.Sprintf("%s deadlocked after %v", t.Name(), watchdog)) - }) - defer timer.Stop() - - for ; n > 0; n-- { - listener, err := jsonrpc2.NetPipeListener(ctx) - if err != nil { - t.Fatal(err) - } - listener = jsonrpc2.NewIdleListener(24*time.Hour, listener) - - done := make(chan struct{}) - go func() { - conn, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}, nil) - listener.Close() - if err == nil { - conn.Close() - } - close(done) - }() - - // Accept may return a non-nil error if Close closes the underlying network - // connection before the wrapped Accept call unblocks. However, it must not - // deadlock! - c, err := listener.Accept(ctx) - if err == nil { - c.Close() - } - <-done - } -} - -// TestCloseCallRace checks for a race resulting in a deadlock when a Call on -// one side of the connection races with a Close (or otherwise broken -// connection) initiated from the other side. -// -// (The Call method was waiting for a result from the Read goroutine to -// determine which error value to return, but the Read goroutine was waiting for -// in-flight calls to complete before reporting that result.) -func TestCloseCallRace(t *testing.T) { - ctx := context.Background() - n := 10 - - watchdog := time.Duration(n) * 1000 * time.Millisecond - timer := time.AfterFunc(watchdog, func() { - debug.SetTraceback("all") - panic(fmt.Sprintf("%s deadlocked after %v", t.Name(), watchdog)) - }) - defer timer.Stop() - - for ; n > 0; n-- { - listener, err := jsonrpc2.NetPipeListener(ctx) - if err != nil { - t.Fatal(err) - } - - pokec := make(chan *jsonrpc2.AsyncCall, 1) - - s := jsonrpc2.NewServer(ctx, listener, jsonrpc2.BinderFunc(func(_ context.Context, srvConn *jsonrpc2.Connection) jsonrpc2.ConnectionOptions { - h := jsonrpc2.HandlerFunc(func(ctx context.Context, _ *jsonrpc2.Request) (any, error) { - // Start a concurrent call from the server to the client. - // The point of this test is to ensure this doesn't deadlock - // if the client shuts down the connection concurrently. - // - // The racing Call may or may not receive a response: it should get a - // response if it is sent before the client closes the connection, and - // it should fail with some kind of "connection closed" error otherwise. - go func() { - pokec <- srvConn.Call(ctx, "poke", nil) - }() - - return &msg{"pong"}, nil - }) - return jsonrpc2.ConnectionOptions{Handler: h} - })) - - dialConn, err := jsonrpc2.Dial(ctx, listener.Dialer(), jsonrpc2.ConnectionOptions{}, nil) - if err != nil { - listener.Close() - s.Wait() - t.Fatal(err) - } - - // Calling any method on the server should provoke it to asynchronously call - // us back. While it is starting that call, we will close the connection. - if err := dialConn.Call(ctx, "ping", nil).Await(ctx, nil); err != nil { - t.Error(err) - } - if err := dialConn.Close(); err != nil { - t.Error(err) - } - - // Ensure that the Call on the server side did not block forever when the - // connection closed. - pokeCall := <-pokec - if err := pokeCall.Await(ctx, nil); err == nil { - t.Errorf("unexpected nil error from server-initited call") - } else if errors.Is(err, jsonrpc2.ErrMethodNotFound) { - // The call completed before the Close reached the handler. - } else { - // The error was something else. - t.Logf("server-initiated call completed with expected error: %v", err) - } - - listener.Close() - s.Wait() - } -} diff --git a/internal/jsonrpc2/wire.go b/internal/jsonrpc2/wire.go index c0a41bf..4d123f2 100644 --- a/internal/jsonrpc2/wire.go +++ b/internal/jsonrpc2/wire.go @@ -28,9 +28,6 @@ var ( // The following errors are not part of the json specification, but // compliant extensions specific to this implementation. - // ErrServerOverloaded is returned when a message was refused due to a - // server being temporarily unable to accept any new messages. - ErrServerOverloaded = NewError(-32000, "overloaded") // ErrUnknown should be used for all non coded errors. ErrUnknown = NewError(-32001, "unknown error") // ErrServerClosing is returned for calls that arrive while the server is closing. From e01639a31808e27b9a77f02367340569f47e4b3d Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Wed, 29 Apr 2026 17:33:39 +0200 Subject: [PATCH 264/418] feat: HTTP Header Standardization for method and name (#907) ## Description Implements SEP-2243 (https://modelcontextprotocol.io/seps/2243-http-standardization) (HTTP Header Standardization) for Mcp-Method and Mcp-Name standard headers (x-mcp-param custom headers will be covvered in separate PR). Fixes #905 --- mcp/shared.go | 4 + mcp/streamable.go | 28 ++- mcp/streamable_client_test.go | 2 +- mcp/streamable_headers.go | 98 ++++++++ mcp/streamable_headers_test.go | 415 +++++++++++++++++++++++++++++++++ mcp/streamable_test.go | 261 ++++++++++++++++++++- 6 files changed, 798 insertions(+), 10 deletions(-) create mode 100644 mcp/streamable_headers.go create mode 100644 mcp/streamable_headers_test.go diff --git a/mcp/shared.go b/mcp/shared.go index cc28de8..b5b09db 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -36,6 +36,7 @@ const ( // It is the version that the client sends in the initialization request, and // the default version used by the server. latestProtocolVersion = protocolVersion20251125 + protocolVersion20260630 = "2026-06-30" protocolVersion20251125 = "2025-11-25" protocolVersion20250618 = "2025-06-18" protocolVersion20250326 = "2025-03-26" @@ -343,6 +344,9 @@ func clientSessionMethod[P Params, R Result](f func(*ClientSession, context.Cont // MCP-specific error codes. const ( + // CodeHeaderMismatch indicates that HTTP headers do not match the corresponding values + // in the request body, or that required headers are missing or malformed. + CodeHeaderMismatch = -32001 // CodeResourceNotFound indicates that a requested resource could not be found. CodeResourceNotFound = -32002 // CodeURLElicitationRequired indicates that the server requires URL elicitation diff --git a/mcp/streamable.go b/mcp/streamable.go index a176789..4470954 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -39,12 +39,6 @@ import ( "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) -const ( - protocolVersionHeader = "Mcp-Protocol-Version" - sessionIDHeader = "Mcp-Session-Id" - lastEventIDHeader = "Last-Event-ID" -) - // A StreamableHTTPHandler is an http.Handler that serves streamable MCP // sessions, as defined by the [MCP spec]. // @@ -1191,6 +1185,24 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques } } + // Validate MCP standard headers (Mcp-Method, Mcp-Name) + if !isBatch && len(incoming) == 1 { + if err := validateMcpHeaders(req.Header, incoming[0]); err != nil { + resp := &jsonrpc.Response{ + Error: jsonrpc2.NewError(CodeHeaderMismatch, err.Error()), + } + if jreq, ok := incoming[0].(*jsonrpc.Request); ok { + resp.ID = jreq.ID + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + if data, err := jsonrpc2.EncodeMessage(resp); err == nil { + w.Write(data) + } + return + } + } + // The prime and close events were added in protocol version 2025-11-25 (SEP-1699). // Use the version from InitializeParams if this is an initialize request, // otherwise use the protocol version header. @@ -1797,6 +1809,9 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e // and permanently break the connection. return nil, nil, fmt.Errorf("%s: %w: %w", requestSummary, jsonrpc2.ErrRejected, err) } + // Keep this after the setMCPHeaders call to ensure that the + // protocol version header is set. + setStandardHeaders(req.Header, msg) resp, err := c.client.Do(req) if err != nil { // Any error from client.Do means the request didn't reach the server. @@ -1932,6 +1947,7 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request) error { if c.sessionID != "" { req.Header.Set(sessionIDHeader, c.sessionID) } + return nil } diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 9aac7b5..96c5e75 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -70,7 +70,7 @@ func (s *fakeStreamableServer) ServeHTTP(w http.ResponseWriter, req *http.Reques key := streamableRequestKey{ httpMethod: req.Method, sessionID: req.Header.Get(sessionIDHeader), - lastEventID: req.Header.Get("Last-Event-ID"), // TODO: extract this to a constant, like sessionIDHeader + lastEventID: req.Header.Get(lastEventIDHeader), } var jsonrpcReq *jsonrpc.Request if req.Method == http.MethodPost { diff --git a/mcp/streamable_headers.go b/mcp/streamable_headers.go new file mode 100644 index 0000000..ac9a412 --- /dev/null +++ b/mcp/streamable_headers.go @@ -0,0 +1,98 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package mcp + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + + internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" +) + +const ( + protocolVersionHeader = "Mcp-Protocol-Version" + sessionIDHeader = "Mcp-Session-Id" + lastEventIDHeader = "Last-Event-ID" + methodHeader = "Mcp-Method" + nameHeader = "Mcp-Name" + minVersionForStandardHeaders = protocolVersion20260630 +) + +func extractName(method string, params json.RawMessage) (string, bool) { + switch method { + case "tools/call": + var p CallToolParams + if err := internaljson.Unmarshal(params, &p); err == nil { + return p.Name, true + } + case "prompts/get": + var p GetPromptParams + if err := internaljson.Unmarshal(params, &p); err == nil { + return p.Name, true + } + case "resources/read": + var p ReadResourceParams + if err := internaljson.Unmarshal(params, &p); err == nil { + return p.URI, true + } + } + + return "", false +} + +// setStandardHeaders populates standard MCP headers. +// It requires the protocol version header to be set. +func setStandardHeaders(header http.Header, msg jsonrpc.Message) { + if msg == nil { + return + } + if header.Get(protocolVersionHeader) == "" || header.Get(protocolVersionHeader) < minVersionForStandardHeaders { + return + } + + switch msg := msg.(type) { + case *jsonrpc.Request: + header.Set(methodHeader, msg.Method) + if name, ok := extractName(msg.Method, msg.Params); ok { + header.Set(nameHeader, name) + } + } +} + +func validateMcpHeaders(header http.Header, msg jsonrpc.Message) error { + protocolVersion := header.Get(protocolVersionHeader) + if protocolVersion == "" || protocolVersion < minVersionForStandardHeaders { + return nil + } + + switch msg := msg.(type) { + case *jsonrpc.Request: + methodInHeader := header.Get(methodHeader) + if methodInHeader == "" { + return errors.New("missing required Mcp-Method header") + } + if methodInHeader != msg.Method { + return fmt.Errorf("header mismatch: Mcp-Method header value '%s' does not match body value '%s'", methodInHeader, msg.Method) + } + + if msg.Method == "tools/call" || msg.Method == "resources/read" || msg.Method == "prompts/get" { + nameInHeader := header.Get(nameHeader) + if nameInHeader == "" { + return fmt.Errorf("missing required Mcp-Name header for method %q", msg.Method) + } + nameInBody, ok := extractName(msg.Method, msg.Params) + if !ok { + return fmt.Errorf("failed to extract name from parameters for method %q", msg.Method) + } + if nameInHeader != nameInBody { + return fmt.Errorf("header mismatch: Mcp-Name header value '%s' does not match body value '%s'", nameInHeader, nameInBody) + } + } + } + return nil +} diff --git a/mcp/streamable_headers_test.go b/mcp/streamable_headers_test.go new file mode 100644 index 0000000..0abf1b2 --- /dev/null +++ b/mcp/streamable_headers_test.go @@ -0,0 +1,415 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package mcp + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/jsonrpc" +) + +func TestExtractName(t *testing.T) { + tests := []struct { + name string + method string + params json.RawMessage + wantName string + wantOK bool + }{ + { + name: "tools/call", + method: "tools/call", + params: mustMarshal(&CallToolParams{Name: "my-tool"}), + wantName: "my-tool", + wantOK: true, + }, + { + name: "prompts/get", + method: "prompts/get", + params: mustMarshal(&GetPromptParams{Name: "code_review"}), + wantName: "code_review", + wantOK: true, + }, + { + name: "resources/read", + method: "resources/read", + params: mustMarshal(&ReadResourceParams{URI: "file:///info.txt"}), + wantName: "file:///info.txt", + wantOK: true, + }, + { + name: "tools/call with empty name", + method: "tools/call", + params: mustMarshal(&CallToolParams{Name: ""}), + wantName: "", + wantOK: true, + }, + { + name: "tool name with hyphen", + method: "tools/call", + params: mustMarshal(&CallToolParams{Name: "my-tool-v2"}), + wantName: "my-tool-v2", + wantOK: true, + }, + { + name: "tool name with underscore", + method: "tools/call", + params: mustMarshal(&CallToolParams{Name: "my_tool_name"}), + wantName: "my_tool_name", + wantOK: true, + }, + { + name: "resource URI with special chars", + method: "resources/read", + params: mustMarshal(&ReadResourceParams{URI: "file:///path/to/file%20name.txt"}), + wantName: "file:///path/to/file%20name.txt", + wantOK: true, + }, + { + name: "resource URI with query string", + method: "resources/read", + params: mustMarshal(&ReadResourceParams{URI: "https://example.com/resource?id=123"}), + wantName: "https://example.com/resource?id=123", + wantOK: true, + }, + { + name: "unrelated method", + method: "initialize", + params: mustMarshal(&InitializeParams{ProtocolVersion: "2025-06-18"}), + wantName: "", + wantOK: false, + }, + { + name: "notification method", + method: "notifications/initialized", + params: nil, + wantName: "", + wantOK: false, + }, + { + name: "invalid JSON params", + method: "tools/call", + params: []byte("not json"), + wantName: "", + wantOK: false, + }, + { + name: "nil params", + method: "tools/call", + params: nil, + wantName: "", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotName, gotOK := extractName(tt.method, tt.params) + if gotName != tt.wantName || gotOK != tt.wantOK { + t.Errorf("extractName(%q, ...) = (%q, %v), want (%q, %v)", + tt.method, gotName, gotOK, tt.wantName, tt.wantOK) + } + }) + } +} + +func TestSetStandardHeaders(t *testing.T) { + tests := []struct { + name string + protocolVersion string + msg jsonrpc.Message + wantMethodHeader string + wantNameHeader string + }{ + { + name: "tools/call with future version", + protocolVersion: minVersionForStandardHeaders, + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})}, + wantMethodHeader: "tools/call", + wantNameHeader: "my-tool", + }, + { + name: "prompts/get with future version", + protocolVersion: minVersionForStandardHeaders, + msg: &jsonrpc.Request{Method: "prompts/get", Params: mustMarshal(&GetPromptParams{Name: "code_review"})}, + wantMethodHeader: "prompts/get", + wantNameHeader: "code_review", + }, + { + name: "resources/read with future version", + protocolVersion: minVersionForStandardHeaders, + msg: &jsonrpc.Request{Method: "resources/read", Params: mustMarshal(&ReadResourceParams{URI: "file:///info.txt"})}, + wantMethodHeader: "resources/read", + wantNameHeader: "file:///info.txt", + }, + { + name: "initialize sets method only", + protocolVersion: minVersionForStandardHeaders, + msg: &jsonrpc.Request{Method: "initialize", Params: mustMarshal(&InitializeParams{ProtocolVersion: minVersionForStandardHeaders})}, + wantMethodHeader: "initialize", + wantNameHeader: "", + }, + { + name: "notification sets method only", + protocolVersion: minVersionForStandardHeaders, + msg: &jsonrpc.Request{Method: "notifications/initialized"}, + wantMethodHeader: "notifications/initialized", + wantNameHeader: "", + }, + { + name: "old version skips all headers", + protocolVersion: protocolVersion20251125, + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})}, + wantMethodHeader: "", + wantNameHeader: "", + }, + { + name: "empty version skips all headers", + protocolVersion: "", + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})}, + wantMethodHeader: "", + wantNameHeader: "", + }, + { + name: "nil message is a no-op", + protocolVersion: minVersionForStandardHeaders, + msg: nil, + wantMethodHeader: "", + wantNameHeader: "", + }, + { + name: "response message is ignored", + protocolVersion: minVersionForStandardHeaders, + msg: &jsonrpc.Response{}, + wantMethodHeader: "", + wantNameHeader: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + header := http.Header{} + if tt.protocolVersion != "" { + header.Set(protocolVersionHeader, tt.protocolVersion) + } + + setStandardHeaders(header, tt.msg) + + if got := header.Get(methodHeader); got != tt.wantMethodHeader { + t.Errorf("MethodHeader = %q, want %q", got, tt.wantMethodHeader) + } + if got := header.Get(nameHeader); got != tt.wantNameHeader { + t.Errorf("NameHeader = %q, want %q", got, tt.wantNameHeader) + } + }) + } +} + +func TestValidateMcpHeaders(t *testing.T) { + + tests := []struct { + name string + version string + methodHeader string + nameHeader string + msg jsonrpc.Message + wantErr bool + wantErrContain string + }{ + { + name: "old version skips validation", + version: protocolVersion20251125, + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})}, + wantErr: false, + }, + { + name: "empty version skips validation", + version: "", + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})}, + wantErr: false, + }, + { + name: "missing Mcp-Method header", + version: minVersionForStandardHeaders, + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})}, + wantErr: true, + wantErrContain: "missing required Mcp-Method header", + }, + { + name: "missing Mcp-Name for tools/call", + version: minVersionForStandardHeaders, + methodHeader: "tools/call", + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})}, + wantErr: true, + wantErrContain: "missing required Mcp-Name header", + }, + { + name: "missing Mcp-Name for resources/read", + version: minVersionForStandardHeaders, + methodHeader: "resources/read", + msg: &jsonrpc.Request{Method: "resources/read", Params: mustMarshal(&ReadResourceParams{URI: "file:///info.txt"})}, + wantErr: true, + wantErrContain: "missing required Mcp-Name header", + }, + { + name: "missing Mcp-Name for prompts/get", + version: minVersionForStandardHeaders, + methodHeader: "prompts/get", + msg: &jsonrpc.Request{Method: "prompts/get", Params: mustMarshal(&GetPromptParams{Name: "review"})}, + wantErr: true, + wantErrContain: "missing required Mcp-Name header", + }, + { + name: "method mismatch", + version: minVersionForStandardHeaders, + methodHeader: "tools/call", + msg: &jsonrpc.Request{Method: "prompts/get", Params: mustMarshal(&GetPromptParams{Name: "review"})}, + wantErr: true, + wantErrContain: "Mcp-Method header value 'tools/call' does not match body value 'prompts/get'", + }, + { + name: "tool name mismatch", + version: minVersionForStandardHeaders, + methodHeader: "tools/call", + nameHeader: "wrong-tool", + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "right-tool"})}, + wantErr: true, + wantErrContain: "Mcp-Name header value 'wrong-tool' does not match body value 'right-tool'", + }, + { + name: "resource URI mismatch", + version: minVersionForStandardHeaders, + methodHeader: "resources/read", + nameHeader: "file:///wrong.txt", + msg: &jsonrpc.Request{Method: "resources/read", Params: mustMarshal(&ReadResourceParams{URI: "file:///right.txt"})}, + wantErr: true, + wantErrContain: "Mcp-Name header value 'file:///wrong.txt' does not match body value 'file:///right.txt'", + }, + { + name: "prompt name mismatch", + version: minVersionForStandardHeaders, + methodHeader: "prompts/get", + nameHeader: "wrong-prompt", + msg: &jsonrpc.Request{Method: "prompts/get", Params: mustMarshal(&GetPromptParams{Name: "right-prompt"})}, + wantErr: true, + wantErrContain: "Mcp-Name header value 'wrong-prompt' does not match body value 'right-prompt'", + }, + { + name: "method value is case-sensitive", + version: minVersionForStandardHeaders, + methodHeader: "TOOLS/CALL", + nameHeader: "my-tool", + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})}, + wantErr: true, + wantErrContain: "Mcp-Method header value 'TOOLS/CALL' does not match body value 'tools/call'", + }, + { + name: "valid tools/call", + version: minVersionForStandardHeaders, + methodHeader: "tools/call", + nameHeader: "my-tool", + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})}, + wantErr: false, + }, + { + name: "valid resources/read", + version: minVersionForStandardHeaders, + methodHeader: "resources/read", + nameHeader: "file:///info.txt", + msg: &jsonrpc.Request{Method: "resources/read", Params: mustMarshal(&ReadResourceParams{URI: "file:///info.txt"})}, + wantErr: false, + }, + { + name: "valid prompts/get", + version: minVersionForStandardHeaders, + methodHeader: "prompts/get", + nameHeader: "code_review", + msg: &jsonrpc.Request{Method: "prompts/get", Params: mustMarshal(&GetPromptParams{Name: "code_review"})}, + wantErr: false, + }, + { + name: "valid initialize (no name needed)", + version: minVersionForStandardHeaders, + methodHeader: "initialize", + msg: &jsonrpc.Request{Method: "initialize", Params: mustMarshal(&InitializeParams{ProtocolVersion: minVersionForStandardHeaders})}, + wantErr: false, + }, + { + name: "valid notification (no name needed)", + version: minVersionForStandardHeaders, + methodHeader: "notifications/initialized", + msg: &jsonrpc.Request{Method: "notifications/initialized"}, + wantErr: false, + }, + { + name: "tool name with hyphen", + version: minVersionForStandardHeaders, + methodHeader: "tools/call", + nameHeader: "my-tool-name", + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool-name"})}, + wantErr: false, + }, + { + name: "tool name with underscore", + version: minVersionForStandardHeaders, + methodHeader: "tools/call", + nameHeader: "my_tool_name", + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my_tool_name"})}, + wantErr: false, + }, + { + name: "resource URI with special chars", + version: minVersionForStandardHeaders, + methodHeader: "resources/read", + nameHeader: "file:///path/to/file%20name.txt", + msg: &jsonrpc.Request{Method: "resources/read", Params: mustMarshal(&ReadResourceParams{URI: "file:///path/to/file%20name.txt"})}, + wantErr: false, + }, + { + name: "resource URI with query string", + version: minVersionForStandardHeaders, + methodHeader: "resources/read", + nameHeader: "https://example.com/resource?id=123", + msg: &jsonrpc.Request{Method: "resources/read", Params: mustMarshal(&ReadResourceParams{URI: "https://example.com/resource?id=123"})}, + wantErr: false, + }, + { + name: "response message is ignored", + version: minVersionForStandardHeaders, + msg: &jsonrpc.Response{}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + header := http.Header{} + if tt.version != "" { + header.Set(protocolVersionHeader, tt.version) + } + if tt.methodHeader != "" { + header.Set(methodHeader, tt.methodHeader) + } + if tt.nameHeader != "" { + header.Set(nameHeader, tt.nameHeader) + } + + err := validateMcpHeaders(header, tt.msg) + if tt.wantErr { + if err == nil { + t.Fatalf("validateMcpHeaders() = nil, want error containing %q", tt.wantErrContain) + } + if !strings.Contains(err.Error(), tt.wantErrContain) { + t.Errorf("validateMcpHeaders() error = %q, want substring %q", err.Error(), tt.wantErrContain) + } + } else if err != nil { + t.Errorf("validateMcpHeaders() = %v, want nil", err) + } + }) + } +} diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 8332621..80fb4ba 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1781,7 +1781,7 @@ func TestSessionHijackingPrevention(t *testing.T) { req.Header.Set("Accept", "application/json, text/event-stream") req.Header.Set("Authorization", "Bearer "+userID) if sessionID != "" { - req.Header.Set("Mcp-Session-Id", sessionID) + req.Header.Set(sessionIDHeader, sessionID) } resp, err := http.DefaultClient.Do(req) if err != nil { @@ -1802,7 +1802,7 @@ func TestSessionHijackingPrevention(t *testing.T) { body, _ := io.ReadAll(resp.Body) t.Fatalf("initialize failed with status %d: %s", resp.StatusCode, body) } - sessionID := resp.Header.Get("Mcp-Session-Id") + sessionID := resp.Header.Get(sessionIDHeader) if sessionID == "" { t.Fatal("no session ID in response") } @@ -1915,6 +1915,261 @@ func TestStreamableGET(t *testing.T) { } } +// TestStreamableMcpHeaderValidation tests the server-side Mcp-Method and +// Mcp-Name header validation through the full HTTP handler, as specified +// in SEP-2243. +func TestStreamableMcpHeaderValidation(t *testing.T) { + // Temporarily register the future version so the handler accepts it. + orig := supportedProtocolVersions + supportedProtocolVersions = append(slices.Clone(orig), minVersionForStandardHeaders) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) + server.AddTool( + &Tool{Name: "my-tool", InputSchema: &jsonschema.Schema{Type: "object"}}, + func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { + return &CallToolResult{}, nil + }) + + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) + defer handler.closeAll() + + initReq := req(1, methodInitialize, &InitializeParams{ProtocolVersion: minVersionForStandardHeaders}) + initResp := resp(1, &InitializeResult{ + Capabilities: &ServerCapabilities{ + Logging: &LoggingCapabilities{}, + Tools: &ToolCapabilities{ListChanged: true}, + }, + ProtocolVersion: minVersionForStandardHeaders, + ServerInfo: &Implementation{Name: "testServer", Version: "v1.0.0"}, + }, nil) + + initialize := streamableRequest{ + method: "POST", + messages: []jsonrpc.Message{initReq}, + wantStatusCode: http.StatusOK, + wantMessages: []jsonrpc.Message{initResp}, + wantSessionID: true, + } + initialized := streamableRequest{ + method: "POST", + headers: http.Header{ + protocolVersionHeader: {minVersionForStandardHeaders}, + methodHeader: {notificationInitialized}, + }, + messages: []jsonrpc.Message{req(0, notificationInitialized, &InitializedParams{})}, + wantStatusCode: http.StatusAccepted, + } + + testStreamableHandler(t, handler, []streamableRequest{ + initialize, + initialized, + { + method: "POST", + headers: http.Header{ + protocolVersionHeader: {minVersionForStandardHeaders}, + methodHeader: {"tools/call"}, + nameHeader: {"my-tool"}, + }, + messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "my-tool"})}, + wantStatusCode: http.StatusOK, + wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)}, + }, + { + method: "POST", + headers: http.Header{ + protocolVersionHeader: {minVersionForStandardHeaders}, + methodHeader: {"prompts/get"}, + nameHeader: {"my-tool"}, + }, + messages: []jsonrpc.Message{req(3, "tools/call", &CallToolParams{Name: "my-tool"})}, + wantStatusCode: http.StatusBadRequest, + wantBodyContaining: "Mcp-Method header value", + }, + { + method: "POST", + headers: http.Header{ + protocolVersionHeader: {minVersionForStandardHeaders}, + methodHeader: {"tools/call"}, + nameHeader: {"wrong-tool"}, + }, + messages: []jsonrpc.Message{req(4, "tools/call", &CallToolParams{Name: "my-tool"})}, + wantStatusCode: http.StatusBadRequest, + wantBodyContaining: "Mcp-Name header value", + }, + { + method: "POST", + headers: http.Header{ + protocolVersionHeader: {minVersionForStandardHeaders}, + methodHeader: {"TOOLS/CALL"}, + nameHeader: {"my-tool"}, + }, + messages: []jsonrpc.Message{req(5, "tools/call", &CallToolParams{Name: "my-tool"})}, + wantStatusCode: http.StatusBadRequest, + wantBodyContaining: "Mcp-Method header value", + }, + { + method: "POST", + headers: http.Header{ + protocolVersionHeader: {minVersionForStandardHeaders}, + methodHeader: {"tools/call"}, + nameHeader: {"my-tool"}, + }, + messages: []jsonrpc.Message{req(6, "tools/call", &CallToolParams{Name: "my-tool"})}, + wantStatusCode: http.StatusOK, + wantMessages: []jsonrpc.Message{resp(6, &CallToolResult{Content: []Content{}}, nil)}, + }, + }) +} + +// TestStreamableMcpHeaderValidationErrorFormat verifies that header +// validation errors return a JSON-RPC error with code -32001 and +// Content-Type application/json, per SEP-2243. +func TestStreamableMcpHeaderValidationErrorFormat(t *testing.T) { + orig := supportedProtocolVersions + supportedProtocolVersions = append(slices.Clone(orig), minVersionForStandardHeaders) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) + server.AddTool( + &Tool{Name: "my-tool", InputSchema: &jsonschema.Schema{Type: "object"}}, + func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { + return &CallToolResult{}, nil + }) + + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) + defer handler.closeAll() + + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + defer httpServer.Close() + + // Use the MCP client with a custom RoundTripper to inject a bad header. + var toolCallResp *http.Response + var toolCallRespBody []byte + + customClient := &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + var originalMethodHeader string + if req.Header.Get(methodHeader) == "tools/call" { + originalMethodHeader = req.Header.Get(methodHeader) + req.Header.Set(methodHeader, "wrong-method") + } + resp, err := http.DefaultTransport.RoundTrip(req) + if err == nil && originalMethodHeader == "tools/call" { + toolCallResp = resp + toolCallRespBody, _ = io.ReadAll(resp.Body) + } + return resp, err + }), + } + + clientTransport := &StreamableClientTransport{ + Endpoint: httpServer.URL, + HTTPClient: customClient, + } + + client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil) + ctx := context.Background() + session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{protocolVersion: minVersionForStandardHeaders}) + if err != nil { + t.Fatal(err) + } + defer session.Close() + + _, err = session.CallTool(ctx, &CallToolParams{Name: "my-tool"}) + // We expect an error because the server should reject it. + if err == nil { + t.Error("CallTool succeeded unexpectedly") + } + + if toolCallResp == nil { + t.Fatal("no response captured") + } + + // Verify HTTP status code. + if toolCallResp.StatusCode != http.StatusBadRequest { + t.Errorf("status code = %d, want %d", toolCallResp.StatusCode, http.StatusBadRequest) + } + + // Verify Content-Type. + if baseMediaType(toolCallResp.Header.Get("Content-Type")) != "application/json" { + t.Errorf("Content-Type = %q, want %q", baseMediaType(toolCallResp.Header.Get("Content-Type")), "application/json") + } + + // Verify JSON-RPC error body contains error code -32001. + msg, err := jsonrpc2.DecodeMessage(toolCallRespBody) + if err != nil { + t.Fatalf("failed to decode message: %v", err) + } + resp, ok := msg.(*jsonrpc2.Response) + if !ok { + t.Fatalf("expected *jsonrpc2.Response, got %T", msg) + } + var wireErr *jsonrpc2.WireError + if !errors.As(resp.Error, &wireErr) { + t.Fatalf("expected *jsonrpc2.WireError, got %T", resp.Error) + } + if wireErr.Code != -32001 { + t.Errorf("wireErr.Code = %d, want -32001", wireErr.Code) + } + if !strings.Contains(wireErr.Message, "Mcp-Method header value") { + t.Errorf("wireErr.Message = %q, want it to contain %q", wireErr.Message, "Mcp-Method header value") + } +} + +// TestStreamableMcpHeaderVersionGating verifies that header validation +// is skipped for protocol versions older than minVersionForStandardHeaders. +func TestStreamableMcpHeaderVersionGating(t *testing.T) { + server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) + server.AddTool( + &Tool{Name: "my-tool", InputSchema: &jsonschema.Schema{Type: "object"}}, + func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { + return &CallToolResult{}, nil + }) + + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) + defer handler.closeAll() + + initReq := req(1, methodInitialize, &InitializeParams{ProtocolVersion: protocolVersion20251125}) + initResp := resp(1, &InitializeResult{ + Capabilities: &ServerCapabilities{ + Logging: &LoggingCapabilities{}, + Tools: &ToolCapabilities{ListChanged: true}, + }, + ProtocolVersion: protocolVersion20251125, + ServerInfo: &Implementation{Name: "testServer", Version: "v1.0.0"}, + }, nil) + + testStreamableHandler(t, handler, []streamableRequest{ + { + method: "POST", + messages: []jsonrpc.Message{initReq}, + wantStatusCode: http.StatusOK, + wantMessages: []jsonrpc.Message{initResp}, + wantSessionID: true, + }, + { + method: "POST", + headers: http.Header{protocolVersionHeader: {protocolVersion20251125}}, + messages: []jsonrpc.Message{req(0, notificationInitialized, &InitializedParams{})}, + wantStatusCode: http.StatusAccepted, + }, + // Requests with deliberately wrong MCP headers should still succeed + // because the protocol version is too old for validation. + { + method: "POST", + headers: http.Header{ + protocolVersionHeader: {protocolVersion20251125}, + methodHeader: {"wrong-method"}, + nameHeader: {"wrong-name"}, + }, + messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "my-tool"})}, + wantStatusCode: http.StatusOK, + wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)}, + }, + }) +} + // TestStreamable405AllowHeader verifies RFC 9110 §15.5.6 compliance: // 405 Method Not Allowed responses MUST include an Allow header. func TestStreamable405AllowHeader(t *testing.T) { @@ -2082,7 +2337,7 @@ func TestStreamableClientContextPropagation(t *testing.T) { switch req.Method { case "POST": w.Header().Set("Content-Type", "application/json") - w.Header().Set("Mcp-Session-Id", "test-session") + w.Header().Set(sessionIDHeader, "test-session") w.WriteHeader(http.StatusOK) w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-03-26","capabilities":{},"serverInfo":{"name":"test","version":"1.0"}}}`)) case "GET": From f5f2015413f17f578fbacc839d2c05d0fdb3f1cc Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Thu, 30 Apr 2026 09:54:32 +0200 Subject: [PATCH 265/418] MCPGODEBUG update for 1.6.0 (#893) --- docs/mcpgodebug.md | 30 ++++++++++++++++++++++++++++-- internal/docs/mcpgodebug.src.md | 30 ++++++++++++++++++++++++++++-- internal/jsonrpc2/messages.go | 12 ------------ mcp/streamable.go | 2 +- 4 files changed, 57 insertions(+), 17 deletions(-) diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index 7c6773d..96827dc 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -18,13 +18,39 @@ ## `MCPGODEBUG` history +### 1.6.0 + +Options listed below were added and will be removed in the 1.8.0 version of the SDK. + +- `seterroroverwrite` added. If set to `1`, `SetError` will always overwrite + `Content` with the error text, restoring the previous behavior. The default + behavior was changed to preserve existing `Content` if it has already been + populated. + +- `enableoriginverification` added. If set to `1`, default (zero-value) + cross-origin protection will be applied when + `StreamableHTTPOptions.CrossOriginProtection` is nil, restoring the + behavior from v1.4.1-v1.5.0. The default behavior was changed to not + enable cross-origin protection. + +- `disablelocalhostprotection` removal was postponed until 1.8.0, as it is now + also used in the SSE transport. + +Options below were removed: + +- `jsonescaping`, according to plan, + +- `disablecrossoriginprotection`, it was replaced by + `enableoriginverification` after the default was changed to not enable + cross-origin protection. + ### 1.4.1 Options listed below will be removed in the 1.6.0 version of the SDK. - `disablecrossoriginprotection` added. If set to `1`, newly added cross-origin protection will be disabled. The default behavior was changed to enable - cross-origin protection. **Removal of this option was postponed until 1.7.0.** + cross-origin protection. ### 1.4.0 @@ -39,4 +65,4 @@ Options listed below will be removed in the 1.6.0 version of the SDK. protection. The protection can also be disabled by setting the `DisableLocalhostProtection` field in the `StreamableHTTPOptions` or `SSEOptions` struct to `true`, which is the recommended way to disable - the protection long term. **Removal of this option was postponed until 1.7.0.** + the protection long term. **Removal of this option was postponed until 1.8.0.** diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index 592e923..04cf89c 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -17,13 +17,39 @@ ## `MCPGODEBUG` history +### 1.6.0 + +Options listed below were added and will be removed in the 1.8.0 version of the SDK. + +- `seterroroverwrite` added. If set to `1`, `SetError` will always overwrite + `Content` with the error text, restoring the previous behavior. The default + behavior was changed to preserve existing `Content` if it has already been + populated. + +- `enableoriginverification` added. If set to `1`, default (zero-value) + cross-origin protection will be applied when + `StreamableHTTPOptions.CrossOriginProtection` is nil, restoring the + behavior from v1.4.1-v1.5.0. The default behavior was changed to not + enable cross-origin protection. + +- `disablelocalhostprotection` removal was postponed until 1.8.0, as it is now + also used in the SSE transport. + +Options below were removed: + +- `jsonescaping`, according to plan, + +- `disablecrossoriginprotection`, it was replaced by + `enableoriginverification` after the default was changed to not enable + cross-origin protection. + ### 1.4.1 Options listed below will be removed in the 1.6.0 version of the SDK. - `disablecrossoriginprotection` added. If set to `1`, newly added cross-origin protection will be disabled. The default behavior was changed to enable - cross-origin protection. **Removal of this option was postponed until 1.7.0.** + cross-origin protection. ### 1.4.0 @@ -38,4 +64,4 @@ Options listed below will be removed in the 1.6.0 version of the SDK. protection. The protection can also be disabled by setting the `DisableLocalhostProtection` field in the `StreamableHTTPOptions` or `SSEOptions` struct to `true`, which is the recommended way to disable - the protection long term. **Removal of this option was postponed until 1.7.0.** + the protection long term. **Removal of this option was postponed until 1.8.0.** diff --git a/internal/jsonrpc2/messages.go b/internal/jsonrpc2/messages.go index b424780..77c9531 100644 --- a/internal/jsonrpc2/messages.go +++ b/internal/jsonrpc2/messages.go @@ -11,8 +11,6 @@ import ( "fmt" internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" - - "github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug" ) // ID is a Request identifier, which is defined by the spec to be a string, integer, or null. @@ -219,18 +217,8 @@ func marshalToRaw(obj any) (json.RawMessage, error) { return json.RawMessage(data), nil } -// jsonescaping is a compatibility parameter that allows to restore -// JSON escaping in the JSON marshaling, which stopped being the default -// in the 1.4.0 version of the SDK. See the documentation for the -// mcpgodebug package for instructions how to enable it. -// The option will be removed in the 1.6.0 version of the SDK. -var jsonescaping = mcpgodebug.Value("jsonescaping") - // jsonMarshal marshals obj to JSON like json.Marshal but without HTML escaping. func jsonMarshal(obj any) ([]byte, error) { - if jsonescaping == "1" { - return json.Marshal(obj) - } var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) diff --git a/mcp/streamable.go b/mcp/streamable.go index 4470954..708b132 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -235,7 +235,7 @@ func (h *StreamableHTTPHandler) closeAll() { // disablelocalhostprotection is a compatibility parameter that allows to disable // DNS rebinding protection, which was added in the 1.4.0 version of the SDK. // See the documentation for the mcpgodebug package for instructions how to enable it. -// The option will be removed in the 1.6.0 version of the SDK. +// The option will be removed in the 1.8.0 version of the SDK. var disablelocalhostprotection = mcpgodebug.Value("disablelocalhostprotection") // enableoriginverification is a compatibility parameter that restores the From 8e45782dbd21322c73381f3a021dd2bb0f3d7108 Mon Sep 17 00:00:00 2001 From: Dayna Blackwell Date: Mon, 4 May 2026 03:14:20 -0700 Subject: [PATCH 266/418] mcp: close HTTP response body from DELETE request in streamableClientConn.Close (#929) ## Summary The DELETE request that terminates a streamable HTTP session discards the `*http.Response` without closing its body. Per the `net/http` documentation, this prevents the underlying TCP connection from being reused and leaks it. ## The bug ```go // mcp/streamable.go:2239 (before) } else if _, err := c.client.Do(req); err != nil { c.closeErr = err } ``` On the success path (no error), the response is silently discarded. The default client is `http.DefaultClient` with keep-alives enabled, so the leaked connection stays open until GC or process exit. ## The fix ```go // mcp/streamable.go:2239 (after) } else if resp, err := c.client.Do(req); err != nil { c.closeErr = err } else { resp.Body.Close() } ``` All 10 other `client.Do` call sites in this file close the response body. This one was missed. ## Test plan - [x] `go build ./...` passes - [x] `go test ./... -short` passes Fixes #928 --- mcp/streamable.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 708b132..41acc5d 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -2236,8 +2236,10 @@ func (c *streamableClientConn) Close() error { } else { if err := c.setMCPHeaders(req); err != nil { c.closeErr = err - } else if _, err := c.client.Do(req); err != nil { + } else if resp, err := c.client.Do(req); err != nil { c.closeErr = err + } else { + resp.Body.Close() } } } From 4f1b3e5062d16d98bb44bdcc0c49f046f11d9f1b Mon Sep 17 00:00:00 2001 From: Scott Leggett Date: Mon, 4 May 2026 19:07:03 +0800 Subject: [PATCH 267/418] fix: handle oauth2.RetrieveError during token refresh (#917) Previously, an expired refresh token in the oauth2.Token returned from OAuthHandler.TokenSource() would cause the connection to fail. From the client perspective, this meant that the MCP connection was in a hard-failed state with no way to re-authorize. The change in this commit causes Authorize() to be called in the event of both an oauth2.RetrieveError from the token endpoint, as well as in the pre-existing case of a 401/403 HTTP response from the MCP server. Clients will handle this in their existing Authorize() flows to get a new valid token for the connection. Supersedes and closes: #909. Co-authored-by: Yaroslav --- mcp/streamable.go | 19 ++++++++++-- mcp/streamable_client_test.go | 56 +++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 41acc5d..0653171 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -37,6 +37,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/internal/util" "github.com/modelcontextprotocol/go-sdk/internal/xcontext" "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "golang.org/x/oauth2" ) // A StreamableHTTPHandler is an http.Handler that serves streamable MCP @@ -1803,6 +1804,7 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") + if err := c.setMCPHeaders(req); err != nil { // Failure to set headers means that the request was not sent. // Wrap with ErrRejected so the jsonrpc2 connection doesn't set writeErr @@ -1934,9 +1936,20 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request) error { if ts != nil { token, err := ts.Token() if err != nil { - return err - } - if token != nil { + // If the error is an invalid_grant oauth2.RetrieveError it indicates + // that the token source doesn't have valid authorization for the token + // endpoint, per RFC 6749 section 5.2. For example, the refresh token + // may be expired or invalid. + // + // In that case, ignore the error, skip setting the Authorization + // header, and proceed with the request. Callers that support + // authorization flows get a 401/403 response and trigger the + // Authorize() flow to refresh their token. + var retrieveErr *oauth2.RetrieveError + if !errors.As(err, &retrieveErr) || retrieveErr.ErrorCode != "invalid_grant" { + return err + } + } else if token != nil { req.Header.Set("Authorization", "Bearer "+token.AccessToken) } } diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 96c5e75..517e51a 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -6,6 +6,7 @@ package mcp import ( "context" + "errors" "fmt" "io" "net/http" @@ -1156,3 +1157,58 @@ func TestTokenInfo(t *testing.T) { t.Errorf("got %q, want %q", g, w) } } + +// errTestAuthorizeFailed is a sentinel error returned by +// retrieveErrorOAuthHandler.Authorize(). +var errTestAuthorizeFailed = errors.New("authorize intentionally failed for test") + +// retrieveErrorOAuthHandler is a mock OAuthHandler that always returns +// an oauth2.RetrieveError from its TokenSource's Token() method. +type retrieveErrorOAuthHandler struct{} + +func (h *retrieveErrorOAuthHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) { + return h, nil +} + +func (h *retrieveErrorOAuthHandler) Token() (*oauth2.Token, error) { + return nil, &oauth2.RetrieveError{ + Response: &http.Response{StatusCode: http.StatusBadRequest}, + Body: []byte("test retrieve error"), + ErrorCode: "invalid_grant", + } +} + +func (h *retrieveErrorOAuthHandler) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error { + return errTestAuthorizeFailed +} + +// TestStreamableClientOAuth_RetrieveError verifies that an invalid_grant RetrieveError +// from the OAuth token source correctly skips sending Authorization header and relies on +// the server's 401 response to trigger the Authorize fallback flow. +func TestStreamableClientOAuth_RetrieveError(t *testing.T) { + ctx := context.Background() + oauthHandler := &retrieveErrorOAuthHandler{} + + // Mock MCP server returns 401 Unauthorized to simulate a server rejecting + // the request that omitted the Authorization header. + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + t.Cleanup(httpServer.Close) + + transport := &StreamableClientTransport{ + Endpoint: httpServer.URL, + OAuthHandler: oauthHandler, + } + client := NewClient(testImpl, nil) + + // Attempt to connect. The Connect call will trigger the initialization request, + // which will fail to retrieve the token and proceed without auth header, receive 401, + // and invoke Authorize(). + _, err := client.Connect(ctx, transport, nil) + + // Expect the connection to fail with the sentinel error, not the RetrieveError. + if !errors.Is(err, errTestAuthorizeFailed) { + t.Fatalf("client.Connect() error = %v, want %v", err, errTestAuthorizeFailed) + } +} From 9e0d8979df500b2a8f2ab5f15a078b1923f1338a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 09:58:16 +0200 Subject: [PATCH 268/418] build(deps): bump github/codeql-action from 4.35.1 to 4.35.2 (#922) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.1 to 4.35.2.
Release notes

Sourced from github/codeql-action's releases.

v4.35.2

  • The undocumented TRAP cache cleanup feature that could be enabled using the CODEQL_ACTION_CLEANUP_TRAP_CACHES environment variable is deprecated and will be removed in May 2026. If you are affected by this, we recommend disabling TRAP caching by passing the trap-caching: false input to the init Action. #3795
  • The Git version 2.36.0 requirement for improved incremental analysis now only applies to repositories that contain submodules. #3789
  • Python analysis on GHES no longer extracts the standard library, relying instead on models of the standard library. This should result in significantly faster extraction and analysis times, while the effect on alerts should be minimal. #3794
  • Fixed a bug in the validation of OIDC configurations for private registries that was added in CodeQL Action 4.33.0 / 3.33.0. #3807
  • Update default CodeQL bundle version to 2.25.2. #3823
Changelog

Sourced from github/codeql-action's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

  • Configurations for private registries that use Cloudsmith or GCP OIDC are now accepted. #3850
  • Fixed a bug where two diagnostics produced within the same millisecond could overwrite each other on disk, causing one of them to be lost. #3852
  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.19.3 and earlier. These versions of CodeQL were discontinued on 9 April 2026 alongside GitHub Enterprise Server 3.15, and will be unsupported by the next minor release of the CodeQL Action. #3837
  • Update default CodeQL bundle version to 2.25.3. #3865

4.35.2 - 15 Apr 2026

  • The undocumented TRAP cache cleanup feature that could be enabled using the CODEQL_ACTION_CLEANUP_TRAP_CACHES environment variable is deprecated and will be removed in May 2026. If you are affected by this, we recommend disabling TRAP caching by passing the trap-caching: false input to the init Action. #3795
  • The Git version 2.36.0 requirement for improved incremental analysis now only applies to repositories that contain submodules. #3789
  • Python analysis on GHES no longer extracts the standard library, relying instead on models of the standard library. This should result in significantly faster extraction and analysis times, while the effect on alerts should be minimal. #3794
  • Fixed a bug in the validation of OIDC configurations for private registries that was added in CodeQL Action 4.33.0 / 3.33.0. #3807
  • Update default CodeQL bundle version to 2.25.2. #3823

4.35.1 - 27 Mar 2026

4.35.0 - 27 Mar 2026

4.34.1 - 20 Mar 2026

  • Downgrade default CodeQL bundle version to 2.24.3 due to issues with a small percentage of Actions and JavaScript analyses. #3762

4.34.0 - 20 Mar 2026

  • Added an experimental change which disables TRAP caching when improved incremental analysis is enabled, since improved incremental analysis supersedes TRAP caching. This will improve performance and reduce Actions cache usage. We expect to roll this change out to everyone in March. #3569
  • We are rolling out improved incremental analysis to C/C++ analyses that use build mode none. We expect this rollout to be complete by the end of April 2026. #3584
  • Update default CodeQL bundle version to 2.25.0. #3585

4.33.0 - 16 Mar 2026

  • Upcoming change: Starting April 2026, the CodeQL Action will skip collecting file coverage information on pull requests to improve analysis performance. File coverage information will still be computed on non-PR analyses. Pull request analyses will log a warning about this upcoming change. #3562

    To opt out of this change:

    • Repositories owned by an organization: Create a custom repository property with the name github-codeql-file-coverage-on-prs and the type "True/false", then set this property to true in the repository's settings. For more information, see Managing custom properties for repositories in your organization. Alternatively, if you are using an advanced setup workflow, you can set the CODEQL_ACTION_FILE_COVERAGE_ON_PRS environment variable to true in your workflow.
    • User-owned repositories using default setup: Switch to an advanced setup workflow and set the CODEQL_ACTION_FILE_COVERAGE_ON_PRS environment variable to true in your workflow.
    • User-owned repositories using advanced setup: Set the CODEQL_ACTION_FILE_COVERAGE_ON_PRS environment variable to true in your workflow.
  • Fixed a bug which caused the CodeQL Action to fail loading repository properties if a "Multi select" repository property was configured for the repository. #3557

  • The CodeQL Action now loads custom repository properties on GitHub Enterprise Server, enabling the customization of features such as github-codeql-disable-overlay that was previously only available on GitHub.com. #3559

  • Once private package registries can be configured with OIDC-based authentication for organizations, the CodeQL Action will now be able to accept such configurations. #3563

  • Fixed the retry mechanism for database uploads. Previously this would fail with the error "Response body object should not be disturbed or locked". #3564

... (truncated)

Commits
  • 95e58e9 Merge pull request #3824 from github/update-v4.35.2-d2e135a73
  • 6f31bfe Update changelog for v4.35.2
  • d2e135a Merge pull request #3823 from github/update-bundle/codeql-bundle-v2.25.2
  • 60abb65 Add changelog note
  • 5a0a562 Update default bundle to codeql-bundle-v2.25.2
  • 6521697 Merge pull request #3820 from github/dependabot/github_actions/dot-github/wor...
  • 3c45af2 Merge pull request #3821 from github/dependabot/npm_and_yarn/npm-minor-345b93...
  • f1c3393 Rebuild
  • 1024fc4 Rebuild
  • 9dd4cfe Bump the npm-minor group across 1 directory with 6 updates
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github/codeql-action&package-manager=github_actions&previous-version=4.35.1&new-version=4.35.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Guglielmo Colombo --- .github/workflows/codeql.yml | 4 ++-- .github/workflows/scorecard.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6b0a9e9..4aa9179 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -38,11 +38,11 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Initialize CodeQL - uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 0b46d03..a4ee772 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -73,6 +73,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 with: sarif_file: results.sarif From 49ef49acf16591447f7b1b6a1f3126f1672bb3c6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 08:00:13 +0000 Subject: [PATCH 269/418] build(deps): bump actions/cache from 5.0.4 to 5.0.5 (#923) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/cache](https://github.com/actions/cache) from 5.0.4 to 5.0.5.
Release notes

Sourced from actions/cache's releases.

v5.0.5

What's Changed

Full Changelog: https://github.com/actions/cache/compare/v5...v5.0.5

Changelog

Sourced from actions/cache's changelog.

Releases

How to prepare a release

[!NOTE]
Relevant for maintainers with write access only.

  1. Switch to a new branch from main.
  2. Run npm test to ensure all tests are passing.
  3. Update the version in https://github.com/actions/cache/blob/main/package.json.
  4. Run npm run build to update the compiled files.
  5. Update this https://github.com/actions/cache/blob/main/RELEASES.md with the new version and changes in the ## Changelog section.
  6. Run licensed cache to update the license report.
  7. Run licensed status and resolve any warnings by updating the https://github.com/actions/cache/blob/main/.licensed.yml file with the exceptions.
  8. Commit your changes and push your branch upstream.
  9. Open a pull request against main and get it reviewed and merged.
  10. Draft a new release https://github.com/actions/cache/releases use the same version number used in package.json
    1. Create a new tag with the version number.
    2. Auto generate release notes and update them to match the changes you made in RELEASES.md.
    3. Toggle the set as the latest release option.
    4. Publish the release.
  11. Navigate to https://github.com/actions/cache/actions/workflows/release-new-action-version.yml
    1. There should be a workflow run queued with the same version number.
    2. Approve the run to publish the new version and update the major tags for this action.

Changelog

5.0.4

  • Bump minimatch to v3.1.5 (fixes ReDoS via globstar patterns)
  • Bump undici to v6.24.1 (WebSocket decompression bomb protection, header validation fixes)
  • Bump fast-xml-parser to v5.5.6

5.0.3

5.0.2

  • Bump @actions/cache to v5.0.3 #1692

5.0.1

  • Update @azure/storage-blob to ^12.29.1 via @actions/cache@5.0.1 #1685

5.0.0

[!IMPORTANT] actions/cache@v5 runs on the Node.js 24 runtime and requires a minimum Actions Runner version of 2.327.1.

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/cache&package-manager=github_actions&previous-version=5.0.4&new-version=5.0.5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Guglielmo Colombo --- .github/workflows/publish-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index 088fceb..202734f 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -22,7 +22,7 @@ jobs: with: python-version: 3.x - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: key: mkdocs-material-${{ env.cache_id }} path: .cache From 93a09db45bc74846cd0ef033787910d2210a1335 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 08:02:20 +0000 Subject: [PATCH 270/418] build(deps): bump actions/setup-node from 6.3.0 to 6.4.0 (#921) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.3.0 to 6.4.0.
Release notes

Sourced from actions/setup-node's releases.

v6.4.0

What's Changed

Dependency updates:

New Contributors

Full Changelog: https://github.com/actions/setup-node/compare/v6...v6.4.0

Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/nightly.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 8c56e4d..d92dc93 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -29,7 +29,7 @@ jobs: with: go-version: "^1.26" - name: Set up Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: "22" - name: Run server conformance tests From ec54d41a17a38f05165694fb9fb3ce232a7dbfd0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 09:43:53 +0000 Subject: [PATCH 271/418] build(deps): bump actions/upload-artifact from 7.0.0 to 7.0.1 (#920) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 7.0.0 to 7.0.1.
Release notes

Sourced from actions/upload-artifact's releases.

v7.0.1

What's Changed

Full Changelog: https://github.com/actions/upload-artifact/compare/v7...v7.0.1

Commits
  • 043fb46 Merge pull request #797 from actions/yacaovsnc/update-dependency
  • 634250c Include changes in typespec/ts-http-runtime 0.3.5
  • e454baa Readme: bump all the example versions to v7 (#796)
  • 74fad66 Update the readme with direct upload details (#795)
  • See full diff in compare view

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/scorecard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index a4ee772..bb8b659 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -64,7 +64,7 @@ jobs: # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: SARIF file path: results.sarif From 2b7c9937e642728908fbd288eb362c09adf78e1f Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Tue, 5 May 2026 16:20:02 +0200 Subject: [PATCH 272/418] mcp: align resource not found error code with sep-2164 (#931) Change the error code returned by ResourceNotFoundError from -32002 to -32602 (jsonrpc.CodeInvalidParams), aligning with [SEP-2164](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164). * Not gated with a protocol version check, because the previous error code was never formally specified and the behaviour varied across SDKs. * In case clients which rely on the old error code exist, the previous behaviour can be restored with `MCPGODEBUG=customresnotfounderrcode=1`. * `CodeResourceNotFound` deprecated since its only purpose going forward is backward compatibility. The new code should use `jsonrpc.CodeInvalidParams` directly. * `CodeResourceNotFound` changed to `var` so it can stay in sync with `ResourceNotFoundError` and existing code doing `code == mcp.CodeResourceNotFound` keeps working in both the default and compat modes. --- docs/mcpgodebug.md | 9 +++++++++ internal/docs/mcpgodebug.src.md | 9 +++++++++ mcp/error_test.go | 16 ++++++++++++++++ mcp/resource.go | 18 ++++++++++++++++++ mcp/shared.go | 12 ++++++++++-- 5 files changed, 62 insertions(+), 2 deletions(-) diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index 96827dc..e5a7d6f 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -18,6 +18,15 @@ ## `MCPGODEBUG` history +### 1.7.0 + +Options listed below were added and will be removed in the 1.9.0 version of the SDK. + +- `customresnotfounderrcode` added. If set to `1`, `ResourceNotFoundError` will + use the custom error code `-32002` instead of the standard `-32602` (Invalid + Params), restoring the previous behavior. The default behavior was changed to + align with SEP-2164 and the JSON-RPC specification. + ### 1.6.0 Options listed below were added and will be removed in the 1.8.0 version of the SDK. diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index 04cf89c..c03205b 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -17,6 +17,15 @@ ## `MCPGODEBUG` history +### 1.7.0 + +Options listed below were added and will be removed in the 1.9.0 version of the SDK. + +- `customresnotfounderrcode` added. If set to `1`, `ResourceNotFoundError` will + use the custom error code `-32002` instead of the standard `-32602` (Invalid + Params), restoring the previous behavior. The default behavior was changed to + align with SEP-2164 and the JSON-RPC specification. + ### 1.6.0 Options listed below were added and will be removed in the 1.8.0 version of the SDK. diff --git a/mcp/error_test.go b/mcp/error_test.go index 47a99f8..2b68d75 100644 --- a/mcp/error_test.go +++ b/mcp/error_test.go @@ -111,6 +111,22 @@ func TestServerErrors(t *testing.T) { } } +// TestResourceNotFoundErrorCode verifies ResourceNotFoundError code and +// CodeResourceNotFound are -32602 (InvalidParams) per SEP-2164. +func TestResourceNotFoundErrorCode(t *testing.T) { + err := ResourceNotFoundError("file:///test.txt") + var rpcErr *jsonrpc.Error + if !errors.As(err, &rpcErr) { + t.Fatalf("got error type %T, want *jsonrpc.Error", err) + } + if rpcErr.Code != jsonrpc.CodeInvalidParams { + t.Errorf("got error code %d, want %d (InvalidParams)", rpcErr.Code, jsonrpc.CodeInvalidParams) + } + if CodeResourceNotFound != jsonrpc.CodeInvalidParams { + t.Errorf("CodeResourceNotFound = %d, want %d", CodeResourceNotFound, jsonrpc.CodeInvalidParams) + } +} + // TestInputValidationToolError validates that input validation errors (missing // required params, wrong types) are returned as tool results with IsError=true, // not as JSON-RPC errors. This allows LLMs to see the error and self-correct. diff --git a/mcp/resource.go b/mcp/resource.go index bc4b3cb..fff38a2 100644 --- a/mcp/resource.go +++ b/mcp/resource.go @@ -15,6 +15,7 @@ import ( "path/filepath" "strings" + "github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug" "github.com/modelcontextprotocol/go-sdk/internal/util" "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/yosida95/uritemplate/v3" @@ -37,8 +38,25 @@ type serverResourceTemplate struct { // If it cannot find the resource, it should return the result of calling [ResourceNotFoundError]. type ResourceHandler func(context.Context, *ReadResourceRequest) (*ReadResourceResult, error) +// customresnotfounderrcode is a compatibility parameter that restores the +// pre-1.7.0 behavior of [ResourceNotFoundError] and [CodeResourceNotFound], +// where the error code was a custom -32002. See the documentation for the mcpgodebug +// package for instructions on how to enable it. +// The option will be removed in the future version of the SDK. +var customresnotfounderrcode = mcpgodebug.Value("customresnotfounderrcode") + +func init() { + if customresnotfounderrcode == "1" { + CodeResourceNotFound = -32002 + } +} + // ResourceNotFoundError returns an error indicating that a resource being read could // not be found. +// +// By default, the error code is -32602 (Invalid Params), as specified in the +// MCP specification (SEP-2164). To restore the pre-1.7.0 release behavior where the +// error code was -32002, set MCPGODEBUG=customresnotfounderrcode=1. func ResourceNotFoundError(uri string) error { return &jsonrpc.Error{ Code: CodeResourceNotFound, diff --git a/mcp/shared.go b/mcp/shared.go index b5b09db..078b401 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -347,14 +347,22 @@ const ( // CodeHeaderMismatch indicates that HTTP headers do not match the corresponding values // in the request body, or that required headers are missing or malformed. CodeHeaderMismatch = -32001 - // CodeResourceNotFound indicates that a requested resource could not be found. - CodeResourceNotFound = -32002 // CodeURLElicitationRequired indicates that the server requires URL elicitation // before processing the request. The client should execute the elicitation handler // with the elicitations provided in the error data. CodeURLElicitationRequired = -32042 ) +// CodeResourceNotFound indicates that a requested resource could not be found. +// +// By default, the value is -32602 (Invalid Params), as specified in the +// MCP specification (SEP-2164). To restore the pre-1.7.0 release behavior where the +// error code was -32002, set MCPGODEBUG=customresnotfounderrcode=1. +// +// Deprecated: Use [jsonrpc.CodeInvalidParams] directly. This variable will be +// removed in a future version. +var CodeResourceNotFound int64 = jsonrpc.CodeInvalidParams + // URLElicitationRequiredError returns an error indicating that URL elicitation is required // before the request can be processed. The elicitations parameter should contain the // elicitation requests that must be completed. From 06c5f290f253768a0ee419ac32d688d002113ad4 Mon Sep 17 00:00:00 2001 From: wucm667 <109257021+wucm667@users.noreply.github.com> Date: Wed, 6 May 2026 15:29:29 +0800 Subject: [PATCH 273/418] fix(mcp): prevent SIGSEGV in AddTool when schema is nil (#918) ## Bug `mcp.AddTool` crashes the server with SIGSEGV when input/output struct tags trigger a nil schema. ## Root Cause In `mcp/server.go`, the function `setSchema` calls `internalSchema.Resolve(...)` without checking if `internalSchema` is nil. If the schema inference (e.g., via `jsonschema.ForType`) or a provided schema results in a nil value, this dereference causes a panic. ## Fix Added nil checks for `internalSchema` before calling `Resolve` in two locations within `setSchema`: 1. After schema generation via `jsonschema.ForType`. 2. After processing a provided schema (which might be nil or fail to unmarshal). If `internalSchema` is nil, the function now returns a descriptive error instead of crashing. ## Verification - `go test ./mcp/...` passes. - The fix prevents the crash by handling the nil case gracefully. Closes #916. --------- Signed-off-by: wucm667 Co-authored-by: Guglielmo Colombo --- mcp/server.go | 13 +++++++++++++ mcp/server_test.go | 47 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/mcp/server.go b/mcp/server.go index 2850437..16d06ca 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -247,6 +247,9 @@ func (s *Server) AddTool(t *Tool, h ToolHandler) { panic(fmt.Errorf("AddTool %q: missing input schema", t.Name)) } if s, ok := t.InputSchema.(*jsonschema.Schema); ok { + if s == nil { + panic(fmt.Errorf("AddTool %q: input schema is nil", t.Name)) + } if s.Type != "object" { panic(fmt.Errorf(`AddTool %q: input schema must have type "object"`, t.Name)) } @@ -261,6 +264,9 @@ func (s *Server) AddTool(t *Tool, h ToolHandler) { } if t.OutputSchema != nil { if s, ok := t.OutputSchema.(*jsonschema.Schema); ok { + if s == nil { + panic(fmt.Errorf("AddTool %q: output schema is nil", t.Name)) + } if s.Type != "object" { panic(fmt.Errorf(`AddTool %q: output schema must have type "object"`, t.Name)) } @@ -437,6 +443,9 @@ func setSchema[T any](sfield *any, rfield **jsonschema.Resolved, cache *SchemaCa if err != nil { return zero, err } + if internalSchema == nil { + return zero, fmt.Errorf("schema is nil for type %v", rt) + } *sfield = internalSchema resolved, err := internalSchema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true}) @@ -466,6 +475,10 @@ func setSchema[T any](sfield *any, rfield **jsonschema.Resolved, cache *SchemaCa } } + if internalSchema == nil { + return zero, fmt.Errorf("schema is nil for type %v", rt) + } + resolved, err := internalSchema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true}) if err != nil { return zero, err diff --git a/mcp/server_test.go b/mcp/server_test.go index 1312e1d..2937ea2 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -8,6 +8,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "log" "log/slog" "slices" @@ -604,6 +605,52 @@ func TestAddToolNameValidation(t *testing.T) { } } +func TestAddToolNilSchema(t *testing.T) { + var nilSchema *jsonschema.Schema + + panicMsg := func(f func()) (msg string) { + defer func() { + if r := recover(); r != nil { + msg = fmt.Sprintf("%v", r) + } + }() + f() + return msg + } + + // Call s.AddTool directly to exercise the typed-nil checks added to that method. + tests := []struct { + name string + tool *Tool + wantContain string + }{ + { + name: "typed nil InputSchema", + tool: &Tool{Name: "T", InputSchema: nilSchema}, + wantContain: "input schema is nil", + }, + { + name: "typed nil OutputSchema", + tool: &Tool{Name: "T", InputSchema: &jsonschema.Schema{Type: "object"}, OutputSchema: nilSchema}, + wantContain: "output schema is nil", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := NewServer(testImpl, nil) + + msg := panicMsg(func() { + s.AddTool(tc.tool, nil) + }) + if msg == "" { + t.Error("expected panic") + } else if !strings.Contains(msg, tc.wantContain) { + t.Errorf("panic message %q does not contain %q", msg, tc.wantContain) + } + }) + } +} + type schema = jsonschema.Schema func testToolForSchema[In, Out any](t *testing.T, tool *Tool, in string, out Out, wantIn, wantOut any, wantErrContaining string) { From 9604499eed4c9e727b43f2bda8f64087d0320091 Mon Sep 17 00:00:00 2001 From: wucm667 <109257021+wucm667@users.noreply.github.com> Date: Wed, 6 May 2026 16:31:01 +0800 Subject: [PATCH 274/418] mcp: use golang.org/x/time/rate for LoggingHandler rate limiting (#927) Replaces the simple time-interval check in `LoggingHandler` with a standard `rate.Limiter` from `golang.org/x/time/rate`, as suggested by the existing TODO comment. This provides a well-tested implementation and removes the need for manual `lastMessageSent` tracking. The rate (`1/MinInterval`) and burst (1) match the previous behavior exactly. /cc @jba Closes #924 --------- Signed-off-by: wucm667 Co-authored-by: Jonathan Amsterdam Co-authored-by: Maciej Kisiel --- go.mod | 1 + go.sum | 2 ++ mcp/logging.go | 23 ++++++++++------------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/go.mod b/go.mod index 386b393..860d6fa 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/segmentio/encoding v0.5.4 github.com/yosida95/uritemplate/v3 v3.0.2 golang.org/x/oauth2 v0.35.0 + golang.org/x/time v0.15.0 golang.org/x/tools v0.42.0 ) diff --git a/go.sum b/go.sum index ea18ced..377a7b1 100644 --- a/go.sum +++ b/go.sum @@ -14,5 +14,7 @@ golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= diff --git a/mcp/logging.go b/mcp/logging.go index b1bd82b..e77c6e1 100644 --- a/mcp/logging.go +++ b/mcp/logging.go @@ -13,6 +13,8 @@ import ( "slices" "sync" "time" + + "golang.org/x/time/rate" ) // Logging levels. @@ -83,10 +85,10 @@ type LoggingHandler struct { // Ensures that the buffer reset is atomic with the write (see Handle). // A pointer so that clones share the mutex. See // https://github.com/golang/example/blob/master/slog-handler-guide/README.md#getting-the-mutex-right. - mu *sync.Mutex - lastMessageSent time.Time // for rate-limiting - buf *bytes.Buffer - handler slog.Handler + mu *sync.Mutex + limiter *rate.Limiter // for rate-limiting + buf *bytes.Buffer + handler slog.Handler } // ensureLogger returns l if non-nil, otherwise a discard logger. @@ -118,6 +120,9 @@ func NewLoggingHandler(ss *ServerSession, opts *LoggingHandlerOptions) *LoggingH } if opts != nil { lh.opts = *opts + if opts.MinInterval > 0 { + lh.limiter = rate.NewLimiter(rate.Every(opts.MinInterval), 1) + } } return lh } @@ -157,11 +162,7 @@ func (h *LoggingHandler) Handle(ctx context.Context, r slog.Record) error { func (h *LoggingHandler) handle(ctx context.Context, r slog.Record) error { // Observe the rate limit. - // TODO(jba): use golang.org/x/time/rate. - h.mu.Lock() - skip := time.Since(h.lastMessageSent) < h.opts.MinInterval - h.mu.Unlock() - if skip { + if h.limiter != nil && !h.limiter.Allow() { return nil } @@ -184,10 +185,6 @@ func (h *LoggingHandler) handle(ctx context.Context, r slog.Record) error { return err } - h.mu.Lock() - h.lastMessageSent = time.Now() - h.mu.Unlock() - params := &LoggingMessageParams{ Logger: h.opts.LoggerName, Level: slogLevelToMCP(r.Level), From 438cd4384c23b7bac80b8db58c6db878d6a140f0 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Wed, 6 May 2026 15:43:26 +0200 Subject: [PATCH 275/418] mcp: OIDC-Flavored Refresh Token Guidance (#939) ## Description Implemets SEP-2207 (https://modelcontextprotocol.io/seps/2207-oidc-refresh-token-guidance) Fixes #940 --- auth/authorization_code.go | 25 ++++ auth/authorization_code_test.go | 126 ++++++++++++++++++ .../oauthtest/fake_authorization_server.go | 4 + 3 files changed, 155 insertions(+) diff --git a/auth/authorization_code.go b/auth/authorization_code.go index 758b88e..9b41e36 100644 --- a/auth/authorization_code.go +++ b/auth/authorization_code.go @@ -90,6 +90,23 @@ type AuthorizationCodeHandlerConfig struct { // See [AuthorizationCodeFetcher] for details. AuthorizationCodeFetcher AuthorizationCodeFetcher + // RequestRefreshToken indicates that the client intends to use refresh + // tokens and is capable of storing them securely. + // + // When true and the Authorization Server metadata contains "offline_access" + // in its scopes_supported, the client adds "offline_access" to the + // requested scopes. + // + // When using Dynamic Client Registration, callers should include + // "refresh_token" in [DynamicClientRegistrationConfig].Metadata.GrantTypes + // directly to advertise refresh token support to the Authorization Server. + // + // When using Client ID Metadata Document, the document hosted at the + // Client ID URL should include "refresh_token" in its grant_types. + // + // See https://modelcontextprotocol.io/seps/2207-oidc-refresh-token-guidance. + RequestRefreshToken bool + // Client is an optional HTTP client to use for HTTP requests. // It is used for the following requests: // - Fetching Protected Resource Metadata @@ -263,6 +280,14 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ scps = prm.ScopesSupported } + // SEP-2207: when the client desires refresh tokens and the Authorization + // Server advertises offline_access support, add it to the requested scopes. + if h.config.RequestRefreshToken && + slices.Contains(asm.ScopesSupported, "offline_access") && + !slices.Contains(scps, "offline_access") { + scps = append(scps, "offline_access") + } + cfg := &oauth2.Config{ ClientID: resolvedClientConfig.clientID, ClientSecret: resolvedClientConfig.clientSecret, diff --git a/auth/authorization_code_test.go b/auth/authorization_code_test.go index 92b6faf..d30cbf3 100644 --- a/auth/authorization_code_test.go +++ b/auth/authorization_code_test.go @@ -10,6 +10,8 @@ import ( "net/http" "net/http/httptest" "net/http/httputil" + "net/url" + "slices" "strings" "testing" @@ -738,6 +740,130 @@ func TestApplicationTypeInference(t *testing.T) { } } +func TestAuthorize_OfflineAccessScope(t *testing.T) { + tests := []struct { + name string + requestRefreshToken bool + asScopesSupported []string + challengeScopes string + wantOfflineAccess bool + }{ + { + name: "AddedWhenASSupportsAndClientRequests", + requestRefreshToken: true, + asScopesSupported: []string{"openid", "offline_access"}, + wantOfflineAccess: true, + }, + { + name: "NotAddedWhenClientDoesNotRequest", + requestRefreshToken: false, + asScopesSupported: []string{"openid", "offline_access"}, + wantOfflineAccess: false, + }, + { + name: "NotAddedWhenASDoesNotSupport", + requestRefreshToken: true, + asScopesSupported: []string{"openid"}, + wantOfflineAccess: false, + }, + { + name: "NotDuplicatedWhenAlreadyInScopes", + requestRefreshToken: true, + asScopesSupported: []string{"openid", "offline_access"}, + challengeScopes: "read offline_access", + wantOfflineAccess: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + ScopesSupported: tt.asScopesSupported, + RegistrationConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "test_client_id": { + Secret: "test_client_secret", + RedirectURIs: []string{"http://localhost:12345/callback"}, + }, + }, + }, + }) + authServer.Start(t) + + resourceMux := http.NewServeMux() + resourceServer := httptest.NewServer(resourceMux) + t.Cleanup(resourceServer.Close) + resourceURL := resourceServer.URL + "/resource" + resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{ + Resource: resourceURL, + AuthorizationServers: []string{authServer.URL()}, + })) + + var capturedAuthURL string + handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{ + RedirectURL: "http://localhost:12345/callback", + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "test_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "test_client_secret", + }, + }, + RequestRefreshToken: tt.requestRefreshToken, + AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + capturedAuthURL = args.URL + return nil, fmt.Errorf("stop after capturing URL") + }, + }) + if err != nil { + t.Fatalf("NewAuthorizationCodeHandler failed: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, resourceURL, nil) + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: http.NoBody, + Request: req, + } + wwwAuth := "Bearer resource_metadata=" + resourceServer.URL + "/.well-known/oauth-protected-resource/resource" + if tt.challengeScopes != "" { + wwwAuth += fmt.Sprintf(", scope=%q", tt.challengeScopes) + } + resp.Header.Set("WWW-Authenticate", wwwAuth) + + // Authorize will fail at the fetcher, but we only care about the URL. + handler.Authorize(context.Background(), req, resp) + + if capturedAuthURL == "" { + t.Fatal("AuthorizationCodeFetcher was not called") + } + u, err := url.Parse(capturedAuthURL) + if err != nil { + t.Fatalf("failed to parse captured auth URL: %v", err) + } + scopes := strings.Fields(u.Query().Get("scope")) + hasOfflineAccess := slices.Contains(scopes, "offline_access") + if hasOfflineAccess != tt.wantOfflineAccess { + t.Errorf("offline_access in scopes = %v, want %v (scopes: %v)", hasOfflineAccess, tt.wantOfflineAccess, scopes) + } + + // When offline_access was already present in challenge scopes, + // verify it appears exactly once. + if tt.wantOfflineAccess { + count := 0 + for _, s := range scopes { + if s == "offline_access" { + count++ + } + } + if count != 1 { + t.Errorf("offline_access appears %d times in scopes, want 1", count) + } + } + }) + } +} + // validConfig for test to create an AuthorizationCodeHandler using its constructor. // Values that are relevant to the test should be set explicitly. func validConfig() *AuthorizationCodeHandlerConfig { diff --git a/internal/oauthtest/fake_authorization_server.go b/internal/oauthtest/fake_authorization_server.go index 33c25e8..94d725a 100644 --- a/internal/oauthtest/fake_authorization_server.go +++ b/internal/oauthtest/fake_authorization_server.go @@ -82,6 +82,9 @@ type Config struct { // ClientCredentialsConfig enables RFC 6749 Section 4.4 client credentials // grant at the /token endpoint. ClientCredentialsConfig *ClientCredentialsConfig + // ScopesSupported is an optional list of scopes to advertise in the + // authorization server metadata. + ScopesSupported []string } // FakeAuthorizationServer is a fake OAuth 2.0 Authorization Server for testing. @@ -166,6 +169,7 @@ func (s *FakeAuthorizationServer) handleMetadata(w http.ResponseWriter, r *http. AuthorizationEndpoint: s.URL() + s.config.IssuerPath + "/authorize", TokenEndpoint: s.URL() + s.config.IssuerPath + "/token", RegistrationEndpoint: registrationEndpoint, + ScopesSupported: s.config.ScopesSupported, ResponseTypesSupported: []string{"code"}, CodeChallengeMethodsSupported: []string{"S256"}, ClientIDMetadataDocumentSupported: cimdSupported, From d10c31500822f4b1fb2f1a338667385e169d6b41 Mon Sep 17 00:00:00 2001 From: Justin Chang Date: Thu, 7 May 2026 05:28:38 -0700 Subject: [PATCH 276/418] mcp: write SSE comment on standalone stream so HTTP/2 reverse proxies flush HEADERS frame (#938) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #937. ### What this changes Adds a single line — an SSE comment (`: ok\n\n`) — after `WriteHeader` and before `Flush` on the standalone SSE GET stream. SSE comments are explicitly ignored by clients (any line starting with `:` per the SSE spec), but they produce an HTTP/2 DATA frame, which is what HTTP/2 reverse proxies need before they will forward the HEADERS frame. ### Why the existing fix isn't sufficient Issue #410 was closed by #413, which added `WriteHeader(StatusOK)` + `Flush()` to the same site. #870 refactored the `Flush()` to use `http.NewResponseController`. Both correctly address HTTP/1.1: `Flush()` writes the headers as text on the TCP stream and any HTTP/1.1-aware proxy forwards them. In HTTP/2, headers and body are separate frame types (HEADERS, DATA). Reverse proxies batch them for efficiency and won't forward the HEADERS frame on its own. `Flush()` on the response writer only pushes the in-process buffer to the HTTP/2 stack; the proxy still holds the HEADERS frame waiting for DATA. The standalone SSE stream never sends body data on connect (the whole point — it's the long-poll listener for server-pushed notifications), so without a deliberate "kick" the headers never reach the client until the proxy tears down the stream on timeout. The SSE-comment trick is the established mitigation for SSE servers behind HTTP/2 proxies. It's used in Caddy (https://github.com/caddyserver/caddy/issues/4247), referenced in the Go issue tracker (https://github.com/golang/go/issues/31125), and is independent of the proxy implementation. ### The diff ```go if s.id == "" { // Issue #410: the standalone SSE stream is likely not to receive messages // for a long time. Ensure that headers are flushed. + // + // On HTTP/2, headers and body travel as separate frames (HEADERS and + // DATA). Reverse proxies (e.g. Envoy, Caddy, net/http/httputil) + // commonly buffer the HEADERS frame until they have a DATA frame to + // coalesce it with — there is no HTTP/2 equivalent of HTTP/1.1's + // Transfer-Encoding: chunked signal that says "this is streaming, send + // headers now". Calling Flush() alone is not sufficient: it pushes + // the kernel buffer to the proxy, but the proxy still holds the + // HEADERS frame. + // + // Write an SSE comment (lines starting with ":" are ignored by + // clients per RFC) so a DATA frame is produced, which forces the + // proxy to forward both frames. See: + // https://github.com/golang/go/issues/31125 + // https://github.com/caddyserver/caddy/issues/4247 w.WriteHeader(http.StatusOK) + fmt.Fprint(w, ": ok\n\n") rc := http.NewResponseController(w) // Ignore returned error as flushing is best-effort. _ = rc.Flush() } ``` `fmt` is already imported in this file; no other dependencies change. ### Test Added `TestStandaloneSSEEmitsCommentForHTTP2Flush`. It initializes a streamable session via raw POST, then opens the standalone SSE GET and asserts the first body byte starts with `:` (the SSE comment marker, which is what produces the DATA frame HTTP/2 proxies need). The test catches the bug behaviorally without requiring a full HTTP/2 reverse-proxy setup: it verifies the SDK's contract (emit body bytes after headers on the standalone SSE stream). HTTP/2-specific behavior is then a property of any conforming proxy. I confirmed the test fails on `main` without the patch — it times out at 2s waiting for the first body byte — and passes in <1ms with the patch. Full streamable test suite still passes. ### Reproduction matrix (from #937) | Configuration | TTFB | Result | |---|---|---| | HTTP/2 + 30s request timeout | ~31s | Headers only on stream tear-down | | HTTP/2 + no request timeout | never | Headers never arrive | | HTTP/1.1 + 30s request timeout | ~300ms | Headers immediate | | HTTP/1.1 + no request timeout | ~270ms | Headers immediate | | HTTP/2 POST (response has body) | ~350ms | Works (DATA frame coalesces) | | **HTTP/2 + this PR** | ~1ms | Headers immediate | ### Risk - Behavior-preserving for HTTP/1.1 clients (a leading `:` line is a valid SSE comment and is ignored by every conforming SSE client). - No protocol-level concern: SSE explicitly defines comment lines as a no-op for the consumer. - The DATA frame adds 7 bytes per standalone SSE connection, sent once on connect. Co-authored-by: Maciej Kisiel --- mcp/streamable.go | 16 +++++++ mcp/streamable_test.go | 96 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/mcp/streamable.go b/mcp/streamable.go index 0653171..7b05373 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1061,7 +1061,23 @@ func (c *streamableServerConn) acquireStream(ctx context.Context, w http.Respons if s.id == "" { // Issue #410: the standalone SSE stream is likely not to receive messages // for a long time. Ensure that headers are flushed. + // + // On HTTP/2, headers and body travel as separate frames (HEADERS and + // DATA). Reverse proxies (e.g. Envoy, Caddy, net/http/httputil) + // commonly buffer the HEADERS frame until they have a DATA frame to + // coalesce it with — there is no HTTP/2 equivalent of HTTP/1.1's + // Transfer-Encoding: chunked signal that says "this is streaming, send + // headers now". Calling Flush() alone is not sufficient: it pushes + // the kernel buffer to the proxy, but the proxy still holds the + // HEADERS frame. + // + // Write an SSE comment (lines starting with ":" are ignored by + // clients per RFC) so a DATA frame is produced, which forces the + // proxy to forward both frames. See: + // https://github.com/golang/go/issues/31125 + // https://github.com/caddyserver/caddy/issues/4247 w.WriteHeader(http.StatusOK) + fmt.Fprint(w, ": ok\n\n") rc := http.NewResponseController(w) // Ignore returned error as flushing is best-effort. _ = rc.Flush() diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 80fb4ba..118d05d 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -2904,3 +2904,99 @@ func TestStreamableOriginProtection(t *testing.T) { }) } } + +// TestStandaloneSSEEmitsCommentForHTTP2Flush is a regression test for the +// HTTP/2 reverse-proxy header-buffering bug. The standalone SSE GET stream +// must emit at least one body byte (an SSE comment) immediately after the +// response headers. Without a DATA frame, HTTP/2 reverse proxies (Envoy, +// Caddy, net/http/httputil, etc.) buffer the HEADERS frame indefinitely +// because they have nothing to coalesce it with — calling Flush() alone is +// not sufficient. +// +// SSE explicitly defines lines starting with ":" as comments that clients +// ignore, so this is behavior-preserving for the SSE protocol while +// producing the DATA frame that HTTP/2 proxies need. +func TestStandaloneSSEEmitsCommentForHTTP2Flush(t *testing.T) { + server := NewServer(testImpl, nil) + handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + defer httpServer.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Initialize a session so we have a valid Mcp-Session-Id to use on the + // standalone SSE GET. + initBody := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`) + initReq, err := http.NewRequestWithContext(ctx, http.MethodPost, httpServer.URL, initBody) + if err != nil { + t.Fatal(err) + } + initReq.Header.Set("Content-Type", "application/json") + initReq.Header.Set("Accept", "application/json, text/event-stream") + initResp, err := http.DefaultClient.Do(initReq) + if err != nil { + t.Fatal(err) + } + io.Copy(io.Discard, initResp.Body) + initResp.Body.Close() + sessionID := initResp.Header.Get(sessionIDHeader) + if sessionID == "" { + t.Fatalf("initialize response missing %s header", sessionIDHeader) + } + + // Open the standalone SSE stream. + getReq, err := http.NewRequestWithContext(ctx, http.MethodGet, httpServer.URL, nil) + if err != nil { + t.Fatal(err) + } + getReq.Header.Set("Accept", "text/event-stream") + getReq.Header.Set(sessionIDHeader, sessionID) + + resp, err := http.DefaultClient.Do(getReq) + if err != nil { + t.Fatalf("GET standalone SSE: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d; want 200", resp.StatusCode) + } + if got := resp.Header.Get("Content-Type"); !strings.HasPrefix(got, "text/event-stream") { + t.Fatalf("Content-Type = %q; want text/event-stream", got) + } + + // Read the first chunk. With the fix, an SSE comment (": ok\n\n") is + // written immediately. Without the fix, the body has no bytes to read + // until the server pushes an event, which won't happen in this test. + type readResult struct { + n int + err error + buf []byte + } + ch := make(chan readResult, 1) + go func() { + buf := make([]byte, 64) + n, err := resp.Body.Read(buf) + ch <- readResult{n: n, err: err, buf: buf} + }() + + select { + case r := <-ch: + if r.err != nil && r.err != io.EOF { + t.Fatalf("reading first SSE chunk: %v", r.err) + } + if r.n == 0 { + t.Fatal("first SSE chunk was empty; expected an SSE comment to flush HTTP/2 proxy HEADERS frame") + } + got := string(r.buf[:r.n]) + // SSE spec: lines starting with ":" are comments, ignored by clients. + // We don't pin the exact comment text; just require the first byte to + // be the SSE comment marker so HTTP/2 proxies have a DATA frame. + if !strings.HasPrefix(got, ":") { + t.Errorf("first SSE chunk = %q; want it to start with %q (SSE comment marker, so HTTP/2 proxies receive a DATA frame and forward HEADERS)", got, ":") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for first SSE bytes; the standalone SSE stream must emit a DATA frame immediately so HTTP/2 reverse proxies don't buffer the HEADERS frame") + } +} From 909372d8d1bbe08de1350dd4d0eb3e8494d4dd52 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Fri, 8 May 2026 12:39:18 +0200 Subject: [PATCH 277/418] mcp: HTTP Header Standardization for x-mcp-header (#915) ## Description Implements [SEP-2243](https://modelcontextprotocol.io/seps/2243-http-standardization) (HTTP Header Standardization) for x-mcp-param custom header. Fixes https://github.com/modelcontextprotocol/go-sdk/issues/905 --------- Co-authored-by: Maciej Kisiel --- mcp/client.go | 39 +- mcp/client_test.go | 74 +++ mcp/server.go | 14 +- mcp/streamable.go | 9 +- mcp/streamable_headers.go | 323 ++++++++++++- mcp/streamable_headers_test.go | 831 ++++++++++++++++++++++++++++++++- mcp/streamable_test.go | 237 +++++++++- 7 files changed, 1512 insertions(+), 15 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index c82e189..6e24c5a 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -160,6 +160,12 @@ type ClientOptions struct { KeepAlive time.Duration } +// toolContextKeyType is the context key type for passing tool definitions +// from CallTool to the transport layer. +type toolContextKeyType struct{} + +var toolContextKey = toolContextKeyType{} + // bind implements the binder[*ClientSession] interface, so that Clients can // be connected using [connect]. func (c *Client) bind(mcpConn Connection, conn *jsonrpc2.Connection, state *clientSessionState, onClose func()) *ClientSession { @@ -318,6 +324,13 @@ type ClientSession struct { // Pending URL elicitations waiting for completion notifications. pendingElicitationsMu sync.Mutex pendingElicitations map[string]chan struct{} + + // toolCacheMu guards toolCache. + toolCacheMu sync.RWMutex + // toolCache stores tool definitions keyed by name. + // It is used to look up x-mcp-header annotations when + // constructing Mcp-Param-* headers for tools/call requests. + toolCache map[string]*Tool } type clientSessionState struct { @@ -363,6 +376,21 @@ func (cs *ClientSession) Wait() error { return cs.conn.Wait() } +func (cs *ClientSession) cacheTools(tools []*Tool) { + cs.toolCacheMu.Lock() + defer cs.toolCacheMu.Unlock() + cs.toolCache = make(map[string]*Tool, len(tools)) + for _, tool := range tools { + cs.toolCache[tool.Name] = tool + } +} + +func (cs *ClientSession) getCachedTool(name string) *Tool { + cs.toolCacheMu.RLock() + defer cs.toolCacheMu.RUnlock() + return cs.toolCache[name] +} + // registerElicitationWaiter registers a waiter for an elicitation complete // notification with the given elicitation ID. It returns two functions: an await // function that waits for the notification or context cancellation, and a cleanup @@ -981,7 +1009,13 @@ func (cs *ClientSession) GetPrompt(ctx context.Context, params *GetPromptParams) // ListTools lists tools that are currently available on the server. func (cs *ClientSession) ListTools(ctx context.Context, params *ListToolsParams) (*ListToolsResult, error) { - return handleSend[*ListToolsResult](ctx, methodListTools, newClientRequest(cs, orZero[Params](params))) + result, err := handleSend[*ListToolsResult](ctx, methodListTools, newClientRequest(cs, orZero[Params](params))) + if err != nil { + return nil, err + } + result.Tools = filterValidTools(cs.client.opts.Logger, result.Tools) + cs.cacheTools(result.Tools) + return result, nil } // CallTool calls the tool with the given parameters. @@ -995,6 +1029,9 @@ func (cs *ClientSession) CallTool(ctx context.Context, params *CallToolParams) ( // Avoid sending nil over the wire. params.Arguments = map[string]any{} } + if tool := cs.getCachedTool(params.Name); tool != nil { + ctx = context.WithValue(ctx, toolContextKey, tool) + } return handleSend[*CallToolResult](ctx, methodCallTool, newClientRequest(cs, orZero[Params](params))) } diff --git a/mcp/client_test.go b/mcp/client_test.go index fc37c3e..609fd50 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -440,6 +440,80 @@ func TestClientCapabilities(t *testing.T) { } } +func TestToolCache(t *testing.T) { + tool1 := &Tool{Name: "tool1", Description: "first"} + tool2 := &Tool{Name: "tool2", Description: "second"} + tool1Updated := &Tool{Name: "tool1", Description: "updated"} + + testCases := []struct { + name string + cacheBatches [][]*Tool + lookup string + want *Tool + }{ + { + name: "empty cache", + lookup: "tool1", + want: nil, + }, + { + name: "single tool found", + cacheBatches: [][]*Tool{{tool1}}, + lookup: "tool1", + want: tool1, + }, + { + name: "unknown tool", + cacheBatches: [][]*Tool{{tool1}}, + lookup: "nonexistent", + want: nil, + }, + { + name: "multiple tools single batch", + cacheBatches: [][]*Tool{{tool1, tool2}}, + lookup: "tool2", + want: tool2, + }, + { + name: "replace clears old entries", + cacheBatches: [][]*Tool{{tool1}, {tool2}}, + lookup: "tool1", + want: nil, + }, + { + name: "replace keeps new entries", + cacheBatches: [][]*Tool{{tool1}, {tool2}}, + lookup: "tool2", + want: tool2, + }, + { + name: "overwrite existing entry", + cacheBatches: [][]*Tool{{tool1}, {tool1Updated}}, + lookup: "tool1", + want: tool1Updated, + }, + { + name: "empty batch no-op", + cacheBatches: [][]*Tool{{}}, + lookup: "tool1", + want: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cs := &ClientSession{} + for _, batch := range tc.cacheBatches { + cs.cacheTools(batch) + } + got := cs.getCachedTool(tc.lookup) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("getCachedTool(%q) mismatch (-want +got):\n%s", tc.lookup, diff) + } + }) + } +} + func TestClientCapabilitiesOverWire(t *testing.T) { testCases := []struct { name string diff --git a/mcp/server.go b/mcp/server.go index 16d06ca..d25c792 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -280,6 +280,9 @@ func (s *Server) AddTool(t *Tool, h ToolHandler) { } } } + if err := validateParamHeaderAnnotations(t); err != nil { + panic(fmt.Errorf("AddTool %q: invalid parameter header annotations: %v", t.Name, err)) + } st := &serverTool{tool: t, handler: h} // Assume there was a change, since add replaces existing tools. // (It's possible a tool was replaced with an identical one, but not worth checking.) @@ -753,10 +756,15 @@ func (s *Server) listTools(_ context.Context, req *ListToolsRequest) (*ListTools }) } -func (s *Server) callTool(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { +// getServerTool looks up a server tool by name. +func (s *Server) getServerTool(name string) (*serverTool, bool) { s.mu.Lock() - st, ok := s.tools.get(req.Params.Name) - s.mu.Unlock() + defer s.mu.Unlock() + return s.tools.get(name) +} + +func (s *Server) callTool(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { + st, ok := s.getServerTool(req.Params.Name) if !ok { return nil, &jsonrpc.Error{ Code: jsonrpc.CodeInvalidParams, diff --git a/mcp/streamable.go b/mcp/streamable.go index 7b05373..da13537 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -491,6 +491,7 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque http.Error(w, "failed connection", http.StatusInternalServerError) return } + transport.connection.toolLookup = server.getServerTool // Capture the user ID from the token info to enable session hijacking // prevention on subsequent requests. var userID string @@ -669,6 +670,8 @@ type streamableServerConn struct { logger *slog.Logger + toolLookup func(name string) (*serverTool, bool) + incoming chan jsonrpc.Message // messages from the client to the server mu sync.Mutex // guards all fields below @@ -1202,9 +1205,9 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques } } - // Validate MCP standard headers (Mcp-Method, Mcp-Name) + // Validate MCP standard headers (Mcp-Method, Mcp-Name, Mcp-Param-*) if !isBatch && len(incoming) == 1 { - if err := validateMcpHeaders(req.Header, incoming[0]); err != nil { + if err := validateMcpHeaders(req.Header, incoming[0], c.toolLookup); err != nil { resp := &jsonrpc.Response{ Error: jsonrpc2.NewError(CodeHeaderMismatch, err.Error()), } @@ -1829,7 +1832,7 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e } // Keep this after the setMCPHeaders call to ensure that the // protocol version header is set. - setStandardHeaders(req.Header, msg) + setStandardHeaders(ctx, req.Header, msg) resp, err := c.client.Do(req) if err != nil { // Any error from client.Do means the request didn't reach the server. diff --git a/mcp/streamable_headers.go b/mcp/streamable_headers.go index ac9a412..fba1b4d 100644 --- a/mcp/streamable_headers.go +++ b/mcp/streamable_headers.go @@ -5,10 +5,14 @@ package mcp import ( + "context" + "encoding/base64" "encoding/json" "errors" "fmt" + "log/slog" "net/http" + "strings" internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/jsonrpc" @@ -20,7 +24,10 @@ const ( lastEventIDHeader = "Last-Event-ID" methodHeader = "Mcp-Method" nameHeader = "Mcp-Name" + paramHeaderPrefix = "Mcp-Param-" minVersionForStandardHeaders = protocolVersion20260630 + base64Prefix = "=?base64?" + base64Suffix = "?=" ) func extractName(method string, params json.RawMessage) (string, bool) { @@ -45,9 +52,79 @@ func extractName(method string, params json.RawMessage) (string, bool) { return "", false } +// headerSchemaProperty captures the fields needed for x-mcp-header processing. +type headerSchemaProperty struct { + Type string `json:"type"` + XMCPHeader json.RawMessage `json:"x-mcp-header,omitempty"` + Properties map[string]headerSchemaProperty `json:"properties,omitempty"` +} + +// unmarshalSchemaProperties normalizes any InputSchema type +// (*jsonschema.Schema, map[string]any, or json.RawMessage) into a common +// representation by marshaling to JSON and unmarshaling only the fields we need. +func unmarshalSchemaProperties(schema any) map[string]headerSchemaProperty { + var s headerSchemaProperty + if err := remarshal(schema, &s); err != nil { + return nil + } + return s.Properties +} + +// extractParamHeaderAnnotations returns a map of parameter name to header name +// for all properties in the tool's InputSchema that have an x-mcp-header +// annotation. +func extractParamHeaderAnnotations(tool *Tool) map[string]string { + props := unmarshalSchemaProperties(tool.InputSchema) + if len(props) == 0 { + return nil + } + result := make(map[string]string) + for propName, prop := range props { + var headerName string + if err := json.Unmarshal(prop.XMCPHeader, &headerName); err != nil || headerName == "" { + continue + } + result[propName] = headerName + } + if len(result) == 0 { + return nil + } + return result +} + +// primitiveToString conversion. +// Returns false in the second return value if the argument is not a primitive value. +func primitiveToString(value any) (string, bool) { + switch v := value.(type) { + case string: + return v, true + case float64: + return fmt.Sprintf("%g", v), true + case bool: + return fmt.Sprintf("%t", v), true + default: + return "", false + } +} + +// unmarshalPrimitive unmarshals a JSON value into a Go primitive +// (string, float64, or bool). Returns nil for non-primitive types. +func unmarshalPrimitive(raw json.RawMessage) any { + var val any + if err := internaljson.Unmarshal(raw, &val); err != nil { + return nil + } + switch val.(type) { + case string, float64, bool: + return val + default: + return nil + } +} + // setStandardHeaders populates standard MCP headers. // It requires the protocol version header to be set. -func setStandardHeaders(header http.Header, msg jsonrpc.Message) { +func setStandardHeaders(ctx context.Context, header http.Header, msg jsonrpc.Message) { if msg == nil { return } @@ -61,10 +138,128 @@ func setStandardHeaders(header http.Header, msg jsonrpc.Message) { if name, ok := extractName(msg.Method, msg.Params); ok { header.Set(nameHeader, name) } + if msg.Method == "tools/call" { + if tool, ok := ctx.Value(toolContextKey).(*Tool); ok && tool != nil { + for k, v := range generateParamHeaders(tool, msg.Params) { + header.Set(k, v) + } + } + } } } -func validateMcpHeaders(header http.Header, msg jsonrpc.Message) error { +// generateParamHeaders reads x-mcp-header annotations from the tool's InputSchema +// and returns the Mcp-Param-{Name} headers to be set on the HTTP request. +func generateParamHeaders(tool *Tool, params json.RawMessage) map[string]string { + paramHeaders := extractParamHeaderAnnotations(tool) + if len(paramHeaders) == 0 { + return nil + } + + var raw struct { + Arguments map[string]json.RawMessage `json:"arguments"` + } + if err := internaljson.Unmarshal(params, &raw); err != nil || raw.Arguments == nil { + return nil + } + + res := make(map[string]string) + for paramName, headerName := range paramHeaders { + argRaw, ok := raw.Arguments[paramName] + if !ok { + continue + } + if string(argRaw) == "null" { + continue + } + val := unmarshalPrimitive(argRaw) + if val == nil { + continue + } + encoded, ok := encodeHeaderValue(val) + if !ok { + continue + } + res[paramHeaderPrefix+headerName] = encoded + } + return res +} + +// filterValidTools returns only tools that have valid +// x-mcp-header annotations. Invalid tools are logged and excluded. +func filterValidTools(logger *slog.Logger, tools []*Tool) []*Tool { + logger = ensureLogger(logger) + result := make([]*Tool, 0, len(tools)) + for _, tool := range tools { + if err := validateParamHeaderAnnotations(tool); err != nil { + logger.Error("excluding tool from tools/list", "tool", tool.Name, "error", err) + continue + } + result = append(result, tool) + } + return result +} + +// validateParamHeaderAnnotations checks that a tool's x-mcp-header annotations +// are valid. +func validateParamHeaderAnnotations(tool *Tool) error { + props := unmarshalSchemaProperties(tool.InputSchema) + if len(props) == 0 { + return nil + } + + seen := make(map[string]bool) + for propName, prop := range props { + if err := checkForNestedHeaders(prop, propName); err != nil { + return err + } + if prop.XMCPHeader == nil { + continue + } + var headerName string + if err := json.Unmarshal(prop.XMCPHeader, &headerName); err != nil || headerName == "" { + return fmt.Errorf("property %q: x-mcp-header must be a non-empty string", propName) + } + if err := validateHeaderName(headerName); err != nil { + return fmt.Errorf("property %q: %w", propName, err) + } + lower := strings.ToLower(headerName) + if seen[lower] { + return fmt.Errorf("property %q: duplicate x-mcp-header value %q (case-insensitive)", propName, headerName) + } + seen[lower] = true + + if prop.Type != "string" && prop.Type != "number" && prop.Type != "integer" && prop.Type != "boolean" { + return fmt.Errorf("property %q: x-mcp-header can only be applied to primitive types, got %v", propName, prop.Type) + } + } + return nil +} + +func checkForNestedHeaders(prop headerSchemaProperty, path string) error { + for propName, nested := range prop.Properties { + if nested.XMCPHeader != nil { + return fmt.Errorf("property %q: x-mcp-header cannot be applied to nested properties", path+"."+propName) + } + if err := checkForNestedHeaders(nested, path+"."+propName); err != nil { + return err + } + } + return nil +} + +// validateHeaderName checks that a header name contains only valid +// ASCII characters (excluding space and ':'). +func validateHeaderName(name string) error { + for _, c := range name { + if c <= 0x20 || c > 0x7E || c == ':' { + return fmt.Errorf("x-mcp-header value %q contains invalid character %q", name, c) + } + } + return nil +} + +func validateMcpHeaders(header http.Header, msg jsonrpc.Message, toolLookup func(string) (*serverTool, bool)) error { protocolVersion := header.Get(protocolVersionHeader) if protocolVersion == "" || protocolVersion < minVersionForStandardHeaders { return nil @@ -80,12 +275,14 @@ func validateMcpHeaders(header http.Header, msg jsonrpc.Message) error { return fmt.Errorf("header mismatch: Mcp-Method header value '%s' does not match body value '%s'", methodInHeader, msg.Method) } + var nameInBody string if msg.Method == "tools/call" || msg.Method == "resources/read" || msg.Method == "prompts/get" { nameInHeader := header.Get(nameHeader) if nameInHeader == "" { return fmt.Errorf("missing required Mcp-Name header for method %q", msg.Method) } - nameInBody, ok := extractName(msg.Method, msg.Params) + var ok bool + nameInBody, ok = extractName(msg.Method, msg.Params) if !ok { return fmt.Errorf("failed to extract name from parameters for method %q", msg.Method) } @@ -93,6 +290,126 @@ func validateMcpHeaders(header http.Header, msg jsonrpc.Message) error { return fmt.Errorf("header mismatch: Mcp-Name header value '%s' does not match body value '%s'", nameInHeader, nameInBody) } } + + if msg.Method == "tools/call" && toolLookup != nil { + if st, ok := toolLookup(nameInBody); ok && st != nil { + if err := validateParamHeaders(header, msg, st.tool); err != nil { + return err + } + } + } } return nil } + +func validateParamHeaders(header http.Header, msg *jsonrpc.Request, tool *Tool) error { + paramHeaders := extractParamHeaderAnnotations(tool) + if len(paramHeaders) == 0 { + return nil + } + + var raw struct { + Arguments map[string]json.RawMessage `json:"arguments"` + } + if err := internaljson.Unmarshal(msg.Params, &raw); err != nil { + return nil + } + + for paramName, headerName := range paramHeaders { + fullHeader := paramHeaderPrefix + headerName + headerVal := header.Get(fullHeader) + argRaw, argExists := raw.Arguments[paramName] + + if !argExists || string(argRaw) == "null" { + if headerVal != "" { + return fmt.Errorf("header mismatch: unexpected %s header for absent or null parameter %q", fullHeader, paramName) + } + continue + } + + if headerVal == "" { + return fmt.Errorf("header mismatch: missing %s header for parameter %q", fullHeader, paramName) + } + + decoded, ok := decodeHeaderValue(headerVal) + if !ok { + return fmt.Errorf("header mismatch: %s header contains invalid Base64 encoding", fullHeader) + } + + bodyVal := unmarshalPrimitive(argRaw) + if bodyVal == nil { + return fmt.Errorf("header mismatch: %s header present but body parameter %q is not a primitive type", fullHeader, paramName) + } + expected, ok := primitiveToString(bodyVal) + if !ok { + return fmt.Errorf("header mismatch: %s header present but body parameter %q is not a primitive type", fullHeader, paramName) + } + + // TODO: String comparison may not work ideally for numbers + if decoded != expected { + return fmt.Errorf("header mismatch: %s header value '%s' does not match body value", fullHeader, headerVal) + } + } + return nil +} + +// encodeHeaderValue converts a parameter value to an HTTP header-safe string +// per the SEP-2243 encoding rules: +// - string: used as-is if safe ASCII, otherwise Base64 encoded +// - number (float64): decimal string representation +// - bool: lowercase "true" or "false" +// +// Values that contain non-ASCII characters, control characters, or +// leading/trailing whitespace are Base64-encoded with the =?base64?...?= wrapper. +// +// The second return value is false if the value is not a supported primitive type. +func encodeHeaderValue(value any) (string, bool) { + s, ok := primitiveToString(value) + if !ok { + return "", false + } + if requiresBase64Encoding(s) { + return encodeBase64(s), true + } + return s, true +} + +// decodeHeaderValue decodes a header value that may be Base64-encoded +// with the =?base64?...?= wrapper. +// +// The second return value is false if the header value is not a valid Base64 encoded value. +func decodeHeaderValue(headerValue string) (string, bool) { + if len(headerValue) == 0 { + return headerValue, true + } + + if strings.HasPrefix(strings.ToLower(headerValue), base64Prefix) && + strings.HasSuffix(headerValue, base64Suffix) { + encoded := headerValue[len(base64Prefix) : len(headerValue)-len(base64Suffix)] + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", false + } + return string(decoded), true + } + return headerValue, true +} + +func requiresBase64Encoding(s string) bool { + if len(s) == 0 { + return false + } + if s[0] == ' ' || s[0] == '\t' || s[len(s)-1] == ' ' || s[len(s)-1] == '\t' { + return true + } + for _, c := range s { + if c < 0x20 || c > 0x7E { + return true + } + } + return false +} + +func encodeBase64(s string) string { + return base64Prefix + base64.StdEncoding.EncodeToString([]byte(s)) + base64Suffix +} diff --git a/mcp/streamable_headers_test.go b/mcp/streamable_headers_test.go index 0abf1b2..88e9f46 100644 --- a/mcp/streamable_headers_test.go +++ b/mcp/streamable_headers_test.go @@ -5,11 +5,14 @@ package mcp import ( + "context" "encoding/json" + "fmt" "net/http" "strings" "testing" + "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) @@ -198,7 +201,7 @@ func TestSetStandardHeaders(t *testing.T) { header.Set(protocolVersionHeader, tt.protocolVersion) } - setStandardHeaders(header, tt.msg) + setStandardHeaders(context.Background(), header, tt.msg) if got := header.Get(methodHeader); got != tt.wantMethodHeader { t.Errorf("MethodHeader = %q, want %q", got, tt.wantMethodHeader) @@ -399,7 +402,7 @@ func TestValidateMcpHeaders(t *testing.T) { header.Set(nameHeader, tt.nameHeader) } - err := validateMcpHeaders(header, tt.msg) + err := validateMcpHeaders(header, tt.msg, nil) if tt.wantErr { if err == nil { t.Fatalf("validateMcpHeaders() = nil, want error containing %q", tt.wantErrContain) @@ -413,3 +416,827 @@ func TestValidateMcpHeaders(t *testing.T) { }) } } + +func TestValidateToolParamHeaders(t *testing.T) { + tests := []struct { + name string + tool *Tool + wantErr bool + wantErrSub string + }{ + { + name: "valid tool with x-mcp-header", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "Region", + }, + }, + }, + }, + }, + { + name: "tool with no x-mcp-header annotations", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{"type": "string"}, + }, + }, + }, + }, + { + name: "empty x-mcp-header value", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "", + }, + }, + }, + }, + wantErr: true, + wantErrSub: "non-empty string", + }, + { + name: "x-mcp-header with space", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "My Region", + }, + }, + }, + }, + wantErr: true, + wantErrSub: "invalid character", + }, + { + name: "x-mcp-header with colon", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "Region:Primary", + }, + }, + }, + }, + wantErr: true, + wantErrSub: "invalid character", + }, + { + name: "x-mcp-header with non-ASCII", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "Région", + }, + }, + }, + }, + wantErr: true, + wantErrSub: "invalid character", + }, + { + name: "duplicate header names same case", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "a": map[string]any{"type": "string", "x-mcp-header": "Region"}, + "b": map[string]any{"type": "string", "x-mcp-header": "Region"}, + }, + }, + }, + wantErr: true, + wantErrSub: "duplicate", + }, + { + name: "duplicate header names different case", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "a": map[string]any{"type": "string", "x-mcp-header": "Region"}, + "b": map[string]any{"type": "string", "x-mcp-header": "REGION"}, + }, + }, + }, + wantErr: true, + wantErrSub: "duplicate", + }, + { + name: "x-mcp-header on array type", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "items": map[string]any{ + "type": "array", + "x-mcp-header": "Items", + }, + }, + }, + }, + wantErr: true, + wantErrSub: "primitive types", + }, + { + name: "x-mcp-header on object type", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "nested": map[string]any{ + "type": "object", + "x-mcp-header": "Nested", + }, + }, + }, + }, + wantErr: true, + wantErrSub: "primitive types", + }, + { + name: "x-mcp-header on number type is valid", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "count": map[string]any{ + "type": "number", + "x-mcp-header": "Count", + }, + }, + }, + }, + }, + { + name: "x-mcp-header on integer type is valid", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "count": map[string]any{ + "type": "integer", + "x-mcp-header": "Count", + }, + }, + }, + }, + }, + { + name: "x-mcp-header on boolean type is valid", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "flag": map[string]any{ + "type": "boolean", + "x-mcp-header": "Flag", + }, + }, + }, + }, + }, + { + name: "x-mcp-header on nested property inside object", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "Region", + }, + }, + }, + }, + }, + }, + wantErr: true, + wantErrSub: "nested", + }, + { + name: "x-mcp-header on deeply nested property", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "outer": map[string]any{ + "type": "object", + "properties": map[string]any{ + "inner": map[string]any{ + "type": "object", + "properties": map[string]any{ + "value": map[string]any{ + "type": "string", + "x-mcp-header": "Value", + }, + }, + }, + }, + }, + }, + }, + }, + wantErr: true, + wantErrSub: "nested", + }, + { + name: "object property without nested x-mcp-header is valid", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + }, + }, + }, + "flag": map[string]any{ + "type": "boolean", + "x-mcp-header": "Flag", + }, + }, + }, + }, + }, + { + name: "jsonschema.Schema valid x-mcp-header", + tool: &Tool{ + Name: "test", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "region": { + Type: "string", + Extra: map[string]any{"x-mcp-header": "Region"}, + }, + }, + }, + }, + }, + { + name: "jsonschema.Schema x-mcp-header on array type", + tool: &Tool{ + Name: "test", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "items": { + Type: "array", + Extra: map[string]any{"x-mcp-header": "Items"}, + }, + }, + }, + }, + wantErr: true, + wantErrSub: "primitive types", + }, + { + name: "jsonschema.Schema nested x-mcp-header", + tool: &Tool{ + Name: "test", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "config": { + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "region": { + Type: "string", + Extra: map[string]any{"x-mcp-header": "Region"}, + }, + }, + }, + }, + }, + }, + wantErr: true, + wantErrSub: "nested", + }, + { + name: "json.RawMessage valid x-mcp-header", + tool: &Tool{ + Name: "test", + InputSchema: json.RawMessage(`{"type":"object","properties":{"region":{"type":"string","x-mcp-header":"Region"}}}`), + }, + }, + { + name: "json.RawMessage x-mcp-header on object type", + tool: &Tool{ + Name: "test", + InputSchema: json.RawMessage(`{"type":"object","properties":{"nested":{"type":"object","x-mcp-header":"Nested"}}}`), + }, + wantErr: true, + wantErrSub: "primitive types", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateParamHeaderAnnotations(tt.tool) + if tt.wantErr { + if err == nil { + t.Fatal("validateToolParamHeaders() = nil, want error") + } + if !strings.Contains(err.Error(), tt.wantErrSub) { + t.Errorf("error = %q, want substring %q", err.Error(), tt.wantErrSub) + } + } else if err != nil { + t.Errorf("validateToolParamHeaders() = %v, want nil", err) + } + }) + } +} + +func TestFilterValidTools(t *testing.T) { + valid := &Tool{ + Name: "valid", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + }, + }, + } + invalid := &Tool{ + Name: "invalid", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{"type": "string", "x-mcp-header": ""}, + }, + }, + } + noAnnotation := &Tool{ + Name: "plain", + InputSchema: map[string]any{"type": "object", "properties": map[string]any{"q": map[string]any{"type": "string"}}}, + } + nestedInvalid := &Tool{ + Name: "nested-invalid", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + }, + }, + }, + }, + } + + validJsonSchema := &Tool{ + Name: "valid-jsonschema", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "region": { + Type: "string", + Extra: map[string]any{"x-mcp-header": "Region"}, + }, + }, + }, + } + invalidJsonSchema := &Tool{ + Name: "invalid-jsonschema", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "items": { + Type: "array", + Extra: map[string]any{"x-mcp-header": "Items"}, + }, + }, + }, + } + + result := filterValidTools(nil, []*Tool{valid, invalid, noAnnotation, nestedInvalid, validJsonSchema, invalidJsonSchema}) + if len(result) != 3 { + t.Fatalf("filterValidTools returned %d tools, want 3", len(result)) + } + if result[0].Name != "valid" || result[1].Name != "plain" || result[2].Name != "valid-jsonschema" { + t.Errorf("filterValidTools returned [%s, %s, %s], want [valid, plain, valid-jsonschema]", result[0].Name, result[1].Name, result[2].Name) + } +} + +func TestSetStandardHeadersWithParamHeaders(t *testing.T) { + toolSchema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "Region", + }, + "query": map[string]any{ + "type": "string", + }, + "priority": map[string]any{ + "type": "string", + "x-mcp-header": "Priority", + }, + }, + } + tool := &Tool{Name: "execute_sql", InputSchema: toolSchema} + + tests := []struct { + name string + tool *Tool + params any + wantHeaders map[string]string + }{ + { + name: "sets param headers from arguments", + tool: tool, + params: &CallToolParams{ + Name: "execute_sql", + Arguments: map[string]any{"region": "us-west1", "query": "SELECT 1", "priority": "high"}, + }, + wantHeaders: map[string]string{ + "Mcp-Param-Region": "us-west1", + "Mcp-Param-Priority": "high", + }, + }, + { + name: "omits header when argument is missing", + tool: tool, + params: &CallToolParams{ + Name: "execute_sql", + Arguments: map[string]any{"query": "SELECT 1"}, + }, + wantHeaders: nil, + }, + { + name: "omits header when argument is null", + tool: tool, + params: &CallToolParams{ + Name: "execute_sql", + Arguments: map[string]any{"region": nil, "query": "SELECT 1"}, + }, + wantHeaders: nil, + }, + { + name: "encodes non-ASCII value", + tool: tool, + params: &CallToolParams{ + Name: "execute_sql", + Arguments: map[string]any{"region": "日本", "query": "SELECT 1"}, + }, + wantHeaders: map[string]string{ + "Mcp-Param-Region": "=?base64?5pel5pys?=", + }, + }, + { + name: "handles boolean argument", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "flag": map[string]any{"type": "boolean", "x-mcp-header": "Flag"}, + }, + }, + }, + params: &CallToolParams{ + Name: "test", + Arguments: map[string]any{"flag": true}, + }, + wantHeaders: map[string]string{ + "Mcp-Param-Flag": "true", + }, + }, + { + name: "handles number argument", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "count": map[string]any{"type": "number", "x-mcp-header": "Count"}, + }, + }, + }, + params: &CallToolParams{ + Name: "test", + Arguments: map[string]any{"count": float64(42)}, + }, + wantHeaders: map[string]string{ + "Mcp-Param-Count": "42", + }, + }, + { + name: "no tool in extra does not add param headers", + tool: nil, + params: &CallToolParams{ + Name: "execute_sql", + Arguments: map[string]any{"region": "us-west1"}, + }, + wantHeaders: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + header := http.Header{} + header.Set(protocolVersionHeader, minVersionForStandardHeaders) + + ctx := context.Background() + if tt.tool != nil { + ctx = context.WithValue(ctx, toolContextKey, tt.tool) + } + + msg := &jsonrpc.Request{ + Method: "tools/call", + Params: mustMarshal(tt.params), + } + + setStandardHeaders(ctx, header, msg) + + if got := header.Get(methodHeader); got != "tools/call" { + t.Errorf("MethodHeader = %q, want %q", got, "tools/call") + } + + for h, want := range tt.wantHeaders { + if got := header.Get(h); got != want { + t.Errorf("%s = %q, want %q", h, got, want) + } + } + + if got := header.Get("Mcp-Param-query"); got != "" { + t.Errorf("non-annotated param got header: Mcp-Param-query = %q", got) + } + }) + } +} + +func TestExtractToolParamHeaders(t *testing.T) { + tests := []struct { + name string + tool *Tool + want map[string]string + }{ + { + name: "extracts x-mcp-header annotations", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + "query": map[string]any{"type": "string"}, + "tenant_id": map[string]any{"type": "string", "x-mcp-header": "TenantId"}, + }, + }, + }, + want: map[string]string{"region": "Region", "tenant_id": "TenantId"}, + }, + { + name: "returns nil for tool without properties", + tool: &Tool{Name: "test", InputSchema: map[string]any{"type": "object"}}, + want: nil, + }, + { + name: "returns nil for non-map schema", + tool: &Tool{Name: "test", InputSchema: "not a map"}, + want: nil, + }, + { + name: "returns nil when no annotations", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{"q": map[string]any{"type": "string"}}, + }, + }, + want: nil, + }, + { + name: "jsonschema.Schema with x-mcp-header in Extra", + tool: &Tool{ + Name: "test", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "region": { + Type: "string", + Extra: map[string]any{"x-mcp-header": "Region"}, + }, + "query": {Type: "string"}, + }, + }, + }, + want: map[string]string{"region": "Region"}, + }, + { + name: "json.RawMessage with x-mcp-header", + tool: &Tool{ + Name: "test", + InputSchema: json.RawMessage(`{"type":"object","properties":{"region":{"type":"string","x-mcp-header":"Region"},"query":{"type":"string"}}}`), + }, + want: map[string]string{"region": "Region"}, + }, + { + name: "jsonschema.Schema without x-mcp-header", + tool: &Tool{ + Name: "test", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "query": {Type: "string"}, + }, + }, + }, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractParamHeaderAnnotations(tt.tool) + if tt.want == nil { + if got != nil { + t.Errorf("extractToolParamHeaders() = %v, want nil", got) + } + return + } + if len(got) != len(tt.want) { + t.Fatalf("extractToolParamHeaders() returned %d entries, want %d", len(got), len(tt.want)) + } + for k, v := range tt.want { + if got[k] != v { + t.Errorf("extractToolParamHeaders()[%q] = %q, want %q", k, got[k], v) + } + } + }) + } +} + +func TestUnmarshalPrimitive(t *testing.T) { + tests := []struct { + name string + raw string + want any + }{ + {"string", `"hello"`, "hello"}, + {"number", `42`, float64(42)}, + {"float", `3.14`, float64(3.14)}, + {"true", `true`, true}, + {"false", `false`, false}, + {"null", `null`, nil}, + {"array", `[1,2]`, nil}, + {"object", `{"a":1}`, nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := unmarshalPrimitive(json.RawMessage(tt.raw)) + if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", tt.want) { + t.Errorf("unmarshalPrimitive(%s) = %v (%T), want %v (%T)", tt.raw, got, got, tt.want, tt.want) + } + }) + } +} + +func TestEncodeHeaderValue(t *testing.T) { + tests := []struct { + name string + value any + want string + wantOK bool + }{ + // Strings + {"plain ASCII", "us-west1", "us-west1", true}, + {"empty string", "", "", true}, + {"string with internal spaces", "us west 1", "us west 1", true}, + {"string with leading space", " us-west1", "=?base64?IHVzLXdlc3Qx?=", true}, + {"string with trailing space", "us-west1 ", "=?base64?dXMtd2VzdDEg?=", true}, + {"string with both spaces", " us-west1 ", "=?base64?IHVzLXdlc3QxIA==?=", true}, + {"non-ASCII", "日本語", "=?base64?5pel5pys6Kqe?=", true}, + {"mixed ASCII and non-ASCII", "Hello, 世界", "=?base64?SGVsbG8sIOS4lueVjA==?=", true}, + {"string with newline", "line1\nline2", "=?base64?bGluZTEKbGluZTI=?=", true}, + {"string with carriage return", "line1\r\nline2", "=?base64?bGluZTENCmxpbmUy?=", true}, + {"string with leading tab", "\tindented", "=?base64?CWluZGVudGVk?=", true}, + + // Numbers + {"integer", float64(42), "42", true}, + {"float", float64(3.14159), "3.14159", true}, + + // Booleans + {"true", true, "true", true}, + {"false", false, "false", true}, + + // Unsupported types + {"nil", nil, "", false}, + {"slice", []string{"a"}, "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := encodeHeaderValue(tt.value) + if ok != tt.wantOK { + t.Fatalf("encodeHeaderValue(%v) ok = %v, want %v", tt.value, ok, tt.wantOK) + } + if got != tt.want { + t.Errorf("encodeHeaderValue(%v) = %q, want %q", tt.value, got, tt.want) + } + }) + } +} + +func TestDecodeHeaderValue(t *testing.T) { + tests := []struct { + name string + input string + want string + wantOK bool + }{ + {"plain value", "us-west1", "us-west1", true}, + {"empty value", "", "", true}, + {"valid base64", "=?base64?SGVsbG8=?=", "Hello", true}, + {"non-ASCII decoded", "=?base64?5pel5pys6Kqe?=", "日本語", true}, + {"leading space decoded", "=?base64?IHVzLXdlc3Qx?=", " us-west1", true}, + {"case-insensitive prefix", "=?BASE64?SGVsbG8=?=", "Hello", true}, + {"invalid base64 chars", "=?base64?SGVs!!!bG8=?=", "", false}, + // Missing prefix or suffix: treated as literal values, not base64 + {"missing prefix", "SGVsbG8=", "SGVsbG8=", true}, + {"missing suffix", "=?base64?SGVsbG8=", "=?base64?SGVsbG8=", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := decodeHeaderValue(tt.input) + if ok != tt.wantOK { + t.Fatalf("decodeHeaderValue(%q) ok = %v, want %v", tt.input, ok, tt.wantOK) + } + if got != tt.want { + t.Errorf("decodeHeaderValue(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestEncodeDecodeRoundTrip(t *testing.T) { + values := []string{ + "us-west1", + "", + " leading", + "trailing ", + "Hello, 世界", + "line1\nline2", + "\ttab", + } + for _, v := range values { + encoded, ok := encodeHeaderValue(v) + if !ok { + t.Fatalf("encodeHeaderValue(%q) failed", v) + } + decoded, ok := decodeHeaderValue(encoded) + if !ok { + t.Fatalf("decodeHeaderValue(%q) failed", encoded) + } + if decoded != v { + t.Errorf("round-trip failed: %q -> %q -> %q", v, encoded, decoded) + } + } +} diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 118d05d..77eed1b 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1915,9 +1915,9 @@ func TestStreamableGET(t *testing.T) { } } -// TestStreamableMcpHeaderValidation tests the server-side Mcp-Method and -// Mcp-Name header validation through the full HTTP handler, as specified -// in SEP-2243. +// TestStreamableMcpHeaderValidation tests the server-side Mcp-Method, +// Mcp-Name, and Mcp-Param header validation through the full HTTP handler, +// as specified in SEP-2243. func TestStreamableMcpHeaderValidation(t *testing.T) { // Temporarily register the future version so the handler accepts it. orig := supportedProtocolVersions @@ -1930,6 +1930,25 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { return &CallToolResult{}, nil }) + server.AddTool( + &Tool{ + Name: "execute_sql", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "Region", + }, + "query": map[string]any{ + "type": "string", + }, + }, + }, + }, + func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { + return &CallToolResult{}, nil + }) handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) defer handler.closeAll() @@ -2019,6 +2038,50 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(6, &CallToolResult{Content: []Content{}}, nil)}, }, + { + method: "POST", + headers: http.Header{ + protocolVersionHeader: {minVersionForStandardHeaders}, + methodHeader: {"tools/call"}, + nameHeader: {"execute_sql"}, + paramHeaderPrefix + "Region": {"us-west1"}, + }, + messages: []jsonrpc.Message{req(7, "tools/call", &CallToolParams{ + Name: "execute_sql", + Arguments: map[string]any{"region": "us-west1", "query": "SELECT 1"}, + })}, + wantStatusCode: http.StatusOK, + wantMessages: []jsonrpc.Message{resp(7, &CallToolResult{Content: []Content{}}, nil)}, + }, + { + method: "POST", + headers: http.Header{ + protocolVersionHeader: {minVersionForStandardHeaders}, + methodHeader: {"tools/call"}, + nameHeader: {"execute_sql"}, + paramHeaderPrefix + "Region": {"eu-central1"}, + }, + messages: []jsonrpc.Message{req(8, "tools/call", &CallToolParams{ + Name: "execute_sql", + Arguments: map[string]any{"region": "us-west1"}, + })}, + wantStatusCode: http.StatusBadRequest, + wantBodyContaining: "header mismatch", + }, + { + method: "POST", + headers: http.Header{ + protocolVersionHeader: {minVersionForStandardHeaders}, + methodHeader: {"tools/call"}, + nameHeader: {"execute_sql"}, + }, + messages: []jsonrpc.Message{req(9, "tools/call", &CallToolParams{ + Name: "execute_sql", + Arguments: map[string]any{"region": "us-west1"}, + })}, + wantStatusCode: http.StatusBadRequest, + wantBodyContaining: "missing", + }, }) } @@ -2170,6 +2233,174 @@ func TestStreamableMcpHeaderVersionGating(t *testing.T) { }) } +// TestStreamableParamHeadersClientSetsHeaders verifies that the client sets +// Mcp-Param-* headers on tool calls when the tool has x-mcp-header annotations. +func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) { + orig := supportedProtocolVersions + supportedProtocolVersions = append(slices.Clone(orig), minVersionForStandardHeaders) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) + server.AddTool( + &Tool{ + Name: "execute_sql", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "Region", + }, + "query": map[string]any{ + "type": "string", + }, + }, + }, + }, + func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil + }) + + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) + defer handler.closeAll() + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + defer httpServer.Close() + + var capturedHeaders http.Header + customClient := &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + if req.Header.Get(methodHeader) == "tools/call" { + capturedHeaders = req.Header.Clone() + } + return http.DefaultTransport.RoundTrip(req) + }), + } + + clientTransport := &StreamableClientTransport{ + Endpoint: httpServer.URL, + HTTPClient: customClient, + } + + client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil) + ctx := context.Background() + session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{protocolVersion: minVersionForStandardHeaders}) + if err != nil { + t.Fatal(err) + } + defer session.Close() + + // ListTools to populate the tool cache (needed for param headers). + if _, err := session.ListTools(ctx, nil); err != nil { + t.Fatal(err) + } + + _, err = session.CallTool(ctx, &CallToolParams{ + Name: "execute_sql", + Arguments: map[string]any{"region": "us-west1", "query": "SELECT 1"}, + }) + if err != nil { + t.Fatal(err) + } + + if capturedHeaders == nil { + t.Fatal("no tool call headers captured") + } + if got := capturedHeaders.Get(methodHeader); got != "tools/call" { + t.Errorf("Mcp-Method = %q, want %q", got, "tools/call") + } + if got := capturedHeaders.Get(nameHeader); got != "execute_sql" { + t.Errorf("Mcp-Name = %q, want %q", got, "execute_sql") + } + if got := capturedHeaders.Get(paramHeaderPrefix + "Region"); got != "us-west1" { + t.Errorf("Mcp-Param-Region = %q, want %q", got, "us-west1") + } +} + +// TestStreamableFilterValidToolsIntegration verifies that AddTool rejects +// tools with invalid x-mcp-header annotations at registration time, and +// that valid tools are returned by ListTools. +func TestStreamableFilterValidToolsIntegration(t *testing.T) { + orig := supportedProtocolVersions + supportedProtocolVersions = append(slices.Clone(orig), minVersionForStandardHeaders) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) + noop := func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { + return &CallToolResult{}, nil + } + + // Valid tool with correct x-mcp-header annotation. + server.AddTool(&Tool{ + Name: "valid-tool", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "Region", + }, + }, + }, + }, noop) + + // Invalid tool: x-mcp-header on an array type should panic. + func() { + defer func() { + r := recover() + if r == nil { + t.Fatal("AddTool with invalid x-mcp-header annotation did not panic") + } + }() + server.AddTool(&Tool{ + Name: "invalid-tool", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "items": map[string]any{ + "type": "array", + "x-mcp-header": "Items", + }, + }, + }, + }, noop) + }() + + // Tool with no x-mcp-header annotations (always valid). + server.AddTool(&Tool{ + Name: "plain-tool", + InputSchema: &jsonschema.Schema{Type: "object"}, + }, noop) + + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) + defer handler.closeAll() + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + defer httpServer.Close() + + client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil) + ctx := context.Background() + session, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, &ClientSessionOptions{protocolVersion: minVersionForStandardHeaders}) + if err != nil { + t.Fatal(err) + } + defer session.Close() + + result, err := session.ListTools(ctx, nil) + if err != nil { + t.Fatal(err) + } + + toolNames := make([]string, len(result.Tools)) + for i, tool := range result.Tools { + toolNames[i] = tool.Name + } + sort.Strings(toolNames) + + wantNames := []string{"plain-tool", "valid-tool"} + if !slices.Equal(toolNames, wantNames) { + t.Errorf("ListTools returned %v, want %v", toolNames, wantNames) + } +} + // TestStreamable405AllowHeader verifies RFC 9110 §15.5.6 compliance: // 405 Method Not Allowed responses MUST include an Allow header. func TestStreamable405AllowHeader(t *testing.T) { From 9f5e89fea2aca32dbebcf058fb1cb65e8cb36b7b Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Fri, 8 May 2026 14:29:01 +0200 Subject: [PATCH 278/418] mcp: refactor streamable handler control flow (#949) --- mcp/streamable.go | 562 +++++++++++++++++++++++------------------ mcp/streamable_test.go | 8 +- 2 files changed, 322 insertions(+), 248 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index da13537..b8e3655 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -260,284 +260,348 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque } if h.opts.CrossOriginProtection != nil { - // Verify the 'Origin' header to protect against CSRF attacks. if err := h.opts.CrossOriginProtection.Check(req); err != nil { http.Error(w, err.Error(), http.StatusForbidden) return } } - // Validate 'Content-Type' header. - if req.Method == http.MethodPost && baseMediaType(req.Header.Get("Content-Type")) != "application/json" { + // [§2.7] of the spec (2025-06-18): validate the MCP-Protocol-Version + // header. If provided, it must be a supported version. If absent, the + // version is unknown (the request may be an initialize for any version). + // + // [§2.7]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#protocol-version-header + protocolVersion := req.Header.Get(protocolVersionHeader) + if protocolVersion != "" && !slices.Contains(supportedProtocolVersions, protocolVersion) { + http.Error(w, fmt.Sprintf("Bad Request: Unsupported protocol version (supported versions: %s)", strings.Join(supportedProtocolVersions, ",")), http.StatusBadRequest) + return + } + req = req.WithContext(context.WithValue(req.Context(), protocolVersionContextKey{}, protocolVersion)) + + if h.opts.Stateless { + h.serveStateless(w, req) + } else { + h.serveStateful(w, req) + } +} + +// serveStateless handles requests for stateless servers. +// Stateless servers only support POST. Each request creates a temporary +// session that is closed when the request completes. +func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + // RFC 9110 §15.5.6: 405 responses MUST include Allow header. + w.Header().Set("Allow", "POST") + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + if baseMediaType(req.Header.Get("Content-Type")) != "application/json" { http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) return } - // Allow multiple 'Accept' headers. - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept#syntax + // Accept must contain both 'application/json' and 'text/event-stream'. jsonOK, streamOK := streamableAccepts(req.Header.Values("Accept")) + if !jsonOK || !streamOK { + http.Error(w, "Accept must contain both 'application/json' and 'text/event-stream'", http.StatusBadRequest) + return + } - if req.Method == http.MethodGet { - if !streamOK { - http.Error(w, "Accept must contain 'text/event-stream' for GET requests", http.StatusBadRequest) - return + server := h.getServer(req) + if server == nil { + http.Error(w, "no server available", http.StatusBadRequest) + return + } + + // In stateless mode, the client may provide a session ID for application- + // level state correlation. If absent, generate one. + sessionID := req.Header.Get(sessionIDHeader) + if sessionID == "" { + sessionID = server.opts.GetSessionID() + } + + transport := &StreamableServerTransport{ + SessionID: sessionID, + Stateless: true, + EventStore: h.opts.EventStore, + jsonResponse: h.opts.JSONResponse, + logger: h.opts.Logger, + } + + connectOpts, err := h.ephemeralConnectOpts(req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + session, err := connectStreamable(req.Context(), server, transport, connectOpts) + if err != nil { + h.opts.Logger.Error(fmt.Sprintf("failed to connect: %v", err)) + http.Error(w, "failed connection", http.StatusInternalServerError) + return + } + defer session.Close() + + transport.ServeHTTP(w, req) +} + +// ephemeralConnectOpts peeks at the request body to determine whether it +// contains an initialize or initialized message. If not, default session state +// is constructed so that the session doesn't reject the request. +// It is used for both stateless servers and stateful servers with no session ID. +func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (*ServerSessionOptions, error) { + protocolVersion := protocolVersionFromContext(req.Context()) + if protocolVersion == "" { + protocolVersion = protocolVersion20250326 + } + + var hasInitialize, hasInitialized bool + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, fmt.Errorf("failed to read body") + } + req.Body.Close() + req.Body = io.NopCloser(bytes.NewBuffer(body)) + msgs, _, err := readBatch(body) + if err == nil { + for _, msg := range msgs { + if r, ok := msg.(*jsonrpc.Request); ok { + switch r.Method { + case methodInitialize: + hasInitialize = true + case notificationInitialized: + hasInitialized = true + } + } } - } else if (!jsonOK || !streamOK) && req.Method != http.MethodDelete { // TODO: consolidate with handling of http method below. + } + + state := new(ServerSessionState) + if !hasInitialize { + state.InitializeParams = &InitializeParams{ + ProtocolVersion: protocolVersion, + } + } + if !hasInitialized { + state.InitializedParams = new(InitializedParams) + } + state.LogLevel = "info" + return &ServerSessionOptions{ + State: state, + }, nil +} + +func connectStreamable(ctx context.Context, server *Server, transport *StreamableServerTransport, opts *ServerSessionOptions) (*ServerSession, error) { + s, err := server.Connect(ctx, transport, opts) + if err != nil { + return nil, err + } + transport.connection.toolLookup = server.getServerTool + return s, nil +} + +// serveStateful handles requests for stateful servers. +// Stateful servers support GET, POST, and DELETE, and maintain persistent +// sessions keyed by session ID. +func (h *StreamableHTTPHandler) serveStateful(w http.ResponseWriter, req *http.Request) { + switch req.Method { + case http.MethodGet: + h.serveStatefulGET(w, req) + case http.MethodPost: + h.serveStatefulPOST(w, req) + case http.MethodDelete: + h.serveStatefulDELETE(w, req) + default: + // RFC 9110 §15.5.6: 405 responses MUST include Allow header. + w.Header().Set("Allow", "GET, POST, DELETE") + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + } +} + +// lookupSession looks up a session by the Mcp-Session-Id header value. +// It returns the session info and whether the caller should proceed. If ok is +// false, an error response has been written. The sessionID must be non-empty; +// callers are responsible for checking this before calling lookupSession. +func (h *StreamableHTTPHandler) lookupSession(w http.ResponseWriter, req *http.Request, sessionID string) (info *sessionInfo, ok bool) { + h.mu.Lock() + info = h.sessions[sessionID] + h.mu.Unlock() + if info == nil { + http.Error(w, "session not found", http.StatusNotFound) + return nil, false + } + if info.userID != "" { + tokenInfo := auth.TokenInfoFromContext(req.Context()) + if tokenInfo == nil || tokenInfo.UserID != info.userID { + http.Error(w, "session user mismatch", http.StatusForbidden) + return nil, false + } + } + return info, true +} + +// serveStatefulGET handles GET requests for standalone SSE streams. +// GET requires a valid Mcp-Session-Id header. +func (h *StreamableHTTPHandler) serveStatefulGET(w http.ResponseWriter, req *http.Request) { + if _, streamOK := streamableAccepts(req.Header.Values("Accept")); !streamOK { + http.Error(w, "Accept must contain 'text/event-stream' for GET requests", http.StatusBadRequest) + return + } + + sessionID := req.Header.Get(sessionIDHeader) + if sessionID == "" { + http.Error(w, "Bad Request: GET requires an Mcp-Session-Id header", http.StatusBadRequest) + return + } + + sessInfo, ok := h.lookupSession(w, req, sessionID) + if !ok { + return + } + + sessInfo.transport.ServeHTTP(w, req) +} + +// serveStatefulDELETE handles DELETE requests for session termination. +// DELETE requires a valid Mcp-Session-Id header. +func (h *StreamableHTTPHandler) serveStatefulDELETE(w http.ResponseWriter, req *http.Request) { + sessionID := req.Header.Get(sessionIDHeader) + if sessionID == "" { + http.Error(w, "Bad Request: DELETE requires an Mcp-Session-Id header", http.StatusBadRequest) + return + } + + sessInfo, ok := h.lookupSession(w, req, sessionID) + if !ok { + return + } + + sessInfo.session.Close() + w.WriteHeader(http.StatusNoContent) +} + +// serveStatefulPOST handles POST requests for stateful servers. +// POST may arrive with or without a Mcp-Session-Id header. Without a session +// ID, a new session is created (this is the normal path for the first +// initialize request). +func (h *StreamableHTTPHandler) serveStatefulPOST(w http.ResponseWriter, req *http.Request) { + if baseMediaType(req.Header.Get("Content-Type")) != "application/json" { + http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) + return + } + + jsonOK, streamOK := streamableAccepts(req.Header.Values("Accept")) + if !jsonOK || !streamOK { http.Error(w, "Accept must contain both 'application/json' and 'text/event-stream'", http.StatusBadRequest) return } sessionID := req.Header.Get(sessionIDHeader) - var sessInfo *sessionInfo + + // Look up existing session if a session ID was provided. if sessionID != "" { - h.mu.Lock() - sessInfo = h.sessions[sessionID] - h.mu.Unlock() - if sessInfo == nil && !h.opts.Stateless { - // Unless we're in 'stateless' mode, which doesn't perform any Session-ID - // validation, we require that the session ID matches a known session. - // - // In stateless mode, a temporary transport is be created below. - http.Error(w, "session not found", http.StatusNotFound) + sessInfo, ok := h.lookupSession(w, req, sessionID) + if !ok { return } - // Prevent session hijacking: if the session was created with a user ID, - // verify that subsequent requests come from the same user. - if sessInfo != nil && sessInfo.userID != "" { - tokenInfo := auth.TokenInfoFromContext(req.Context()) - if tokenInfo == nil || tokenInfo.UserID != sessInfo.userID { - http.Error(w, "session user mismatch", http.StatusForbidden) - return - } - } - } - - if req.Method == http.MethodDelete { - if sessionID == "" { - http.Error(w, "Bad Request: DELETE requires an Mcp-Session-Id header", http.StatusBadRequest) - return - } - if sessInfo != nil { // sessInfo may be nil in stateless mode - // Closing the session also removes it from h.sessions, due to the - // onClose callback. - sessInfo.session.Close() - } - w.WriteHeader(http.StatusNoContent) + sessInfo.startPOST() + defer sessInfo.endPOST() + sessInfo.transport.ServeHTTP(w, req) return } - switch req.Method { - case http.MethodPost, http.MethodGet: - if req.Method == http.MethodGet && (h.opts.Stateless || sessionID == "") { - if h.opts.Stateless { - // Per MCP spec: server MUST return 405 if it doesn't offer SSE stream. - // In stateless mode, GET (SSE streaming) is not supported. - // RFC 9110 §15.5.6: 405 responses MUST include Allow header. - w.Header().Set("Allow", "POST") - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) - } else { - // In stateful mode, GET is supported but requires a session ID. - // This is a precondition error, similar to DELETE without session. - http.Error(w, "Bad Request: GET requires an Mcp-Session-Id header", http.StatusBadRequest) - } - return - } - default: - // RFC 9110 §15.5.6: 405 responses MUST include Allow header. - if h.opts.Stateless { - w.Header().Set("Allow", "POST") - } else { - w.Header().Set("Allow", "GET, POST, DELETE") - } - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + // No session ID: create a new session. + server := h.getServer(req) + if server == nil { + http.Error(w, "no server available", http.StatusBadRequest) return } + sessionID = server.opts.GetSessionID() - // [§2.7] of the spec (2025-06-18) states: - // - // "If using HTTP, the client MUST include the MCP-Protocol-Version: - // HTTP header on all subsequent requests to the MCP - // server, allowing the MCP server to respond based on the MCP protocol - // version. - // - // For example: MCP-Protocol-Version: 2025-06-18 - // The protocol version sent by the client SHOULD be the one negotiated during - // initialization. - // - // For backwards compatibility, if the server does not receive an - // MCP-Protocol-Version header, and has no other way to identify the version - - // for example, by relying on the protocol version negotiated during - // initialization - the server SHOULD assume protocol version 2025-03-26. - // - // If the server receives a request with an invalid or unsupported - // MCP-Protocol-Version, it MUST respond with 400 Bad Request." - // - // Since this wasn't present in the 2025-03-26 version of the spec, this - // effectively means: - // 1. IF the client provides a version header, it must be a supported - // version. - // 2. In stateless mode, where we've lost the state of the initialize - // request, we assume that whatever the client tells us is the truth (or - // assume 2025-03-26 if the client doesn't say anything). - // - // This logic matches the typescript SDK. - // - // [§2.7]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#protocol-version-header - protocolVersion := req.Header.Get(protocolVersionHeader) - if protocolVersion == "" { - protocolVersion = protocolVersion20250326 - } - if !slices.Contains(supportedProtocolVersions, protocolVersion) { - http.Error(w, fmt.Sprintf("Bad Request: Unsupported protocol version (supported versions: %s)", strings.Join(supportedProtocolVersions, ",")), http.StatusBadRequest) - return + transport := &StreamableServerTransport{ + SessionID: sessionID, + Stateless: false, + EventStore: h.opts.EventStore, + jsonResponse: h.opts.JSONResponse, + logger: h.opts.Logger, } - if sessInfo == nil { - server := h.getServer(req) - if server == nil { - // The getServer argument to NewStreamableHTTPHandler returned nil. - http.Error(w, "no server available", http.StatusBadRequest) - return - } - if sessionID == "" { - // In stateless mode, sessionID may be nonempty even if there's no - // existing transport. - sessionID = server.opts.GetSessionID() - } - transport := &StreamableServerTransport{ - SessionID: sessionID, - Stateless: h.opts.Stateless, - EventStore: h.opts.EventStore, - jsonResponse: h.opts.JSONResponse, - logger: h.opts.Logger, - } - - // Sessions without a session ID are also stateless: there's no way to - // address them. - stateless := h.opts.Stateless || sessionID == "" - // To support stateless mode, we initialize the session with a default - // state, so that it doesn't reject subsequent requests. - var connectOpts *ServerSessionOptions - if stateless { - // Peek at the body to see if it is initialize or initialized. - // We want those to be handled as usual. - var hasInitialize, hasInitialized bool - { - // TODO: verify that this allows protocol version negotiation for - // stateless servers. - body, err := io.ReadAll(req.Body) - if err != nil { - http.Error(w, "failed to read body", http.StatusBadRequest) - return - } - req.Body.Close() - - // Reset the body so that it can be read later. - req.Body = io.NopCloser(bytes.NewBuffer(body)) - - msgs, _, err := readBatch(body) - if err == nil { - for _, msg := range msgs { - if req, ok := msg.(*jsonrpc.Request); ok { - switch req.Method { - case methodInitialize: - hasInitialize = true - case notificationInitialized: - hasInitialized = true - } - } - } - } - } - - // If we don't have InitializeParams or InitializedParams in the request, - // set the initial state to a default value. - state := new(ServerSessionState) - if !hasInitialize { - state.InitializeParams = &InitializeParams{ - ProtocolVersion: protocolVersion, - } - } - if !hasInitialized { - state.InitializedParams = new(InitializedParams) - } - state.LogLevel = "info" - connectOpts = &ServerSessionOptions{ - State: state, - } - } else { - // Cleanup is only required in stateful mode, as transportation is - // not stored in the map otherwise. - connectOpts = &ServerSessionOptions{ - onClose: func() { - h.mu.Lock() - defer h.mu.Unlock() - if info, ok := h.sessions[transport.SessionID]; ok { - info.stopTimer() - delete(h.sessions, transport.SessionID) - if h.onTransportDeletion != nil { - h.onTransportDeletion(transport.SessionID) - } - } - }, - } - } - - // Pass req.Context() here, to allow middleware to add context values. - // The context is detached in the jsonrpc2 library when handling the - // long-running stream. - session, err := server.Connect(req.Context(), transport, connectOpts) + // Sessions without a session ID (GetSessionID returned "") are ephemeral: + // there's no way to address them, so they are closed after the request. + // This can happen when ServerOptions.GetSessionID is explicitly set to + // return "" to suppress session IDs entirely. It also covers any request + // that arrives before a session exists (e.g. initialize or ping) on a + // server configured this way. + if sessionID == "" { + connectOpts, err := h.ephemeralConnectOpts(req) if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + session, err := connectStreamable(req.Context(), server, transport, connectOpts) + if err != nil { + h.opts.Logger.Error(fmt.Sprintf("failed to connect: %v", err)) http.Error(w, "failed connection", http.StatusInternalServerError) return } - transport.connection.toolLookup = server.getServerTool - // Capture the user ID from the token info to enable session hijacking - // prevention on subsequent requests. - var userID string - if tokenInfo := auth.TokenInfoFromContext(req.Context()); tokenInfo != nil { - userID = tokenInfo.UserID - } - sessInfo = &sessionInfo{ - session: session, - transport: transport, - userID: userID, - } + defer session.Close() + transport.ServeHTTP(w, req) + return + } - if stateless { - // Stateless mode: close the session when the request exits. - defer session.Close() // close the fake session after handling the request - } else { - // Otherwise, save the transport so that it can be reused - - // Clean up the session when it times out. - // - // Note that the timer here may fire multiple times, but - // sessInfo.session.Close is idempotent. - if h.opts.SessionTimeout > 0 { - sessInfo.timeout = h.opts.SessionTimeout - sessInfo.timer = time.AfterFunc(sessInfo.timeout, func() { - sessInfo.session.Close() - }) - } + connectOpts := &ServerSessionOptions{ + onClose: func() { h.mu.Lock() - h.sessions[transport.SessionID] = sessInfo - h.mu.Unlock() - defer func() { - // If initialization failed, clean up the session (#578). - if session.InitializeParams() == nil { - // Initialization failed. - session.Close() + defer h.mu.Unlock() + if info, ok := h.sessions[transport.SessionID]; ok { + info.stopTimer() + delete(h.sessions, transport.SessionID) + if h.onTransportDeletion != nil { + h.onTransportDeletion(transport.SessionID) } - }() + } + }, + } + + // Pass req.Context() here, to allow middleware to add context values. + // The context is detached in the jsonrpc2 library when handling the + // long-running stream. + session, err := connectStreamable(req.Context(), server, transport, connectOpts) + if err != nil { + h.opts.Logger.Error(fmt.Sprintf("failed to connect: %v", err)) + http.Error(w, "failed connection", http.StatusInternalServerError) + return + } + // Capture the user ID from the token info to enable session hijacking + // prevention on subsequent requests. + var userID string + if tokenInfo := auth.TokenInfoFromContext(req.Context()); tokenInfo != nil { + userID = tokenInfo.UserID + } + sessInfo := &sessionInfo{ + session: session, + transport: transport, + userID: userID, + } + + if h.opts.SessionTimeout > 0 { + sessInfo.timeout = h.opts.SessionTimeout + sessInfo.timer = time.AfterFunc(sessInfo.timeout, func() { + sessInfo.session.Close() + }) + } + h.mu.Lock() + h.sessions[transport.SessionID] = sessInfo + h.mu.Unlock() + defer func() { + // If initialization failed, clean up the session (#578). + if session.InitializeParams() == nil { + session.Close() } - } - - if req.Method == http.MethodPost { - sessInfo.startPOST() - defer sessInfo.endPOST() - } + }() + sessInfo.startPOST() + defer sessInfo.endPOST() sessInfo.transport.ServeHTTP(w, req) } @@ -901,6 +965,18 @@ func (c *streamableServerConn) newStream(ctx context.Context, requests map[jsonr // ID, we avoid having to make this API decision. type idContextKey struct{} +// protocolVersionContextKey stores the protocol version extracted from the +// MCP-Protocol-Version HTTP header for use by lower layers. +type protocolVersionContextKey struct{} + +// protocolVersionFromContext returns the protocol version from the context, or +// the empty string if not set. An empty string means the version is unknown +// (e.g. the header was absent). +func protocolVersionFromContext(ctx context.Context) string { + v, _ := ctx.Value(protocolVersionContextKey{}).(string) + return v +} + // ServeHTTP handles a single HTTP request for the session. func (t *StreamableServerTransport) ServeHTTP(w http.ResponseWriter, req *http.Request) { if t.connection == nil { @@ -946,9 +1022,7 @@ func (c *streamableServerConn) serveGET(w http.ResponseWriter, req *http.Request ctx := req.Context() - // Read the protocol version from the header. For GET requests, this should - // always be present since GET only happens after initialization. - protocolVersion := req.Header.Get(protocolVersionHeader) + protocolVersion := protocolVersionFromContext(ctx) if protocolVersion == "" { protocolVersion = protocolVersion20250326 } @@ -1141,7 +1215,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques return } - protocolVersion := req.Header.Get(protocolVersionHeader) + protocolVersion := protocolVersionFromContext(req.Context()) if protocolVersion == "" { protocolVersion = protocolVersion20250326 } diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 77eed1b..d3cd9f8 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -2428,13 +2428,13 @@ func TestStreamable405AllowHeader(t *testing.T) { wantAllow: "POST", }, { - // DELETE without session returns 400 Bad Request (not 405) - // because DELETE is a valid method, just requires a session ID. + // In stateless mode, only POST is supported. + // DELETE returns 405, consistent with Allow: POST. name: "DELETE without session stateless", stateless: true, method: "DELETE", - wantStatus: http.StatusBadRequest, - wantAllow: "", // No Allow header for 400 responses + wantStatus: http.StatusMethodNotAllowed, + wantAllow: "POST", }, } From da3535b6786149733cd8383f9ffb41191f45a9a6 Mon Sep 17 00:00:00 2001 From: Jon <38672162+gainsley@users.noreply.github.com> Date: Thu, 14 May 2026 00:26:57 -0700 Subject: [PATCH 279/418] Compare auth server metadata issuer ignoring trailing slash (#955) oauthex/auth_meta.go: fixes metadata issuer comparison to ignore trailing slash difference Google's gmail MCP server returns `https://accounts.google.com/` as the Authorization Server in the Protected Resource Metadata, but returns `https://accounts.google.com` in the Auth Server Metadata. This causes a comparison failure per rfc8414 section-3.3: Authorization Server Metadata Validation. The authorization server URL is meant to be a base to build other URLs from, it does not seem necessary to enforce that the trailing slash (or lack thereof) matches with the issuer URL. Please see #953 for more details. Fixes #953 --------- Co-authored-by: Guglielmo Colombo --- oauthex/auth_meta.go | 3 ++- oauthex/auth_meta_test.go | 16 +++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/oauthex/auth_meta.go b/oauthex/auth_meta.go index df1a0aa..255aca9 100644 --- a/oauthex/auth_meta.go +++ b/oauthex/auth_meta.go @@ -12,6 +12,7 @@ import ( "errors" "fmt" "net/http" + "strings" ) // AuthServerMeta represents the metadata for an OAuth 2.0 authorization server, @@ -145,7 +146,7 @@ func GetAuthServerMeta(ctx context.Context, metadataURL, issuer string, c *http. } return nil, fmt.Errorf("%v", err) // Do not expose error types. } - if asm.Issuer != issuer { + if strings.TrimRight(asm.Issuer, "/") != strings.TrimRight(issuer, "/") { // Validate the Issuer field (see RFC 8414, section 3.3). return nil, fmt.Errorf("metadata issuer %q does not match issuer URL %q", asm.Issuer, issuer) } diff --git a/oauthex/auth_meta_test.go b/oauthex/auth_meta_test.go index f390676..5c6c4ad 100644 --- a/oauthex/auth_meta_test.go +++ b/oauthex/auth_meta_test.go @@ -35,9 +35,10 @@ func TestAuthMetaParse(t *testing.T) { func TestGetAuthServerMetaPKCESupport(t *testing.T) { ctx := context.Background() tests := []struct { - name string - hasPKCESupport bool - wantError string + name string + hasPKCESupport bool + wantError string + issuerWithTrailingSlash bool }{ { name: "server_with_pkce_support", @@ -48,6 +49,12 @@ func TestGetAuthServerMetaPKCESupport(t *testing.T) { hasPKCESupport: false, wantError: "does not implement PKCE", }, + { + // ProtectedResourceMetadata may contain AuthorizationServers with a trailing slash (see Issue #953) + name: "issuer_with_trailing_slash", + hasPKCESupport: true, + issuerWithTrailingSlash: true, + }, } for _, tt := range tests { @@ -85,6 +92,9 @@ func TestGetAuthServerMetaPKCESupport(t *testing.T) { u, _ := url.Parse(ts.URL) issuer := "https://localhost:" + u.Port() metadataURL := issuer + "/.well-known/oauth-authorization-server" + if tt.issuerWithTrailingSlash { + issuer += "/" + } // The fake server presents a cert for example.com; set ServerName accordingly. httpClient := ts.Client() From 8eb54632ad49af464bae978c461b0bc20340e514 Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Fri, 15 May 2026 09:51:14 +0200 Subject: [PATCH 280/418] mcp: remove session header handling in stateless mode (#952) --- docs/mcpgodebug.md | 6 +++++ internal/docs/mcpgodebug.src.md | 6 +++++ mcp/streamable.go | 47 ++++++++++++++++++++++++++++----- mcp/streamable_test.go | 30 +++------------------ 4 files changed, 56 insertions(+), 33 deletions(-) diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index e5a7d6f..36eddb5 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -27,6 +27,12 @@ Options listed below were added and will be removed in the 1.9.0 version of the Params), restoring the previous behavior. The default behavior was changed to align with SEP-2164 and the JSON-RPC specification. +- `allowsessionsinstateless` added. If set to `1`, stateless streamable HTTP + servers will read the `Mcp-Session-Id` request header (or generate one via + `GetSessionID`), set it on response headers, and accept `DELETE` requests, + restoring the previous behavior. The default behavior was changed so that + stateless servers ignore session IDs entirely and reject `DELETE` with 405. + ### 1.6.0 Options listed below were added and will be removed in the 1.8.0 version of the SDK. diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index c03205b..b5f5a78 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -26,6 +26,12 @@ Options listed below were added and will be removed in the 1.9.0 version of the Params), restoring the previous behavior. The default behavior was changed to align with SEP-2164 and the JSON-RPC specification. +- `allowsessionsinstateless` added. If set to `1`, stateless streamable HTTP + servers will read the `Mcp-Session-Id` request header (or generate one via + `GetSessionID`), set it on response headers, and accept `DELETE` requests, + restoring the previous behavior. The default behavior was changed so that + stateless servers ignore session IDs entirely and reject `DELETE` with 405. + ### 1.6.0 Options listed below were added and will be removed in the 1.8.0 version of the SDK. diff --git a/mcp/streamable.go b/mcp/streamable.go index b8e3655..84beff3 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -128,12 +128,13 @@ func (i *sessionInfo) stopTimer() { type StreamableHTTPOptions struct { // Stateless controls whether the session is 'stateless'. // - // A stateless server does not validate the Mcp-Session-Id header, and uses a + // A stateless server ignores the Mcp-Session-Id header, and uses a // temporary session with default initialization parameters. Any // server->client request is rejected immediately as there's no way for the // client to respond. Server->Client notifications may reach the client if // they are made in the context of an incoming request, as described in the // documentation for [StreamableServerTransport]. + // In Stateless mode DELETE requests will return 405 Method Not Allowed. Stateless bool // TODO(#148): support session retention (?) @@ -247,6 +248,16 @@ var disablelocalhostprotection = mcpgodebug.Value("disablelocalhostprotection") // The option will be removed in the 1.8.0 version of the SDK. var enableoriginverification = mcpgodebug.Value("enableoriginverification") +// allowsessionsinstateless is a compatibility parameter that restores the old +// behavior of reading and using Mcp-Session-Id headers in stateless mode. When +// set to "1", stateless servers will read the session ID from the request +// header (or generate one via GetSessionID), set it on response headers, and +// accept DELETE requests. When unset (the default), stateless servers ignore +// session IDs entirely and reject DELETE with 405. +// See the documentation for the mcpgodebug package for instructions how to enable it. +// The option will be removed in the 1.9.0 version of the SDK. +var allowsessionsinstateless = mcpgodebug.Value("allowsessionsinstateless") + func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { // DNS rebinding protection: auto-enabled for localhost servers. // See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise @@ -288,7 +299,17 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque // serveStateless handles requests for stateless servers. // Stateless servers only support POST. Each request creates a temporary // session that is closed when the request completes. +// +// When the allowsessionsinstateless compatibility flag is set, DELETE is also +// accepted (as a no-op) and session IDs are read from the request header. func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http.Request) { + legacySessions := allowsessionsinstateless == "1" + + if req.Method == http.MethodDelete && legacySessions { + h.serveStatelessLegacyDELETE(w, req) + return + } + if req.Method != http.MethodPost { // RFC 9110 §15.5.6: 405 responses MUST include Allow header. w.Header().Set("Allow", "POST") @@ -314,11 +335,12 @@ func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http. return } - // In stateless mode, the client may provide a session ID for application- - // level state correlation. If absent, generate one. - sessionID := req.Header.Get(sessionIDHeader) - if sessionID == "" { - sessionID = server.opts.GetSessionID() + var sessionID string + if legacySessions { + sessionID = req.Header.Get(sessionIDHeader) + if sessionID == "" { + sessionID = server.opts.GetSessionID() + } } transport := &StreamableServerTransport{ @@ -345,6 +367,19 @@ func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http. transport.ServeHTTP(w, req) } +// serveStatelessLegacyDELETE handles DELETE requests in stateless mode when the +// allowsessionsinstateless compatibility flag is set. DELETE requires a +// Mcp-Session-Id header but is otherwise a no-op since stateless servers don't +// persist sessions. +func (h *StreamableHTTPHandler) serveStatelessLegacyDELETE(w http.ResponseWriter, req *http.Request) { + sessionID := req.Header.Get(sessionIDHeader) + if sessionID == "" { + http.Error(w, "Bad Request: DELETE requires an Mcp-Session-Id header", http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusNoContent) +} + // ephemeralConnectOpts peeks at the request body to determine whether it // contains an initialize or initialized message. If not, default session state // is constructed so that the session doesn't reject the request. diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index d3cd9f8..d2e5422 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1703,40 +1703,16 @@ func TestStreamableStateless(t *testing.T) { } } - sessionlessHandler := NewStreamableHTTPHandler(func(*http.Request) *Server { - // Return a stateless server which never assigns a session ID. - server := NewServer(testImpl, &ServerOptions{ - GetSessionID: func() string { return "" }, - }) - AddTool(server, &Tool{Name: "greet", Description: "say hi"}, sayHi) - return server - }, &StreamableHTTPOptions{ - Stateless: true, - }) - - // First, test the "sessionless" stateless mode, where there is no session ID. - t.Run("sessionless", func(t *testing.T) { - testStreamableHandler(t, sessionlessHandler, requests) - testClientCompatibility(t, sessionlessHandler) - }) - - // Next, test the default stateless mode, where session IDs are permitted. - // - // This can be used by tools to look up application state preserved across - // subsequent requests. - requests[0].wantSessionID = true // now expect a session ID for initialize statelessHandler := NewStreamableHTTPHandler(func(*http.Request) *Server { - // Return a server with default options which should assign a random session ID. server := NewServer(testImpl, nil) AddTool(server, &Tool{Name: "greet", Description: "say hi"}, sayHi) return server }, &StreamableHTTPOptions{ Stateless: true, }) - t.Run("stateless", func(t *testing.T) { - testStreamableHandler(t, statelessHandler, requests) - testClientCompatibility(t, sessionlessHandler) - }) + + testStreamableHandler(t, statelessHandler, requests) + testClientCompatibility(t, statelessHandler) } func textContent(t *testing.T, res *CallToolResult) string { From 1253d17add40d347e379eb2b5ef0919781b0dbf2 Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Fri, 15 May 2026 21:47:34 +0200 Subject: [PATCH 281/418] mcp: do not call DELETE for sessionless & doc adjustments (#960) --- mcp/server.go | 3 +++ mcp/streamable.go | 27 ++++++++++++++++++++------- mcp/streamable_client.go | 13 +++++++++---- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index d25c792..183226d 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -143,6 +143,9 @@ type ServerOptions struct { // // As a special case, if GetSessionID returns the empty string, the // Mcp-Session-Id header will not be set. + // + // GetSessionID is not consulted when [StreamableHTTPOptions.Stateless] is + // true, since stateless servers do not maintain sessions. GetSessionID func() string } diff --git a/mcp/streamable.go b/mcp/streamable.go index 84beff3..d3f3f4f 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -128,13 +128,21 @@ func (i *sessionInfo) stopTimer() { type StreamableHTTPOptions struct { // Stateless controls whether the session is 'stateless'. // - // A stateless server ignores the Mcp-Session-Id header, and uses a - // temporary session with default initialization parameters. Any + // A stateless server does not read or set the Mcp-Session-Id header, and + // uses a temporary session with default initialization parameters for each + // request. [ServerOptions.GetSessionID] is not consulted. Any // server->client request is rejected immediately as there's no way for the // client to respond. Server->Client notifications may reach the client if // they are made in the context of an incoming request, as described in the // documentation for [StreamableServerTransport]. - // In Stateless mode DELETE requests will return 405 Method Not Allowed. + // In Stateless mode, GET and DELETE requests return 405 Method Not Allowed. + // + // This mode aligns with the sessionless direction of the MCP spec; see + // [SEP-2567]. The previous behavior, in which stateless servers still + // honored Mcp-Session-Id, can be restored temporarily via the + // MCPGODEBUG compatibility parameter "allowsessionsinstateless=1". + // + // [SEP-2567]: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567 Stateless bool // TODO(#148): support session retention (?) @@ -1689,11 +1697,13 @@ func init() { // Connect implements the [Transport] interface. // // The resulting [Connection] writes messages via POST requests to the -// transport URL with the Mcp-Session-Id header set, and reads messages from -// hanging requests. +// transport URL, and reads messages from hanging requests. If the server +// provides a session ID via the Mcp-Session-Id response header, subsequent +// requests include it; sessionless servers that omit the header are fully +// supported. // -// When closed, the connection issues a DELETE request to terminate the logical -// session. +// When closed, the connection issues a DELETE request to terminate the +// session, unless no session was established. func (t *StreamableClientTransport) Connect(ctx context.Context) (Connection, error) { client := t.HTTPClient if client == nil { @@ -2370,6 +2380,9 @@ func (c *streamableClientConn) Close() error { c.closeOnce.Do(func() { if errors.Is(c.failure(), ErrSessionMissing) { // If the session is missing, no need to delete it. + } else if c.SessionID() == "" { + // No session was established (e.g. the server is stateless), + // so there is nothing to delete. } else { req, err := http.NewRequestWithContext(c.ctx, http.MethodDelete, c.url, nil) if err != nil { diff --git a/mcp/streamable_client.go b/mcp/streamable_client.go index c2cc25b..74940b3 100644 --- a/mcp/streamable_client.go +++ b/mcp/streamable_client.go @@ -40,8 +40,12 @@ components: # Sessions -The client maintains a session with the server, identified by a session ID -(Mcp-Session-Id header): +The client optionally maintains a session with the server, identified by a +session ID (Mcp-Session-Id header). Sessionless servers (those that never +send Mcp-Session-Id) are fully supported; the client simply omits the +header from subsequent requests and skips the DELETE on close. + +When a session is present: - Session ID is received from the server after initialization - Client includes the session ID in all subsequent requests @@ -162,6 +166,7 @@ The client must handle two response formats from POST requests: - DELETE: Terminate the session - Used by [streamableClientConn.Close] - Skipped if session is already known to be gone ([ErrSessionMissing]) + or if no session was established (sessionless server) # Error Handling @@ -176,7 +181,7 @@ Errors are categorized and handled differently: - 404 Not Found: Session terminated by server ([ErrSessionMissing]) - Message decode errors: Protocol violation - Context cancellation: Client closed connection - - Mismatched session IDs: Protocol error + - Mismatched session IDs: Protocol error (only relevant for servers that use sessions) - See issue #683: our terminal errors are too strict. Terminal errors are stored via [streamableClientConn.fail] and returned by @@ -211,7 +216,7 @@ This header (set by [streamableClientConn.setMCPHeaders]): State management: - [streamableClientConn.incoming]: Buffered channel for received messages - - [streamableClientConn.sessionID]: Server-assigned session identifier + - [streamableClientConn.sessionID]: Server-assigned session identifier (empty for sessionless servers) - [streamableClientConn.initializedResult]: Cached for protocol version header - [streamableClientConn.failed]: Channel closed on terminal error - [streamableClientConn.done]: Channel closed on graceful shutdown From bf69179066780d95c1d5a748fc43ba8bd6be1ffa Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Mon, 18 May 2026 10:26:02 +0200 Subject: [PATCH 282/418] mcp: Add client-side scope accumulation (#944) ## Description This PR implements https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350 Fixes #943 --------- Co-authored-by: Maciej Kisiel --- auth/authorization_code.go | 50 +++++-- auth/authorization_code_test.go | 126 ++++++++++++++++++ auth/extauth/client_credentials.go | 40 ++++-- auth/extauth/client_credentials_test.go | 82 ++++++++++++ internal/authutil/scopes.go | 36 +++++ internal/authutil/scopes_test.go | 72 ++++++++++ .../oauthtest/fake_authorization_server.go | 30 ++++- 7 files changed, 413 insertions(+), 23 deletions(-) create mode 100644 internal/authutil/scopes.go create mode 100644 internal/authutil/scopes_test.go diff --git a/auth/authorization_code.go b/auth/authorization_code.go index 9b41e36..47541f9 100644 --- a/auth/authorization_code.go +++ b/auth/authorization_code.go @@ -15,6 +15,7 @@ import ( "slices" "strings" + "github.com/modelcontextprotocol/go-sdk/internal/authutil" "github.com/modelcontextprotocol/go-sdk/internal/util" "github.com/modelcontextprotocol/go-sdk/oauthex" "golang.org/x/oauth2" @@ -128,6 +129,9 @@ type AuthorizationCodeHandler struct { // tokenSource is the token source to use for authorization. tokenSource oauth2.TokenSource + + // grantedScopes maps authorization server issuer to the list of scopes granted by that issuer. + grantedScopes map[string][]string } var _ OAuthHandler = (*AuthorizationCodeHandler)(nil) @@ -187,7 +191,10 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho if config.Client == nil { config.Client = http.DefaultClient } - return &AuthorizationCodeHandler{config: config}, nil + return &AuthorizationCodeHandler{ + config: config, + grantedScopes: make(map[string][]string), + }, nil } func isNonRootHTTPSURL(u string) bool { @@ -275,19 +282,24 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ return err } - scps := scopesFromChallenges(wwwChallenges) - if len(scps) == 0 && len(prm.ScopesSupported) > 0 { - scps = prm.ScopesSupported + requestedScopes := scopesFromChallenges(wwwChallenges) + if len(requestedScopes) == 0 && len(prm.ScopesSupported) > 0 { + requestedScopes = prm.ScopesSupported } // SEP-2207: when the client desires refresh tokens and the Authorization // Server advertises offline_access support, add it to the requested scopes. if h.config.RequestRefreshToken && slices.Contains(asm.ScopesSupported, "offline_access") && - !slices.Contains(scps, "offline_access") { - scps = append(scps, "offline_access") + !slices.Contains(requestedScopes, "offline_access") { + requestedScopes = append(requestedScopes, "offline_access") } + // Accumulate scopes: union previously granted scopes with the newly + // challenged scopes so that step-up authorization does not lose + // permissions granted in earlier rounds (SEP-2350). + requestedScopes = authutil.UnionScopes(h.grantedScopes[asm.Issuer], requestedScopes) + cfg := &oauth2.Config{ ClientID: resolvedClientConfig.clientID, ClientSecret: resolvedClientConfig.clientSecret, @@ -298,7 +310,7 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ AuthStyle: resolvedClientConfig.authStyle, }, RedirectURL: h.config.RedirectURL, - Scopes: scps, + Scopes: requestedScopes, } authRes, err := h.getAuthorizationCode(ctx, cfg, prm.Resource) @@ -307,7 +319,12 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ return err } - return h.exchangeAuthorizationCode(ctx, cfg, authRes, prm.Resource) + err = h.exchangeAuthorizationCode(ctx, cfg, authRes, prm.Resource) + if err != nil { + return err + } + + return h.updateGrantedScopes(asm.Issuer, requestedScopes) } // resourceMetadataURLFromChallenges returns a resource metadata URL from the given "WWW-Authenticate" header challenges, @@ -558,3 +575,20 @@ func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context h.tokenSource = cfg.TokenSource(clientCtx, token) return nil } + +// updateGrantedScopes updates the granted scopes based on the token source and requested scopes. +func (h *AuthorizationCodeHandler) updateGrantedScopes(issuer string, requestedScopes []string) error { + if h.tokenSource == nil { + return nil + } + tok, err := h.tokenSource.Token() + if err != nil { + return err + } + if tokenScopes := authutil.ScopesFromToken(tok); tokenScopes == nil { + h.grantedScopes[issuer] = requestedScopes + } else { + h.grantedScopes[issuer] = tokenScopes + } + return nil +} diff --git a/auth/authorization_code_test.go b/auth/authorization_code_test.go index d30cbf3..1252005 100644 --- a/auth/authorization_code_test.go +++ b/auth/authorization_code_test.go @@ -16,6 +16,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/modelcontextprotocol/go-sdk/internal/oauthtest" "github.com/modelcontextprotocol/go-sdk/oauthex" "golang.org/x/oauth2" @@ -113,6 +114,131 @@ func TestAuthorize(t *testing.T) { } } +func TestAuthorize_ScopeAccumulation(t *testing.T) { + authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + RegistrationConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "test_client_id": { + Secret: "test_client_secret", + RedirectURIs: []string{"http://localhost:12345/callback"}, + }, + }, + }, + TokenScopeFunc: func(requestedScope string) string { + // Simulate a server that never grants "write". + var granted []string + for _, s := range strings.Fields(requestedScope) { + if s != "write" { + granted = append(granted, s) + } + } + return strings.Join(granted, " ") + }, + }) + authServer.Start(t) + + resourceMux := http.NewServeMux() + resourceServer := httptest.NewServer(resourceMux) + t.Cleanup(resourceServer.Close) + resourceURL := resourceServer.URL + "/resource" + + resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{ + Resource: resourceURL, + AuthorizationServers: []string{authServer.URL()}, + })) + + var capturedAuthURLs []string + noRedirectClient := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{ + RedirectURL: "http://localhost:12345/callback", + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "test_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "test_client_secret", + }, + }, + AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + capturedAuthURLs = append(capturedAuthURLs, args.URL) + resp, err := noRedirectClient.Get(args.URL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + loc, err := resp.Location() + if err != nil { + return nil, err + } + return &AuthorizationResult{ + Code: loc.Query().Get("code"), + State: loc.Query().Get("state"), + }, nil + }, + }) + if err != nil { + t.Fatalf("NewAuthorizationCodeHandler failed: %v", err) + } + + // First authorization: 401 with scope="read write". + // The token response will only grant "read" (TokenScopeFunc strips "write"). + req := httptest.NewRequest(http.MethodGet, resourceURL, nil) + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: http.NoBody, + } + resp.Header.Set("WWW-Authenticate", + fmt.Sprintf(`Bearer scope="read write", resource_metadata="%s/.well-known/oauth-protected-resource/resource"`, resourceServer.URL)) + if err := handler.Authorize(context.Background(), req, resp); err != nil { + t.Fatalf("First Authorize failed: %v", err) + } + + // Verify first auth URL requested "read" and "write". + firstURL, err := url.Parse(capturedAuthURLs[0]) + if err != nil { + t.Fatalf("Failed to parse first auth URL: %v", err) + } + firstScopes := strings.Fields(firstURL.Query().Get("scope")) + if diff := cmp.Diff([]string{"read", "write"}, firstScopes, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" { + t.Errorf("First auth scopes mismatch (-want +got):\n%s", diff) + } + + // Verify only "read" was granted (the token omitted "write"). + issuer := authServer.URL() + if diff := cmp.Diff([]string{"read"}, handler.grantedScopes[issuer], cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" { + t.Errorf("After first Authorize, grantedScopes mismatch (-want +got):\n%s", diff) + } + + // Second authorization: 403 insufficient_scope with scope="admin". + // Accumulated scopes should be "read" (previously granted) + "admin" (new). + req2 := httptest.NewRequest(http.MethodGet, resourceURL, nil) + resp2 := &http.Response{ + StatusCode: http.StatusForbidden, + Header: make(http.Header), + Body: http.NoBody, + } + resp2.Header.Set("WWW-Authenticate", + fmt.Sprintf(`Bearer error="insufficient_scope", scope="admin", resource_metadata="%s/.well-known/oauth-protected-resource/resource"`, resourceServer.URL)) + if err := handler.Authorize(context.Background(), req2, resp2); err != nil { + t.Fatalf("Second Authorize failed: %v", err) + } + + // Verify second auth URL accumulated "read" (granted) + "admin" (challenged), + // but NOT "write" (requested but never granted). + secondURL, err := url.Parse(capturedAuthURLs[1]) + if err != nil { + t.Fatalf("Failed to parse second auth URL: %v", err) + } + secondScopes := strings.Fields(secondURL.Query().Get("scope")) + if diff := cmp.Diff([]string{"admin", "read"}, secondScopes, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" { + t.Errorf("Second auth scopes mismatch (-want +got):\n%s", diff) + } +} + func TestAuthorize_ForbiddenUnhandledError(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/resource", nil) resp := &http.Response{ diff --git a/auth/extauth/client_credentials.go b/auth/extauth/client_credentials.go index fefa29b..b95fcae 100644 --- a/auth/extauth/client_credentials.go +++ b/auth/extauth/client_credentials.go @@ -14,6 +14,7 @@ import ( "strings" "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/internal/authutil" "github.com/modelcontextprotocol/go-sdk/oauthex" "golang.org/x/oauth2" "golang.org/x/oauth2/clientcredentials" @@ -47,6 +48,9 @@ type ClientCredentialsHandlerConfig struct { type ClientCredentialsHandler struct { config *ClientCredentialsHandlerConfig tokenSource oauth2.TokenSource + + // grantedScopes maps authorization server issuer to the list of scopes granted by that issuer. + grantedScopes map[string][]string } // Compile-time check that ClientCredentialsHandler implements auth.OAuthHandler. @@ -67,7 +71,10 @@ func NewClientCredentialsHandler(config *ClientCredentialsHandlerConfig) (*Clien if config.Credentials.ClientSecretAuth == nil { return nil, fmt.Errorf("clientSecretAuth is required for client credentials grant") } - return &ClientCredentialsHandler{config: config}, nil + return &ClientCredentialsHandler{ + config: config, + grantedScopes: make(map[string][]string), + }, nil } // TokenSource returns the token source for outgoing requests. @@ -121,30 +128,47 @@ func (h *ClientCredentialsHandler) Authorize(ctx context.Context, req *http.Requ } } - // Determine scopes: use PRM's scopes_supported if available. - scopes := scopesFromChallenges(wwwChallenges) - if len(scopes) == 0 && len(prm.ScopesSupported) > 0 { - scopes = prm.ScopesSupported + // Determine requestedScopes: use PRM's scopes_supported if available. + requestedScopes := scopesFromChallenges(wwwChallenges) + if len(requestedScopes) == 0 && len(prm.ScopesSupported) > 0 { + requestedScopes = prm.ScopesSupported } + // Accumulate scopes: union previously granted scopes with the newly + // challenged scopes so that step-up authorization does not lose + // permissions granted in earlier rounds (SEP-2350). + requestedScopes = authutil.UnionScopes(h.grantedScopes[asm.Issuer], requestedScopes) + // Step 3: Exchange client credentials for an access token. creds := h.config.Credentials cfg := &clientcredentials.Config{ ClientID: creds.ClientID, ClientSecret: creds.ClientSecretAuth.ClientSecret, TokenURL: asm.TokenEndpoint, - Scopes: scopes, + Scopes: requestedScopes, AuthStyle: selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported), } ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient) h.tokenSource = cfg.TokenSource(ctxWithClient) - // Eagerly fetch a token to surface errors immediately. - if _, err := h.tokenSource.Token(); err != nil { + return h.updateGrantedScopes(asm.Issuer, requestedScopes) +} + +func (h *ClientCredentialsHandler) updateGrantedScopes(issuer string, requestedScopes []string) error { + if h.tokenSource == nil { + return nil + } + tok, err := h.tokenSource.Token() + if err != nil { h.tokenSource = nil return fmt.Errorf("client credentials token request failed: %w", err) } + if tokenScopes := authutil.ScopesFromToken(tok); tokenScopes == nil { + h.grantedScopes[issuer] = requestedScopes + } else { + h.grantedScopes[issuer] = tokenScopes + } return nil } diff --git a/auth/extauth/client_credentials_test.go b/auth/extauth/client_credentials_test.go index 91ae7c0..550e56a 100644 --- a/auth/extauth/client_credentials_test.go +++ b/auth/extauth/client_credentials_test.go @@ -10,6 +10,8 @@ import ( "strings" "testing" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/modelcontextprotocol/go-sdk/auth" "github.com/modelcontextprotocol/go-sdk/internal/oauthtest" "github.com/modelcontextprotocol/go-sdk/oauthex" @@ -255,6 +257,86 @@ func TestClientCredentialsHandler_Authorize(t *testing.T) { }) } +func TestClientCredentialsHandler_ScopeAccumulation(t *testing.T) { + authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + MetadataEndpointConfig: &oauthtest.MetadataEndpointConfig{ + ServeOAuthInsertedEndpoint: true, + }, + RegistrationConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "test-client": {Secret: "test-secret"}, + }, + }, + ClientCredentialsConfig: &oauthtest.ClientCredentialsConfig{ + Enabled: true, + }, + TokenScopeFunc: func(requestedScope string) string { + // Simulate a server that never grants "write". + var granted []string + for _, s := range strings.Fields(requestedScope) { + if s != "write" { + granted = append(granted, s) + } + } + return strings.Join(granted, " ") + }, + }) + authServer.Start(t) + + resourceMux := http.NewServeMux() + resourceServer := httptest.NewServer(resourceMux) + t.Cleanup(resourceServer.Close) + resourceURL := resourceServer.URL + "/resource" + + resourceMux.Handle("/.well-known/oauth-protected-resource/resource", auth.ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{ + Resource: resourceURL, + AuthorizationServers: []string{authServer.URL()}, + })) + + handler, err := NewClientCredentialsHandler(validClientCredentialsConfig()) + if err != nil { + t.Fatal(err) + } + + // First authorization: 401 with scope="read write". + // The token response will only grant "read" (TokenScopeFunc strips "write"). + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: http.NoBody, + } + resp.Header.Set("WWW-Authenticate", `Bearer scope="read write"`) + req := httptest.NewRequest("GET", resourceURL, nil) + if err := handler.Authorize(t.Context(), req, resp); err != nil { + t.Fatalf("First Authorize failed: %v", err) + } + + // Verify only "read" was granted (the token omitted "write"). + issuer := authServer.URL() + if diff := cmp.Diff([]string{"read"}, handler.grantedScopes[issuer], cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" { + t.Errorf("After first Authorize, grantedScopes mismatch (-want +got):\n%s", diff) + } + + // Second authorization: 401 with scope="admin" (simulating step-up). + // Accumulated scopes should be "read" (previously granted) + "admin" (new), + // but NOT "write" (requested but never granted). + resp2 := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: http.NoBody, + } + resp2.Header.Set("WWW-Authenticate", `Bearer scope="admin"`) + req2 := httptest.NewRequest("GET", resourceURL, nil) + if err := handler.Authorize(t.Context(), req2, resp2); err != nil { + t.Fatalf("Second Authorize failed: %v", err) + } + + // Verify accumulated scopes: "read" (granted) + "admin" (new), no "write". + if diff := cmp.Diff([]string{"admin", "read"}, handler.grantedScopes[issuer], cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" { + t.Errorf("After second Authorize, grantedScopes mismatch (-want +got):\n%s", diff) + } +} + func TestSelectTokenAuthMethod(t *testing.T) { tests := []struct { name string diff --git a/internal/authutil/scopes.go b/internal/authutil/scopes.go new file mode 100644 index 0000000..4b5de73 --- /dev/null +++ b/internal/authutil/scopes.go @@ -0,0 +1,36 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. +package authutil + +import ( + "maps" + "slices" + "strings" + + "golang.org/x/oauth2" +) + +// UnionScopes returns the union of the existing and challenged scope sets. +// It is used during step-up authorization to accumulate scopes across +// authorization rounds (SEP-2350). +func UnionScopes(existing, challenged []string) []string { + combined := make(map[string]struct{}) + for _, s := range existing { + combined[s] = struct{}{} + } + for _, s := range challenged { + combined[s] = struct{}{} + } + return slices.Collect(maps.Keys(combined)) +} + +// ScopesFromToken extracts the granted scopes from an OAuth2 token response. +// Per RFC 6749 §5.1, the scope parameter is optional; returns nil if absent. +func ScopesFromToken(token *oauth2.Token) []string { + scope, ok := token.Extra("scope").(string) + if !ok { + return nil + } + return strings.Fields(scope) +} diff --git a/internal/authutil/scopes_test.go b/internal/authutil/scopes_test.go new file mode 100644 index 0000000..1fdfd7d --- /dev/null +++ b/internal/authutil/scopes_test.go @@ -0,0 +1,72 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. +package authutil + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" +) + +func TestUnionScopes(t *testing.T) { + tests := []struct { + name string + existing []string + challenged []string + want []string + }{ + { + name: "both empty", + existing: nil, + challenged: nil, + want: nil, + }, + { + name: "existing only", + existing: []string{"read"}, + challenged: nil, + want: []string{"read"}, + }, + { + name: "challenged only", + existing: nil, + challenged: []string{"write"}, + want: []string{"write"}, + }, + { + name: "disjoint scopes", + existing: []string{"read"}, + challenged: []string{"write"}, + want: []string{"read", "write"}, + }, + { + name: "overlapping scopes", + existing: []string{"read", "write"}, + challenged: []string{"write", "admin"}, + want: []string{"read", "write", "admin"}, + }, + { + name: "identical scopes", + existing: []string{"read", "write"}, + challenged: []string{"read", "write"}, + want: []string{"read", "write"}, + }, + { + name: "mixed scopes", + existing: []string{"b", "a"}, + challenged: []string{"c", "a"}, + want: []string{"a", "b", "c"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := UnionScopes(tt.existing, tt.challenged) + if diff := cmp.Diff(tt.want, got, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" { + t.Errorf("UnionScopes() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/oauthtest/fake_authorization_server.go b/internal/oauthtest/fake_authorization_server.go index 94d725a..b27e00e 100644 --- a/internal/oauthtest/fake_authorization_server.go +++ b/internal/oauthtest/fake_authorization_server.go @@ -85,6 +85,9 @@ type Config struct { // ScopesSupported is an optional list of scopes to advertise in the // authorization server metadata. ScopesSupported []string + // TokenScopeFunc, if set, is called with the scope from the authorization + // request and returns the scope string to include in the token response. + TokenScopeFunc func(requestedScope string) string } // FakeAuthorizationServer is a fake OAuth 2.0 Authorization Server for testing. @@ -98,6 +101,7 @@ type FakeAuthorizationServer struct { type codeInfo struct { CodeChallenge string + Scope string } // NewFakeAuthorizationServer creates a new FakeAuthorizationServer. @@ -259,6 +263,7 @@ func (s *FakeAuthorizationServer) handleAuthorize(w http.ResponseWriter, r *http code := rand.Text() s.codes[code] = codeInfo{ CodeChallenge: codeChallenge, + Scope: r.URL.Query().Get("scope"), } state := r.URL.Query().Get("state") @@ -317,12 +322,18 @@ func (s *FakeAuthorizationServer) handleAuthorizationCodeGrant(w http.ResponseWr return } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ + resp := map[string]any{ "access_token": "test_access_token", "token_type": "Bearer", "expires_in": 3600, - }) + } + if s.config.TokenScopeFunc != nil { + if scope := s.config.TokenScopeFunc(codeInfo.Scope); scope != "" { + resp["scope"] = scope + } + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) } func (s *FakeAuthorizationServer) handleJWTBearerGrant(w http.ResponseWriter, r *http.Request) { @@ -355,13 +366,18 @@ func (s *FakeAuthorizationServer) handleClientCredentialsGrant(w http.ResponseWr http.Error(w, "client_credentials grant not supported", http.StatusBadRequest) return } - // Client was already authenticated in handleToken. - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ + resp := map[string]any{ "access_token": "test_access_token", "token_type": "Bearer", "expires_in": 3600, - }) + } + if s.config.TokenScopeFunc != nil { + if scope := s.config.TokenScopeFunc(r.Form.Get("scope")); scope != "" { + resp["scope"] = scope + } + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) } func (s *FakeAuthorizationServer) authenticateClient(r *http.Request) error { From bed83e104b39b62b834ecf1b0838c4013b2d1043 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Mon, 25 May 2026 21:15:26 +0800 Subject: [PATCH 283/418] fix: reject duplicate initialize requests (#962) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - reject a second `initialize` request on an already initialized server session - keep the original `ServerSession.InitializeParams()` instead of replacing it with later client parameters - add a raw JSON-RPC regression test for the duplicate-initialize path Fixes #961. ## To verify - `go test ./mcp -run TestServerRejectsDuplicateInitialize -count=1` - `go test ./mcp -count=1` - `go test ./...` --------- Co-authored-by: guglielmoc --- mcp/server.go | 10 +++++- mcp/server_test.go | 86 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/mcp/server.go b/mcp/server.go index 183226d..7526ea7 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1488,9 +1488,17 @@ func (ss *ServerSession) initialize(ctx context.Context, params *InitializeParam if params == nil { return nil, fmt.Errorf("%w: \"params\" must be be provided", jsonrpc2.ErrInvalidParams) } + var wasInit bool ss.updateState(func(state *ServerSessionState) { - state.InitializeParams = params + wasInit = state.InitializeParams != nil + if !wasInit { + state.InitializeParams = params + } }) + if wasInit { + ss.server.opts.Logger.Error("duplicate initialize request") + return nil, fmt.Errorf("duplicate %q received", methodInitialize) + } s := ss.server return &InitializeResult{ diff --git a/mcp/server_test.go b/mcp/server_test.go index 2937ea2..dcc7fb0 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -825,6 +825,92 @@ func TestClientRootCapabilities(t *testing.T) { } } +func TestServerRejectsDuplicateInitialize(t *testing.T) { + ctx := context.Background() + + server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) + cTransport, sTransport := NewInMemoryTransports() + ss, err := server.Connect(ctx, sTransport, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + cConn, err := cTransport.Connect(ctx) + if err != nil { + t.Fatal(err) + } + defer cConn.Close() + + firstParams := json.RawMessage(`{ + "protocolVersion": "2025-11-25", + "clientInfo": {"name": "first-client", "version": "1.0.0"} + }`) + firstReq, err := jsonrpc2.NewCall(jsonrpc2.Int64ID(1), methodInitialize, firstParams) + if err != nil { + t.Fatal(err) + } + if err := cConn.Write(ctx, firstReq); err != nil { + t.Fatalf("first initialize write failed: %v", err) + } + msg, err := cConn.Read(ctx) + if err != nil { + t.Fatalf("first initialize read failed: %v", err) + } + resp, ok := msg.(*jsonrpc2.Response) + if !ok { + t.Fatalf("expected Response, got %T", msg) + } + if resp.Error != nil { + t.Fatalf("first initialize failed: %v", resp.Error) + } + + initializedReq, err := jsonrpc2.NewNotification(notificationInitialized, &InitializedParams{}) + if err != nil { + t.Fatal(err) + } + if err := cConn.Write(ctx, initializedReq); err != nil { + t.Fatalf("initialized notification write failed: %v", err) + } + + secondParams := json.RawMessage(`{ + "protocolVersion": "2024-11-05", + "clientInfo": {"name": "second-client", "version": "2.0.0"} + }`) + secondReq, err := jsonrpc2.NewCall(jsonrpc2.Int64ID(2), methodInitialize, secondParams) + if err != nil { + t.Fatal(err) + } + if err := cConn.Write(ctx, secondReq); err != nil { + t.Fatalf("second initialize write failed: %v", err) + } + msg, err = cConn.Read(ctx) + if err != nil { + t.Fatalf("second initialize read failed: %v", err) + } + resp, ok = msg.(*jsonrpc2.Response) + if !ok { + t.Fatalf("expected Response, got %T", msg) + } + if resp.Error == nil { + t.Fatal("second initialize unexpectedly succeeded") + } + if !strings.Contains(resp.Error.Error(), `duplicate "initialize" received`) { + t.Fatalf("second initialize error = %v, want duplicate initialize", resp.Error) + } + + got := ss.InitializeParams() + if got == nil { + t.Fatal("InitializeParams is nil") + } + if got.ProtocolVersion != "2025-11-25" { + t.Fatalf("ProtocolVersion = %q, want first initialize value", got.ProtocolVersion) + } + if got.ClientInfo == nil || got.ClientInfo.Name != "first-client" { + t.Fatalf("ClientInfo = %#v, want first initialize value", got.ClientInfo) + } +} + // TODO: move this to tool_test.go func TestToolForSchemas(t *testing.T) { // Validate that toolForErr handles schemas properly. From c044cdb734d8d321d596f5b6077072798dce4ad6 Mon Sep 17 00:00:00 2001 From: Pavel Bazin Date: Mon, 25 May 2026 10:52:15 -0300 Subject: [PATCH 284/418] fix: do not `omitempty` `ReadOnlyHint` in `ToolAnnotations` (#908) `ReadOnlyHint` is a `bool` with omitempty, so the zero value (false) is indistinguishable from unset, and **drops out** of marshaled JSON. The current behavior causes an issue with the OpenAI MCP app submission because they require explicit hints. Consumers that explicitly set `ReadOnlyHint: false` on write tools lose the field on the wire. Removing `omitempty` ensures `false` is always serialized, which matches the MCP spec default. --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Maciej Kisiel Co-authored-by: guglielmoc --- docs/mcpgodebug.md | 6 ++++ docs/rough_edges.md | 5 +++ internal/docs/mcpgodebug.src.md | 6 ++++ internal/docs/rough_edges.src.md | 5 +++ mcp/protocol.go | 30 +++++++++++++++-- mcp/protocol_test.go | 57 ++++++++++++++++++++++++++++++++ 6 files changed, 107 insertions(+), 2 deletions(-) diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index 36eddb5..f1e6237 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -27,6 +27,12 @@ Options listed below were added and will be removed in the 1.9.0 version of the Params), restoring the previous behavior. The default behavior was changed to align with SEP-2164 and the JSON-RPC specification. +- `hintomitempty` added. If set to `1`, `ToolAnnotations` JSON marshaling + will omit `ReadOnlyHint` and `IdempotentHint` when their value is `false`, + restoring the previous behavior. The default behavior was changed to always + serialize these fields, since their Go types are bare `bool` (not `*bool`) + and omitting `false` made it indistinguishable from unset. + - `allowsessionsinstateless` added. If set to `1`, stateless streamable HTTP servers will read the `Mcp-Session-Id` request header (or generate one via `GetSessionID`), set it on response headers, and accept `DELETE` requests, diff --git a/docs/rough_edges.md b/docs/rough_edges.md index 7ee18b1..c1a618d 100644 --- a/docs/rough_edges.md +++ b/docs/rough_edges.md @@ -63,3 +63,8 @@ v2. - `StreamableHTTPOptions.CrossOriginProtection` should not have been part of the SDK API. Cross-origin protection is a general HTTP concern, not specific to MCP, and can be applied as standard HTTP middleware. + +- `ToolAnnotations` (`mcp/protocol.go`) should have all fields typed as `*bool` + for full control to define what is being sent over the wire. Different + MCP clients have different requirements, and some of them require all fields + to be explicitly set to either `true` or `false`. diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index b5f5a78..88639a2 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -26,6 +26,12 @@ Options listed below were added and will be removed in the 1.9.0 version of the Params), restoring the previous behavior. The default behavior was changed to align with SEP-2164 and the JSON-RPC specification. +- `hintomitempty` added. If set to `1`, `ToolAnnotations` JSON marshaling + will omit `ReadOnlyHint` and `IdempotentHint` when their value is `false`, + restoring the previous behavior. The default behavior was changed to always + serialize these fields, since their Go types are bare `bool` (not `*bool`) + and omitting `false` made it indistinguishable from unset. + - `allowsessionsinstateless` added. If set to `1`, stateless streamable HTTP servers will read the `Mcp-Session-Id` request header (or generate one via `GetSessionID`), set it on response headers, and accept `DELETE` requests, diff --git a/internal/docs/rough_edges.src.md b/internal/docs/rough_edges.src.md index d573e14..90751f7 100644 --- a/internal/docs/rough_edges.src.md +++ b/internal/docs/rough_edges.src.md @@ -62,3 +62,8 @@ v2. - `StreamableHTTPOptions.CrossOriginProtection` should not have been part of the SDK API. Cross-origin protection is a general HTTP concern, not specific to MCP, and can be applied as standard HTTP middleware. + +- `ToolAnnotations` (`mcp/protocol.go`) should have all fields typed as `*bool` + for full control to define what is being sent over the wire. Different + MCP clients have different requirements, and some of them require all fields + to be explicitly set to either `true` or `false`. \ No newline at end of file diff --git a/mcp/protocol.go b/mcp/protocol.go index 1646788..0af1ec5 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -1346,6 +1346,13 @@ type Tool struct { Icons []Icon `json:"icons,omitempty"` } +// hintomitempty is a compatibility parameter that restores the pre-1.7.0 +// behavior of [ToolAnnotations] JSON marshaling, where false-valued bare bool +// fields (ReadOnlyHint, IdempotentHint) were omitted from the output. +// See the documentation for the mcpgodebug package for instructions on how to +// enable it. +var hintomitempty = mcpgodebug.Value("hintomitempty") + // Additional properties describing a Tool to clients. // // NOTE: all properties in ToolAnnotations are hints. They are not @@ -1368,7 +1375,7 @@ type ToolAnnotations struct { // (This property is meaningful only when ReadOnlyHint == false.) // // Default: false - IdempotentHint bool `json:"idempotentHint,omitempty"` + IdempotentHint bool `json:"idempotentHint"` // If true, this tool may interact with an "open world" of external entities. If // false, the tool's domain of interaction is closed. For example, the world of // a web search tool is open, whereas that of a memory tool is not. @@ -1378,11 +1385,30 @@ type ToolAnnotations struct { // If true, the tool does not modify its environment. // // Default: false - ReadOnlyHint bool `json:"readOnlyHint,omitempty"` + ReadOnlyHint bool `json:"readOnlyHint"` // A human-readable title for the tool. Title string `json:"title,omitempty"` } +// MarshalJSON implements [json.Marshaler] for ToolAnnotations. +// +// To restore the previous behavior where false-valued ReadOnlyHint and +// IdempotentHint were omitted, set MCPGODEBUG=hintomitempty=1. +func (t ToolAnnotations) MarshalJSON() ([]byte, error) { + if hintomitempty == "1" { + type compat struct { + DestructiveHint *bool `json:"destructiveHint,omitempty"` + IdempotentHint bool `json:"idempotentHint,omitempty"` + OpenWorldHint *bool `json:"openWorldHint,omitempty"` + ReadOnlyHint bool `json:"readOnlyHint,omitempty"` + Title string `json:"title,omitempty"` + } + return json.Marshal(compat(t)) + } + type nomethod ToolAnnotations + return json.Marshal(nomethod(t)) +} + type ToolListChangedParams struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. diff --git a/mcp/protocol_test.go b/mcp/protocol_test.go index 751d081..2bab644 100644 --- a/mcp/protocol_test.go +++ b/mcp/protocol_test.go @@ -1194,3 +1194,60 @@ func TestContentUnmarshal(t *testing.T) { var gotpm PromptMessage roundtrip(pm, &gotpm) } + +func TestToolAnnotations_MarshalJSON(t *testing.T) { + boolPtr := func(b bool) *bool { return &b } + + tests := []struct { + name string + in ToolAnnotations + want string + }{ + { + name: "ZeroValue", + in: ToolAnnotations{}, + want: `{"idempotentHint":false,"readOnlyHint":false}`, + }, + { + name: "AllFalse", + in: ToolAnnotations{ + DestructiveHint: boolPtr(false), + IdempotentHint: false, + OpenWorldHint: boolPtr(false), + ReadOnlyHint: false, + }, + want: `{"destructiveHint":false,"idempotentHint":false,"openWorldHint":false,"readOnlyHint":false}`, + }, + { + name: "AllTrue", + in: ToolAnnotations{ + DestructiveHint: boolPtr(true), + IdempotentHint: true, + OpenWorldHint: boolPtr(true), + ReadOnlyHint: true, + Title: "my tool", + }, + want: `{"destructiveHint":true,"idempotentHint":true,"openWorldHint":true,"readOnlyHint":true,"title":"my tool"}`, + }, + { + name: "MixedValues", + in: ToolAnnotations{ + ReadOnlyHint: true, + Title: "read tool", + }, + want: `{"idempotentHint":false,"readOnlyHint":true,"title":"read tool"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := json.Marshal(tt.in) + if err != nil { + t.Fatalf("json.Marshal(%v) failed: %v", tt.in, err) + } + if diff := cmp.Diff(tt.want, string(got)); diff != "" { + t.Errorf("json.Marshal() mismatch (-want +got):\n%s", diff) + } + }) + } +} From 4cbdd6a514a8f5718a40f1baaec8a0af99717950 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Tue, 26 May 2026 16:25:51 +0200 Subject: [PATCH 285/418] 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](https://github.com/modelcontextprotocol/go-sdk/blob/614460a2253e7772eff6c78f142bfa0428530dc5/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 --- mcp/protocol.go | 56 ++++- mcp/server.go | 27 ++- mcp/server_test.go | 166 +++++++++++++++ mcp/shared.go | 155 ++++++++++++++ mcp/shared_test.go | 241 ++++++++++++++++++++++ mcp/streamable.go | 77 +++++-- mcp/streamable_test.go | 453 +++++++++++++++++++++++++++++++++++++---- 7 files changed, 1113 insertions(+), 62 deletions(-) diff --git a/mcp/protocol.go b/mcp/protocol.go index 0af1ec5..fcba83f 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -165,10 +165,12 @@ func (x *CallToolResult) UnmarshalJSON(data []byte) error { } func (x *CallToolParams) isParams() {} +func (x *CallToolParams) isNil() bool { return x == nil } func (x *CallToolParams) GetProgressToken() any { return getProgressToken(x) } func (x *CallToolParams) SetProgressToken(t any) { setProgressToken(x, t) } func (x *CallToolParamsRaw) isParams() {} +func (x *CallToolParamsRaw) isNil() bool { return x == nil } func (x *CallToolParamsRaw) GetProgressToken() any { return getProgressToken(x) } func (x *CallToolParamsRaw) SetProgressToken(t any) { setProgressToken(x, t) } @@ -187,6 +189,7 @@ type CancelledParams struct { } func (x *CancelledParams) isParams() {} +func (x *CancelledParams) isNil() bool { return x == nil } func (x *CancelledParams) GetProgressToken() any { return getProgressToken(x) } func (x *CancelledParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -374,7 +377,8 @@ type CompleteParams struct { Ref *CompleteReference `json:"ref"` } -func (*CompleteParams) isParams() {} +func (x *CompleteParams) isParams() {} +func (x *CompleteParams) isNil() bool { return x == nil } type CompletionResultDetails struct { HasMore bool `json:"hasMore,omitempty"` @@ -422,6 +426,7 @@ type CreateMessageParams struct { } func (x *CreateMessageParams) isParams() {} +func (x *CreateMessageParams) isNil() bool { return x == nil } func (x *CreateMessageParams) GetProgressToken() any { return getProgressToken(x) } func (x *CreateMessageParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -448,6 +453,7 @@ type CreateMessageWithToolsParams struct { } func (x *CreateMessageWithToolsParams) isParams() {} +func (x *CreateMessageWithToolsParams) isNil() bool { return x == nil } func (x *CreateMessageWithToolsParams) GetProgressToken() any { return getProgressToken(x) } func (x *CreateMessageWithToolsParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -654,6 +660,7 @@ type GetPromptParams struct { } func (x *GetPromptParams) isParams() {} +func (x *GetPromptParams) isNil() bool { return x == nil } func (x *GetPromptParams) GetProgressToken() any { return getProgressToken(x) } func (x *GetPromptParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -706,6 +713,7 @@ func (p *initializeParamsV2) toV1() *InitializeParams { } func (x *InitializeParams) isParams() {} +func (x *InitializeParams) isNil() bool { return x == nil } func (x *InitializeParams) GetProgressToken() any { return getProgressToken(x) } func (x *InitializeParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -739,6 +747,7 @@ type InitializedParams struct { } func (x *InitializedParams) isParams() {} +func (x *InitializedParams) isNil() bool { return x == nil } func (x *InitializedParams) GetProgressToken() any { return getProgressToken(x) } func (x *InitializedParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -752,6 +761,7 @@ type ListPromptsParams struct { } func (x *ListPromptsParams) isParams() {} +func (x *ListPromptsParams) isNil() bool { return x == nil } func (x *ListPromptsParams) GetProgressToken() any { return getProgressToken(x) } func (x *ListPromptsParams) SetProgressToken(t any) { setProgressToken(x, t) } func (x *ListPromptsParams) cursorPtr() *string { return &x.Cursor } @@ -780,6 +790,7 @@ type ListResourceTemplatesParams struct { } func (x *ListResourceTemplatesParams) isParams() {} +func (x *ListResourceTemplatesParams) isNil() bool { return x == nil } func (x *ListResourceTemplatesParams) GetProgressToken() any { return getProgressToken(x) } func (x *ListResourceTemplatesParams) SetProgressToken(t any) { setProgressToken(x, t) } func (x *ListResourceTemplatesParams) cursorPtr() *string { return &x.Cursor } @@ -808,6 +819,7 @@ type ListResourcesParams struct { } func (x *ListResourcesParams) isParams() {} +func (x *ListResourcesParams) isNil() bool { return x == nil } func (x *ListResourcesParams) GetProgressToken() any { return getProgressToken(x) } func (x *ListResourcesParams) SetProgressToken(t any) { setProgressToken(x, t) } func (x *ListResourcesParams) cursorPtr() *string { return &x.Cursor } @@ -833,6 +845,7 @@ type ListRootsParams struct { } func (x *ListRootsParams) isParams() {} +func (x *ListRootsParams) isNil() bool { return x == nil } func (x *ListRootsParams) GetProgressToken() any { return getProgressToken(x) } func (x *ListRootsParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -858,6 +871,7 @@ type ListToolsParams struct { } func (x *ListToolsParams) isParams() {} +func (x *ListToolsParams) isNil() bool { return x == nil } func (x *ListToolsParams) GetProgressToken() any { return getProgressToken(x) } func (x *ListToolsParams) SetProgressToken(t any) { setProgressToken(x, t) } func (x *ListToolsParams) cursorPtr() *string { return &x.Cursor } @@ -896,6 +910,7 @@ type LoggingMessageParams struct { } func (x *LoggingMessageParams) isParams() {} +func (x *LoggingMessageParams) isNil() bool { return x == nil } func (x *LoggingMessageParams) GetProgressToken() any { return getProgressToken(x) } func (x *LoggingMessageParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -958,6 +973,7 @@ type PingParams struct { } func (x *PingParams) isParams() {} +func (x *PingParams) isNil() bool { return x == nil } func (x *PingParams) GetProgressToken() any { return getProgressToken(x) } func (x *PingParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -978,7 +994,8 @@ type ProgressNotificationParams struct { Total float64 `json:"total,omitempty"` } -func (*ProgressNotificationParams) isParams() {} +func (x *ProgressNotificationParams) isParams() {} +func (x *ProgressNotificationParams) isNil() bool { return x == nil } // IconTheme specifies the theme an icon is designed for. type IconTheme string @@ -1048,6 +1065,7 @@ type PromptListChangedParams struct { } func (x *PromptListChangedParams) isParams() {} +func (x *PromptListChangedParams) isNil() bool { return x == nil } func (x *PromptListChangedParams) GetProgressToken() any { return getProgressToken(x) } func (x *PromptListChangedParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -1089,6 +1107,7 @@ type ReadResourceParams struct { } func (x *ReadResourceParams) isParams() {} +func (x *ReadResourceParams) isNil() bool { return x == nil } func (x *ReadResourceParams) GetProgressToken() any { return getProgressToken(x) } func (x *ReadResourceParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -1145,6 +1164,7 @@ type ResourceListChangedParams struct { } func (x *ResourceListChangedParams) isParams() {} +func (x *ResourceListChangedParams) isNil() bool { return x == nil } func (x *ResourceListChangedParams) GetProgressToken() any { return getProgressToken(x) } func (x *ResourceListChangedParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -1205,6 +1225,7 @@ type RootsListChangedParams struct { } func (x *RootsListChangedParams) isParams() {} +func (x *RootsListChangedParams) isNil() bool { return x == nil } func (x *RootsListChangedParams) GetProgressToken() any { return getProgressToken(x) } func (x *RootsListChangedParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -1288,6 +1309,7 @@ type SetLoggingLevelParams struct { } func (x *SetLoggingLevelParams) isParams() {} +func (x *SetLoggingLevelParams) isNil() bool { return x == nil } func (x *SetLoggingLevelParams) GetProgressToken() any { return getProgressToken(x) } func (x *SetLoggingLevelParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -1416,6 +1438,7 @@ type ToolListChangedParams struct { } func (x *ToolListChangedParams) isParams() {} +func (x *ToolListChangedParams) isNil() bool { return x == nil } func (x *ToolListChangedParams) GetProgressToken() any { return getProgressToken(x) } func (x *ToolListChangedParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -1429,7 +1452,8 @@ type SubscribeParams struct { URI string `json:"uri"` } -func (*SubscribeParams) isParams() {} +func (x *SubscribeParams) isParams() {} +func (x *SubscribeParams) isNil() bool { return x == nil } // Sent from the client to request cancellation of resources/updated // notifications from the server. This should follow a previous @@ -1442,7 +1466,8 @@ type UnsubscribeParams struct { URI string `json:"uri"` } -func (*UnsubscribeParams) isParams() {} +func (x *UnsubscribeParams) isParams() {} +func (x *UnsubscribeParams) isNil() bool { return x == nil } // A notification from the server to the client, informing it that a resource // has changed and may need to be read again. This should only be sent if the @@ -1455,7 +1480,8 @@ type ResourceUpdatedNotificationParams struct { URI string `json:"uri"` } -func (*ResourceUpdatedNotificationParams) isParams() {} +func (x *ResourceUpdatedNotificationParams) isParams() {} +func (x *ResourceUpdatedNotificationParams) isNil() bool { return x == nil } // TODO(jba): add CompleteRequest and related types. @@ -1494,7 +1520,8 @@ type ElicitParams struct { ElicitationID string `json:"elicitationId,omitempty"` } -func (x *ElicitParams) isParams() {} +func (x *ElicitParams) isParams() {} +func (x *ElicitParams) isNil() bool { return x == nil } func (x *ElicitParams) GetProgressToken() any { return getProgressToken(x) } func (x *ElicitParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -1526,7 +1553,8 @@ type ElicitationCompleteParams struct { ElicitationID string `json:"elicitationId"` } -func (*ElicitationCompleteParams) isParams() {} +func (x *ElicitationCompleteParams) isParams() {} +func (x *ElicitationCompleteParams) isNil() bool { return x == nil } // An Implementation describes the name and version of an MCP implementation, with an optional // title for UI representation. @@ -1656,3 +1684,17 @@ const ( notificationToolListChanged = "notifications/tools/list_changed" methodUnsubscribe = "resources/unsubscribe" ) + +// Per-request _meta field names for the >= 2026-06-30 protocol version. +// +// These keys appear inside a Params._meta map and carry information that +// previously came from the initialization handshake (SEP-2575). +const ( + // MetaKeyProtocolVersion identifies the MCP protocol version that the + // request follows. + MetaKeyProtocolVersion = "io.modelcontextprotocol/protocolVersion" + // MetaKeyClientInfo carries the client's [Implementation]. + MetaKeyClientInfo = "io.modelcontextprotocol/clientInfo" + // MetaKeyClientCapabilities carries the client's [ClientCapabilities]. + MetaKeyClientCapabilities = "io.modelcontextprotocol/clientCapabilities" +) diff --git a/mcp/server.go b/mcp/server.go index 7526ea7..8d24147 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1450,13 +1450,32 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, initialized := ss.state.InitializeParams != nil ss.mu.Unlock() - // From the spec: - // "The client SHOULD NOT send requests other than pings before the server - // has responded to the initialize request." + // Per-request protocol detection (SEP-2575): if the request carries + // `io.modelcontextprotocol/protocolVersion` in its `_meta` field, it + // follows the new sessionless protocol. The initialization gate is + // skipped for such requests. + validatedMeta, perRequestErr := validateRequestMeta(req) + if perRequestErr != nil { + return nil, perRequestErr + } + + if !initialized && validatedMeta.usesNewProtocol && validatedMeta.initializeParams != nil { + ss.updateState(func(state *ServerSessionState) { + state.InitializeParams = validatedMeta.initializeParams + }) + } + switch req.Method { case methodInitialize, methodPing, notificationInitialized: + if validatedMeta.usesNewProtocol { + ss.server.opts.Logger.Error("method removed in the new protocol", "method", req.Method) + return nil, &jsonrpc.Error{ + Code: jsonrpc.CodeMethodNotFound, + Message: fmt.Sprintf("%q is not supported in the new protocol", req.Method), + } + } default: - if !initialized { + if !initialized && !validatedMeta.usesNewProtocol { 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) } diff --git a/mcp/server_test.go b/mcp/server_test.go index dcc7fb0..7cc780a 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -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) + } + }) + } +} diff --git a/mcp/shared.go b/mcp/shared.go index 078b401..1caacac 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -465,6 +465,70 @@ func setProgressToken(p Params, pt any) { m[progressTokenKey] = pt } +// extractRequestMeta performs a lightweight partial unmarshal of the `_meta` +// field from a JSON-RPC request's raw params. +func extractRequestMeta(rawParams json.RawMessage) Meta { + if len(rawParams) == 0 { + return nil + } + var meta struct { + Meta Meta `json:"_meta"` + } + if err := internaljson.Unmarshal(rawParams, &meta); err != nil { + return nil + } + return meta.Meta +} + +type validatedMeta struct { + usesNewProtocol bool + initializeParams *InitializeParams +} + +// validateRequestMeta inspects a JSON-RPC request to detect whether it follows +// the >= 2026-06-30 protocol via the `_meta` field. +// If the request has no _meta, or no protocolVersion in _meta, it returns a non-nil +// validatedMeta with usesNewProtocol set to false, and a nil error. +// If the request has a protocolVersion in _meta: +// - For notifications, it returns usesNewProtocol set to true and a nil initializeParams. +// - For call requests, it validates the presence of clientInfo and clientCapabilities in _meta. +// If either is missing or invalid, it returns nil and a non-nil error. Otherwise, it returns +// usesNewProtocol set to true and the populated initializeParams. +func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) { + meta := extractRequestMeta(req.Params) + if meta == nil { + return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil + } + protocolVersion, ok := meta[MetaKeyProtocolVersion].(string) + if !ok { + return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil + } + // Notifications do not carry full client identity. In new protocol, only cancel notification + // is allowed in STDIO. + if !req.IsCall() { + return &validatedMeta{usesNewProtocol: true, initializeParams: nil}, nil + } + clientInfo, ok := decodeMetaValue[*Implementation](meta, MetaKeyClientInfo) + if !ok { + return nil, &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidParams, + Message: fmt.Sprintf("missing or invalid _meta field %q", MetaKeyClientInfo), + } + } + capabilities, ok := decodeMetaValue[*ClientCapabilities](meta, MetaKeyClientCapabilities) + if !ok { + return nil, &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidParams, + Message: fmt.Sprintf("missing or invalid _meta field %q", MetaKeyClientCapabilities), + } + } + return &validatedMeta{usesNewProtocol: true, initializeParams: &InitializeParams{ + ProtocolVersion: protocolVersion, + Capabilities: capabilities, + ClientInfo: clientInfo, + }}, nil +} + // A Request is a method request with parameters and additional information, such as the session. // Request is implemented by [*ClientRequest] and [*ServerRequest]. type Request interface { @@ -525,6 +589,94 @@ func (r *ServerRequest[P]) GetParams() Params { return r.Params } func (r *ClientRequest[P]) GetExtra() *RequestExtra { return nil } func (r *ServerRequest[P]) GetExtra() *RequestExtra { return r.Extra } +// ProtocolVersion returns the protocol version negotiated for this request. +// +// For requests following the >= 2026-06-30 protocol, the value is read from +// the per-request `_meta` field. For older protocol requests, the value falls +// back to the session-level [InitializeParams] established during the +// initialize handshake. +func (r *ServerRequest[P]) ProtocolVersion() string { + if m := getRequestMeta(r); m != nil { + if v, ok := m[MetaKeyProtocolVersion].(string); ok { + return v + } + } + if r.Session != nil { + if p := r.Session.InitializeParams(); p != nil { + return p.ProtocolVersion + } + } + return "" +} + +// ClientInfo returns the [Implementation] identifying the calling client. +// +// For requests following the >= 2026-06-30 protocol, the value is read from +// the per-request `_meta` field. For older protocol requests, the value falls +// back to the session-level [InitializeParams]. +func (r *ServerRequest[P]) ClientInfo() *Implementation { + if m := getRequestMeta(r); m != nil { + if v, ok := decodeMetaValue[*Implementation](m, MetaKeyClientInfo); ok { + return v + } + } + if r.Session != nil { + if p := r.Session.InitializeParams(); p != nil { + return p.ClientInfo + } + } + return nil +} + +// ClientCapabilities returns the [ClientCapabilities] of the calling client. +// +// For requests following the >= 2026-06-30 protocol, the value is read from +// the per-request `_meta` field. For older protocol requests, the value falls +// back to the session-level [InitializeParams]. +func (r *ServerRequest[P]) ClientCapabilities() *ClientCapabilities { + if m := getRequestMeta(r); m != nil { + if v, ok := decodeMetaValue[*ClientCapabilities](m, MetaKeyClientCapabilities); ok { + return v + } + } + if r.Session != nil { + if p := r.Session.InitializeParams(); p != nil { + return p.Capabilities + } + } + return nil +} + +// getRequestMeta returns the raw `_meta` map from the request's params, or +// nil if the params are absent. +func getRequestMeta[P Params](r *ServerRequest[P]) map[string]any { + // In practice P is a pointer type implementing Params. + if any(r.Params) == nil || r.Params.isNil() { + return nil + } + return r.Params.GetMeta() +} + +// decodeMetaValue decodes a typed value out of a `_meta` map. Values may +// arrive either as the typed Go value (when constructed in-process) or as +// the generic JSON map produced by encoding/json after wire transit. In the +// latter case, the value is re-encoded and decoded into the target type. +func decodeMetaValue[T any](m map[string]any, key string) (T, bool) { + var zero T + raw, ok := m[key] + if !ok || raw == nil { + return zero, false + } + if v, ok := raw.(T); ok { + return v, true + } + var v T + if err := remarshal(raw, &v); err != nil { + return zero, false + } + return v, true +} + func serverRequestFor[P Params](s *ServerSession, p P) *ServerRequest[P] { return &ServerRequest[P]{Session: s, Params: p} } @@ -542,6 +694,9 @@ type Params interface { // isParams discourages implementation of Params outside of this package. isParams() + + // isNil returns true if the underlying value is nil. + isNil() bool } // RequestParams is a parameter (input) type for an MCP request. diff --git a/mcp/shared_test.go b/mcp/shared_test.go index 23818f8..e4f563d 100644 --- a/mcp/shared_test.go +++ b/mcp/shared_test.go @@ -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, ¶ms); err != nil { + t.Fatal(err) + } + req := &ServerRequest[*CallToolParamsRaw]{Params: ¶ms} + 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. diff --git a/mcp/streamable.go b/mcp/streamable.go index d3f3f4f..0f4e65b 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -343,8 +343,14 @@ func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http. return } + connectOpts, usesNewProtocol, err := h.ephemeralConnectOpts(req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + var sessionID string - if legacySessions { + if legacySessions && !usesNewProtocol { sessionID = req.Header.Get(sessionIDHeader) if sessionID == "" { sessionID = server.opts.GetSessionID() @@ -359,11 +365,6 @@ func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http. logger: h.opts.Logger, } - connectOpts, err := h.ephemeralConnectOpts(req) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } session, err := connectStreamable(req.Context(), server, transport, connectOpts) if err != nil { h.opts.Logger.Error(fmt.Sprintf("failed to connect: %v", err)) @@ -389,10 +390,17 @@ func (h *StreamableHTTPHandler) serveStatelessLegacyDELETE(w http.ResponseWriter } // ephemeralConnectOpts peeks at the request body to determine whether it -// contains an initialize or initialized message. If not, default session state -// is constructed so that the session doesn't reject the request. +// contains an initialize or initialized message or whether the protocol version +// header indicates a protocol version >= 2026-06-30 (SEP-2575). +// +// For old-protocol requests, default session state is synthesized so that +// the session's init gate doesn't reject the request. +// // It is used for both stateless servers and stateful servers with no session ID. -func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (*ServerSessionOptions, error) { +// +// The returned usesNewProtocol bool reports whether the protocol version +// header indicates a protocol version >= 2026-06-30 (SEP-2575). +func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (opts *ServerSessionOptions, usesNewProtocol bool, err error) { protocolVersion := protocolVersionFromContext(req.Context()) if protocolVersion == "" { protocolVersion = protocolVersion20250326 @@ -401,7 +409,7 @@ func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (*Server var hasInitialize, hasInitialized bool body, err := io.ReadAll(req.Body) if err != nil { - return nil, fmt.Errorf("failed to read body") + return nil, false, fmt.Errorf("failed to read body") } req.Body.Close() req.Body = io.NopCloser(bytes.NewBuffer(body)) @@ -415,23 +423,28 @@ func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (*Server case notificationInitialized: hasInitialized = true } + if protocolVersion >= protocolVersion20260630 { + usesNewProtocol = true + } } } } state := new(ServerSessionState) - if !hasInitialize { + // Only synthesize fake InitializeParams/InitializedParams for old-protocol + // requests. + if !hasInitialize && !usesNewProtocol { state.InitializeParams = &InitializeParams{ ProtocolVersion: protocolVersion, } } - if !hasInitialized { + if !hasInitialized && !usesNewProtocol { state.InitializedParams = new(InitializedParams) } state.LogLevel = "info" return &ServerSessionOptions{ State: state, - }, nil + }, usesNewProtocol, nil } func connectStreamable(ctx context.Context, server *Server, transport *StreamableServerTransport, opts *ServerSessionOptions) (*ServerSession, error) { @@ -576,7 +589,7 @@ func (h *StreamableHTTPHandler) serveStatefulPOST(w http.ResponseWriter, req *ht // that arrives before a session exists (e.g. initialize or ping) on a // server configured this way. if sessionID == "" { - connectOpts, err := h.ephemeralConnectOpts(req) + connectOpts, _, err := h.ephemeralConnectOpts(req) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -1279,6 +1292,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques tokenInfo := auth.TokenInfoFromContext(req.Context()) isInitialize := false var initializeProtocolVersion string + headerVersion := protocolVersionFromContext(req.Context()) for _, msg := range incoming { if jreq, ok := msg.(*jsonrpc.Request); ok { // Preemptively check that this is a valid request, so that we can fail @@ -1296,6 +1310,41 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques initializeProtocolVersion = params.ProtocolVersion } } + // SEP-2575: requests carrying `_meta.protocolVersion` require the + // Mcp-Protocol-Version HTTP header to be present and to match the + // per-request `_meta.protocolVersion` value. + // The new (>= 2026-06-30) protocol is supported on the HTTP transport + // only when [StreamableHTTPOptions.Stateless] is true. + // + // TODO: this validation can be moved within validateMcpHeaders. + var metaVersion string + if meta := extractRequestMeta(jreq.Params); meta != nil { + metaVersion, _ = meta[MetaKeyProtocolVersion].(string) + } + if protocolVersion >= protocolVersion20260630 || metaVersion != "" { + if !c.stateless { + http.Error(w, fmt.Sprintf( + "Bad Request: protocol version %q is only supported on stateless HTTP servers (set StreamableHTTPOptions.Stateless = true)", + protocolVersion), + http.StatusBadRequest) + return + } + if headerVersion == "" { + http.Error(w, fmt.Sprintf( + "Bad Request: %s header is required for requests carrying %q", + protocolVersionHeader, MetaKeyProtocolVersion), + http.StatusBadRequest) + return + } + if headerVersion != metaVersion { + http.Error(w, fmt.Sprintf( + "Bad Request: %s header %q does not match request %s %q", + protocolVersionHeader, headerVersion, + MetaKeyProtocolVersion, metaVersion), + http.StatusBadRequest) + return + } + } // Include metadata for all requests (including notifications). jreq.Extra = &RequestExtra{ TokenInfo: tokenInfo, diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index d2e5422..e566bf2 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1926,39 +1926,18 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { return &CallToolResult{}, nil }) - handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{ + Stateless: true, + }) defer handler.closeAll() - initReq := req(1, methodInitialize, &InitializeParams{ProtocolVersion: minVersionForStandardHeaders}) - initResp := resp(1, &InitializeResult{ - Capabilities: &ServerCapabilities{ - Logging: &LoggingCapabilities{}, - Tools: &ToolCapabilities{ListChanged: true}, - }, - ProtocolVersion: minVersionForStandardHeaders, - ServerInfo: &Implementation{Name: "testServer", Version: "v1.0.0"}, - }, nil) - - initialize := streamableRequest{ - method: "POST", - messages: []jsonrpc.Message{initReq}, - wantStatusCode: http.StatusOK, - wantMessages: []jsonrpc.Message{initResp}, - wantSessionID: true, - } - initialized := streamableRequest{ - method: "POST", - headers: http.Header{ - protocolVersionHeader: {minVersionForStandardHeaders}, - methodHeader: {notificationInitialized}, - }, - messages: []jsonrpc.Message{req(0, notificationInitialized, &InitializedParams{})}, - wantStatusCode: http.StatusAccepted, + testMeta := Meta{ + MetaKeyProtocolVersion: minVersionForStandardHeaders, + MetaKeyClientInfo: map[string]any{"name": "testClient", "version": "v1.0.0"}, + MetaKeyClientCapabilities: map[string]any{}, } testStreamableHandler(t, handler, []streamableRequest{ - initialize, - initialized, { method: "POST", headers: http.Header{ @@ -1966,7 +1945,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { methodHeader: {"tools/call"}, nameHeader: {"my-tool"}, }, - messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Name: "my-tool"})}, + messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)}, }, @@ -1977,7 +1956,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { methodHeader: {"prompts/get"}, nameHeader: {"my-tool"}, }, - messages: []jsonrpc.Message{req(3, "tools/call", &CallToolParams{Name: "my-tool"})}, + messages: []jsonrpc.Message{req(3, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})}, wantStatusCode: http.StatusBadRequest, wantBodyContaining: "Mcp-Method header value", }, @@ -1988,7 +1967,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { methodHeader: {"tools/call"}, nameHeader: {"wrong-tool"}, }, - messages: []jsonrpc.Message{req(4, "tools/call", &CallToolParams{Name: "my-tool"})}, + messages: []jsonrpc.Message{req(4, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})}, wantStatusCode: http.StatusBadRequest, wantBodyContaining: "Mcp-Name header value", }, @@ -1999,7 +1978,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { methodHeader: {"TOOLS/CALL"}, nameHeader: {"my-tool"}, }, - messages: []jsonrpc.Message{req(5, "tools/call", &CallToolParams{Name: "my-tool"})}, + messages: []jsonrpc.Message{req(5, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})}, wantStatusCode: http.StatusBadRequest, wantBodyContaining: "Mcp-Method header value", }, @@ -2010,7 +1989,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { methodHeader: {"tools/call"}, nameHeader: {"my-tool"}, }, - messages: []jsonrpc.Message{req(6, "tools/call", &CallToolParams{Name: "my-tool"})}, + messages: []jsonrpc.Message{req(6, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})}, wantStatusCode: http.StatusOK, wantMessages: []jsonrpc.Message{resp(6, &CallToolResult{Content: []Content{}}, nil)}, }, @@ -2023,6 +2002,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { paramHeaderPrefix + "Region": {"us-west1"}, }, messages: []jsonrpc.Message{req(7, "tools/call", &CallToolParams{ + Meta: testMeta, Name: "execute_sql", Arguments: map[string]any{"region": "us-west1", "query": "SELECT 1"}, })}, @@ -2038,6 +2018,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { paramHeaderPrefix + "Region": {"eu-central1"}, }, messages: []jsonrpc.Message{req(8, "tools/call", &CallToolParams{ + Meta: testMeta, Name: "execute_sql", Arguments: map[string]any{"region": "us-west1"}, })}, @@ -2052,6 +2033,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { nameHeader: {"execute_sql"}, }, messages: []jsonrpc.Message{req(9, "tools/call", &CallToolParams{ + Meta: testMeta, Name: "execute_sql", Arguments: map[string]any{"region": "us-west1"}, })}, @@ -2061,6 +2043,68 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { }) } +// TODO: Remove this once client operations will automatically inject metadata in the requests +func injectMetaToRequest(req *http.Request) error { + if req.Body == nil { + return nil + } + body, err := io.ReadAll(req.Body) + if err != nil { + return err + } + req.Body.Close() + + var val any + if err := json.Unmarshal(body, &val); err == nil { + var method string + if m, ok := val.(map[string]any); ok { + method, _ = m["method"].(string) + } else if list, ok := val.([]any); ok && len(list) > 0 { + if m, ok := list[0].(map[string]any); ok { + method, _ = m["method"].(string) + } + } + + if method == "initialize" || method == "notifications/initialized" || strings.HasPrefix(method, "notifications/") { + req.Header.Set(protocolVersionHeader, "2025-11-25") + } else { + req.Header.Set(protocolVersionHeader, minVersionForStandardHeaders) + + var msgs []map[string]any + if m, ok := val.(map[string]any); ok { + msgs = []map[string]any{m} + } else if list, ok := val.([]any); ok { + for _, item := range list { + if m, ok := item.(map[string]any); ok { + msgs = append(msgs, m) + } + } + } + + for _, m := range msgs { + params, _ := m["params"].(map[string]any) + if params == nil { + params = make(map[string]any) + m["params"] = params + } + meta, _ := params["_meta"].(map[string]any) + if meta == nil { + meta = make(map[string]any) + params["_meta"] = meta + } + meta[MetaKeyProtocolVersion] = minVersionForStandardHeaders + meta[MetaKeyClientInfo] = map[string]any{"name": "testClient", "version": "v1.0.0"} + meta[MetaKeyClientCapabilities] = map[string]any{} + } + body, _ = json.Marshal(val) + } + } + + req.Body = io.NopCloser(bytes.NewReader(body)) + req.ContentLength = int64(len(body)) + return nil +} + // TestStreamableMcpHeaderValidationErrorFormat verifies that header // validation errors return a JSON-RPC error with code -32001 and // Content-Type application/json, per SEP-2243. @@ -2076,7 +2120,9 @@ func TestStreamableMcpHeaderValidationErrorFormat(t *testing.T) { return &CallToolResult{}, nil }) - handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{ + Stateless: true, + }) defer handler.closeAll() httpServer := httptest.NewServer(mustNotPanic(t, handler)) @@ -2088,6 +2134,9 @@ func TestStreamableMcpHeaderValidationErrorFormat(t *testing.T) { customClient := &http.Client{ Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + if err := injectMetaToRequest(req); err != nil { + return nil, err + } var originalMethodHeader string if req.Header.Get(methodHeader) == "tools/call" { originalMethodHeader = req.Header.Get(methodHeader) @@ -2237,7 +2286,9 @@ func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) { return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil }) - handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{ + Stateless: true, + }) defer handler.closeAll() httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() @@ -2245,6 +2296,9 @@ func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) { var capturedHeaders http.Header customClient := &http.Client{ Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + if err := injectMetaToRequest(req); err != nil { + return nil, err + } if req.Header.Get(methodHeader) == "tools/call" { capturedHeaders = req.Header.Clone() } @@ -2347,14 +2401,28 @@ func TestStreamableFilterValidToolsIntegration(t *testing.T) { InputSchema: &jsonschema.Schema{Type: "object"}, }, noop) - handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, nil) + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{ + Stateless: true, + }) defer handler.closeAll() httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() + customClient := &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + if err := injectMetaToRequest(req); err != nil { + return nil, err + } + return http.DefaultTransport.RoundTrip(req) + }), + } + client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil) ctx := context.Background() - session, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, &ClientSessionOptions{protocolVersion: minVersionForStandardHeaders}) + session, err := client.Connect(ctx, &StreamableClientTransport{ + Endpoint: httpServer.URL, + HTTPClient: customClient, + }, &ClientSessionOptions{protocolVersion: minVersionForStandardHeaders}) if err != nil { t.Fatal(err) } @@ -3207,3 +3275,314 @@ func TestStandaloneSSEEmitsCommentForHTTP2Flush(t *testing.T) { t.Fatal("timed out waiting for first SSE bytes; the standalone SSE stream must emit a DATA frame immediately so HTTP/2 reverse proxies don't buffer the HEADERS frame") } } + +// newProtocolBody builds a raw JSON body for a tools/call request that +// carries the >= 2026-06-30 per-request _meta fields. +func newProtocolBody(t *testing.T, toolName string, args any) []byte { + t.Helper() + rawArgs, err := json.Marshal(args) + if err != nil { + t.Fatal(err) + } + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260630, + MetaKeyClientInfo: map[string]any{"name": "new-proto-client", "version": "9.9"}, + MetaKeyClientCapabilities: map[string]any{"sampling": map[string]any{}}, + }, + "name": toolName, + "arguments": json.RawMessage(rawArgs), + }, + }) + if err != nil { + t.Fatal(err) + } + return body +} + +func TestEphemeralConnectOpts(t *testing.T) { + mkReq := func(body []byte) *http.Request { + r := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + return r + } + + h := &StreamableHTTPHandler{opts: StreamableHTTPOptions{}} + + oldProtocolBody, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": map[string]any{"name": "x", "arguments": map[string]any{}}, + }) + if err != nil { + t.Fatal(err) + } + initializeBody, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": methodInitialize, + "params": map[string]any{"protocolVersion": protocolVersion20250618}, + }) + if err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + body []byte + wantUsesNew bool + wantInitializeParams bool + wantInitializedParams bool + }{ + { + name: "new-protocol request: no synthetic state", + body: newProtocolBody(t, "x", struct{}{}), + wantUsesNew: true, + wantInitializeParams: false, + wantInitializedParams: false, + }, + { + name: "old-protocol request: synthetic state populated", + body: oldProtocolBody, + wantUsesNew: false, + wantInitializeParams: true, + wantInitializedParams: true, + }, + { + name: "initialize request: no synthetic InitializeParams", + body: initializeBody, + wantUsesNew: false, + wantInitializeParams: false, + wantInitializedParams: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := mkReq(tt.body) + var pver string + if tt.wantUsesNew { + pver = protocolVersion20260630 + } else { + pver = protocolVersion20250326 + } + req.Header.Set(protocolVersionHeader, pver) + req = req.WithContext(context.WithValue(req.Context(), protocolVersionContextKey{}, pver)) + opts, usesNew, err := h.ephemeralConnectOpts(req) + if err != nil { + t.Fatal(err) + } + if usesNew != tt.wantUsesNew { + t.Errorf("usesNewProtocol = %v, want %v", usesNew, tt.wantUsesNew) + } + if got := opts.State.InitializeParams != nil; got != tt.wantInitializeParams { + t.Errorf("InitializeParams non-nil = %v, want %v (value = %+v)", + got, tt.wantInitializeParams, opts.State.InitializeParams) + } + if got := opts.State.InitializedParams != nil; got != tt.wantInitializedParams { + t.Errorf("InitializedParams non-nil = %v, want %v (value = %+v)", + got, tt.wantInitializedParams, opts.State.InitializedParams) + } + }) + } +} + +// statelessHandlerCapture builds a stateless server with a single tool whose +// handler captures everything we want to assert about the per-request view of +// the session and the new-protocol accessors. +type statelessHandlerCapture struct { + mu sync.Mutex + sessionInitParams *InitializeParams + reqProtocolVersion string + reqClientInfo *Implementation + reqClientCapabilities *ClientCapabilities +} + +func TestStreamableStateless_NewProtocolSession_NoFakeInit(t *testing.T) { + // SEP-2575: the MCP-Protocol-Version header is mandatory for new-protocol + // requests and must be a supported version. The 2026-06-30 version is + // not yet in the global list, so register it for the duration of the test. + orig := supportedProtocolVersions + supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + capture := &statelessHandlerCapture{} + mcpServer := NewServer(testImpl, nil) + AddTool(mcpServer, &Tool{Name: "capture", Description: "captures request info"}, + func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { + capture.mu.Lock() + defer capture.mu.Unlock() + capture.sessionInitParams = req.Session.InitializeParams() + capture.reqProtocolVersion = req.ProtocolVersion() + capture.reqClientInfo = req.ClientInfo() + capture.reqClientCapabilities = req.ClientCapabilities() + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + }) + + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return mcpServer }, + &StreamableHTTPOptions{Stateless: true}, + ) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + body := newProtocolBody(t, "capture", struct{}{}) + httpReq, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json, text/event-stream") + httpReq.Header.Set(protocolVersionHeader, protocolVersion20260630) + // >= 2026-06-30 also requires the Mcp-Method and Mcp-Name standard + // headers (see streamable_headers.go). + httpReq.Header.Set(methodHeader, "tools/call") + httpReq.Header.Set(nameHeader, "capture") + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody) + } + + capture.mu.Lock() + defer capture.mu.Unlock() + if capture.sessionInitParams == nil { + t.Errorf("Session.InitializeParams() is nil, want populated initializeParams for new-protocol session") + } else { + if got, want := capture.sessionInitParams.ProtocolVersion, protocolVersion20260630; got != want { + t.Errorf("Session.InitializeParams().ProtocolVersion = %q, want %q", got, want) + } + if got, want := capture.sessionInitParams.ClientInfo.Name, "new-proto-client"; got != want { + t.Errorf("Session.InitializeParams().ClientInfo.Name = %q, want %q", got, want) + } + } + if got, want := capture.reqProtocolVersion, protocolVersion20260630; got != want { + t.Errorf("req.ProtocolVersion() = %q, want %q", got, want) + } + if capture.reqClientInfo == nil || capture.reqClientInfo.Name != "new-proto-client" { + t.Errorf("req.ClientInfo() = %+v, want Name=new-proto-client", capture.reqClientInfo) + } + if capture.reqClientCapabilities == nil || capture.reqClientCapabilities.Sampling == nil { + t.Errorf("req.ClientCapabilities() = %+v, want non-nil Sampling", capture.reqClientCapabilities) + } +} + +// TestStreamableStateful_RejectsNewProtocol verifies that a stateful HTTP +// server rejects requests carrying _meta.protocolVersion (i.e. >= 2026-06-30 +// requests) with HTTP 400. The new protocol is +// supported on HTTP only when StreamableHTTPOptions.Stateless=true. +func TestStreamableStateful_RejectsNewProtocol(t *testing.T) { + // Make 2026-06-30 a "known" version so that the request reaches servePOST + // (otherwise the early header validation at ServeHTTP rejects it). + orig := supportedProtocolVersions + supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + 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 }, nil) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + // Initialize a legacy session first. + initBody := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`) + initReq, err := http.NewRequest(http.MethodPost, httpServer.URL, initBody) + if err != nil { + t.Fatal(err) + } + initReq.Header.Set("Content-Type", "application/json") + initReq.Header.Set("Accept", "application/json, text/event-stream") + initResp, err := http.DefaultClient.Do(initReq) + if err != nil { + t.Fatal(err) + } + io.Copy(io.Discard, initResp.Body) + initResp.Body.Close() + sessionID := initResp.Header.Get(sessionIDHeader) + if sessionID == "" { + t.Fatalf("initialize response missing %s header", sessionIDHeader) + } + + // Drive the existing session with a new-protocol request whose header and + // body agree. The cross-check passes; the stateful-rejection check fires. + body := newProtocolBody(t, "noop", struct{}{}) + 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(sessionIDHeader, sessionID) + req.Header.Set(protocolVersionHeader, protocolVersion20260630) + req.Header.Set(methodHeader, "tools/call") + req.Header.Set(nameHeader, "noop") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body = %s", resp.StatusCode, respBody) + } + if !strings.Contains(string(respBody), "stateless") { + t.Errorf("body = %q, want a message mentioning 'stateless'", respBody) + } +} + +// TestStreamableStateless_AcceptsNewProtocol is the positive control: +// confirms that a stateless server still accepts new-protocol requests +// (the rejection in TestStreamableStateful_RejectsNewProtocol must not +// fire on Stateless: true). +func TestStreamableStateless_AcceptsNewProtocol(t *testing.T) { + orig := supportedProtocolVersions + supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + 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() + + body := newProtocolBody(t, "noop", struct{}{}) + 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, protocolVersion20260630) + req.Header.Set(methodHeader, "tools/call") + req.Header.Set(nameHeader, "noop") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody) + } +} From 2d47cc96646020b446391d0b039e1ea5ec06414f Mon Sep 17 00:00:00 2001 From: Max Gerber <89937743+max-stytch@users.noreply.github.com> Date: Wed, 27 May 2026 02:39:08 -0700 Subject: [PATCH 286/418] auth: issuer mix-up mitigation (#859) This PR functions as a Reference implementation of [SEP-2468](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468) / [RFC9207](https://datatracker.ietf.org/doc/rfc9207/). This PR hardens the MCP OAuth Client functionality against Mix-Up attacks: > Mix-up attacks aim to steal an authorization code or access token by > tricking the client into sending the authorization code or access > token to the attacker instead of the honest authorization or resource > server This PR hardens the client by adding support for a new `iss` parameter in authorization responses: - Authorization Servers broadcast support for the `iss` parameter via the `authorization_response_iss_parameter_supported` metadata parameter - If the parameter is supported, clients expect to receive the `iss` parameter in the authorization response - Clients compare the `iss` parameter in the authorization response to the `Issuer` parameter in the authorization metadata. The two must match exactly for the response to be processed. Fixes #941 --- auth/authorization_code.go | 30 ++++++++++ auth/authorization_code_test.go | 55 +++++++++++++++++++ conformance/everything-client/main.go | 6 ++ docs/protocol.md | 23 +++++++- examples/auth/client/main.go | 1 + examples/server/auth-middleware/go.mod | 6 +- examples/server/auth-middleware/go.sum | 2 + examples/server/rate-limiting/go.mod | 2 +- internal/docs/protocol.src.md | 22 +++++++- .../oauthtest/fake_authorization_server.go | 6 +- oauthex/auth_meta.go | 7 +++ 11 files changed, 151 insertions(+), 9 deletions(-) diff --git a/auth/authorization_code.go b/auth/authorization_code.go index 47541f9..144f0ad 100644 --- a/auth/authorization_code.go +++ b/auth/authorization_code.go @@ -50,6 +50,12 @@ type AuthorizationResult struct { Code string // State string returned by the authorization server. State string + // Iss is the issuer identifier returned by the authorization server in the + // authorization response per [RFC 9207]. The AuthorizationCodeFetcher should + // populate this from the "iss" query parameter in the redirect URI if present. + // + // [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207 + Iss string } // AuthorizationArgs is the input to [AuthorizationCodeFetcher]. @@ -318,6 +324,9 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ // Purposefully leaving the error unwrappable so it can be handled by the caller. return err } + if err := validateIssuerResponse(authRes.Iss, asm.Issuer, asm.AuthorizationResponseIssParameterSupported); err != nil { + return err + } err = h.exchangeAuthorizationCode(ctx, cfg, authRes, prm.Resource) if err != nil { @@ -560,6 +569,27 @@ func (h *AuthorizationCodeHandler) getAuthorizationCode(ctx context.Context, cfg }, nil } +// validateIssuerResponse validates the "iss" parameter in an authorization response +// per [RFC 9207]. +// +// [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207 +func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported bool) error { + if issParameterSupported { + if iss == "" { + return fmt.Errorf("authorization server advertises RFC 9207 iss parameter support but none was received in the authorization response") + } + if iss != expectedIssuer { + return fmt.Errorf("authorization response issuer %q does not match expected issuer %q", iss, expectedIssuer) + } + } else { + if iss != "" { + return fmt.Errorf("authorization server does not advertise RFC 9207 iss parameter support but iss was received in the authorization response") + } + } + + return nil +} + // exchangeAuthorizationCode exchanges the authorization code for a token // and stores it in a token source. func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context, cfg *oauth2.Config, authResult *authResult, resourceURL string) error { diff --git a/auth/authorization_code_test.go b/auth/authorization_code_test.go index 1252005..d4de06c 100644 --- a/auth/authorization_code_test.go +++ b/auth/authorization_code_test.go @@ -78,6 +78,7 @@ func TestAuthorize(t *testing.T) { return &AuthorizationResult{ Code: location.Query().Get("code"), State: location.Query().Get("state"), + Iss: location.Query().Get("iss"), }, nil }, }) @@ -176,6 +177,7 @@ func TestAuthorize_ScopeAccumulation(t *testing.T) { return &AuthorizationResult{ Code: loc.Query().Get("code"), State: loc.Query().Get("state"), + Iss: loc.Query().Get("iss"), }, nil }, }) @@ -736,6 +738,59 @@ func TestDynamicRegistration(t *testing.T) { } } +func TestValidateIssuerResponse(t *testing.T) { + const expectedIssuer = "https://auth.example.com" + + tests := []struct { + name string + iss string + issSupported bool + wantErr bool + wantErrContains string + }{ + { + name: "ValidIss", + iss: expectedIssuer, + issSupported: true, + }, + { + name: "WrongIss", + iss: "https://attacker.example.com", + issSupported: true, + wantErr: true, + wantErrContains: "does not match expected issuer", + }, + { + name: "MissingIssWhenRequired", + iss: "", + issSupported: true, + wantErr: true, + wantErrContains: "RFC 9207", + }, + { + name: "MissingIssWhenNotRequired", + iss: "", + issSupported: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateIssuerResponse(tt.iss, expectedIssuer, tt.issSupported) + if tt.wantErr { + if err == nil { + t.Fatalf("validateIssuerResponse() = nil, want error containing %q", tt.wantErrContains) + } + if !strings.Contains(err.Error(), tt.wantErrContains) { + t.Errorf("validateIssuerResponse() error = %q, want it to contain %q", err.Error(), tt.wantErrContains) + } + } else if err != nil { + t.Fatalf("validateIssuerResponse() unexpected error = %v", err) + } + }) + } +} + func TestInferApplicationType(t *testing.T) { tests := []struct { name string diff --git a/conformance/everything-client/main.go b/conformance/everything-client/main.go index 947b095..55a55ad 100644 --- a/conformance/everything-client/main.go +++ b/conformance/everything-client/main.go @@ -66,6 +66,11 @@ func init() { "auth/token-endpoint-auth-basic", "auth/token-endpoint-auth-post", "auth/token-endpoint-auth-none", + "auth/iss-supported", + "auth/iss-not-advertised", + "auth/iss-supported-missing", + "auth/iss-wrong-issuer", + "auth/iss-unexpected", } for _, scenario := range authScenarios { registerScenario(scenario, runAuthClient) @@ -232,6 +237,7 @@ func fetchAuthorizationCodeAndState(ctx context.Context, args *auth.Authorizatio return &auth.AuthorizationResult{ Code: locURL.Query().Get("code"), State: locURL.Query().Get("state"), + Iss: locURL.Query().Get("iss"), }, nil } diff --git a/docs/protocol.md b/docs/protocol.md index cfcdb85..f45d1df 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -15,6 +15,7 @@ 1. [Token Passthrough](#token-passthrough) 1. [Server-Side Request Forgery](#server-side-request-forgery) 1. [Session Hijacking](#session-hijacking) + 1. [Issuer Mix-Up](#issuer-mix-up) 1. [Utilities](#utilities) 1. [Cancellation](#cancellation) 1. [Ping](#ping) @@ -322,6 +323,7 @@ This handler supports: - [Client ID Metadata Documents](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#client-id-metadata-documents) - [Pre-registered clients](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration) - [Dynamic Client Registration](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#dynamic-client-registration) +- [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207) Authorization Server Issuer Identification To use it, configure the handler and assign it to the transport: @@ -333,11 +335,12 @@ authHandler, _ := auth.NewAuthorizationCodeHandler(&auth.AuthorizationCodeHandle // PreregisteredClientConfig: ... // DynamicClientRegistrationConfig: ... AuthorizationCodeFetcher: func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { - // Open the args.URL in a browser and return the resulting code and state. + // Open the args.URL in a browser and return the resulting code, state, and iss. // See full example in examples/auth/client/main.go. code := ... state := ... - return &auth.AuthorizationResult{Code: code, State: state}, nil + iss := ... // "iss" query parameter from the redirect URI (RFC 9207) + return &auth.AuthorizationResult{Code: code, State: state, Iss: iss}, nil }, }) @@ -490,6 +493,22 @@ sets `UserID` on the returned `TokenInfo`, the streamable transport will: `TokenInfo.UserID` to enable this protection. This prevents an attacker with a valid token from hijacking another user's session by guessing or obtaining their session ID. +### Issuer Mix-Up + +The [mitigation](https://www.rfc-editor.org/rfc/rfc9207) against issuer mix-up attacks is +implemented per [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207). The SDK client validates +the `iss` parameter in authorization responses to ensure they originated from the expected +authorization server: + +- If `iss` is present in the redirect URI, the SDK verifies it matches the issuer from the + authorization server's metadata. A mismatch results in an error. +- If `iss` is absent but the authorization server advertises + `authorization_response_iss_parameter_supported: true` in its [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414) + metadata, the SDK rejects the response with an error. + +The `AuthorizationCodeFetcher` is responsible for extracting the `iss` query parameter from +the redirect URI and returning it in [`AuthorizationResult.Iss`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#AuthorizationResult). + ## Utilities ### Cancellation diff --git a/examples/auth/client/main.go b/examples/auth/client/main.go index f514eba..7e5d464 100644 --- a/examples/auth/client/main.go +++ b/examples/auth/client/main.go @@ -36,6 +36,7 @@ func (r *codeReceiver) serveRedirectHandler(listener net.Listener) { r.authChan <- &auth.AuthorizationResult{ Code: req.URL.Query().Get("code"), State: req.URL.Query().Get("state"), + Iss: req.URL.Query().Get("iss"), } fmt.Fprint(w, "Authentication successful. You can close this window.") }) diff --git a/examples/server/auth-middleware/go.mod b/examples/server/auth-middleware/go.mod index 8690256..f1bfc37 100644 --- a/examples/server/auth-middleware/go.mod +++ b/examples/server/auth-middleware/go.mod @@ -1,16 +1,16 @@ module auth-middleware-example -go 1.23.0 +go 1.25.0 require ( - github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/modelcontextprotocol/go-sdk v0.3.0 ) require ( github.com/google/jsonschema-go v0.4.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect ) replace github.com/modelcontextprotocol/go-sdk => ../../../ diff --git a/examples/server/auth-middleware/go.sum b/examples/server/auth-middleware/go.sum index d257e10..a14b134 100644 --- a/examples/server/auth-middleware/go.sum +++ b/examples/server/auth-middleware/go.sum @@ -1,5 +1,6 @@ github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= @@ -9,5 +10,6 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= diff --git a/examples/server/rate-limiting/go.mod b/examples/server/rate-limiting/go.mod index 61c8788..dc37554 100644 --- a/examples/server/rate-limiting/go.mod +++ b/examples/server/rate-limiting/go.mod @@ -1,6 +1,6 @@ module github.com/modelcontextprotocol/go-sdk/examples/rate-limiting -go 1.23.0 +go 1.25.0 require ( github.com/modelcontextprotocol/go-sdk v0.3.0 diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index 593742c..4044a6f 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -247,6 +247,7 @@ This handler supports: - [Client ID Metadata Documents](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#client-id-metadata-documents) - [Pre-registered clients](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration) - [Dynamic Client Registration](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#dynamic-client-registration) +- [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207) Authorization Server Issuer Identification To use it, configure the handler and assign it to the transport: @@ -258,11 +259,12 @@ authHandler, _ := auth.NewAuthorizationCodeHandler(&auth.AuthorizationCodeHandle // PreregisteredClientConfig: ... // DynamicClientRegistrationConfig: ... AuthorizationCodeFetcher: func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { - // Open the args.URL in a browser and return the resulting code and state. + // Open the args.URL in a browser and return the resulting code, state, and iss. // See full example in examples/auth/client/main.go. code := ... state := ... - return &auth.AuthorizationResult{Code: code, State: state}, nil + iss := ... // "iss" query parameter from the redirect URI (RFC 9207) + return &auth.AuthorizationResult{Code: code, State: state, Iss: iss}, nil }, }) @@ -415,6 +417,22 @@ sets `UserID` on the returned `TokenInfo`, the streamable transport will: `TokenInfo.UserID` to enable this protection. This prevents an attacker with a valid token from hijacking another user's session by guessing or obtaining their session ID. +### Issuer Mix-Up + +The [mitigation](https://www.rfc-editor.org/rfc/rfc9207) against issuer mix-up attacks is +implemented per [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207). The SDK client validates +the `iss` parameter in authorization responses to ensure they originated from the expected +authorization server: + +- If `iss` is present in the redirect URI, the SDK verifies it matches the issuer from the + authorization server's metadata. A mismatch results in an error. +- If `iss` is absent but the authorization server advertises + `authorization_response_iss_parameter_supported: true` in its [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414) + metadata, the SDK rejects the response with an error. + +The `AuthorizationCodeFetcher` is responsible for extracting the `iss` query parameter from +the redirect URI and returning it in [`AuthorizationResult.Iss`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth#AuthorizationResult). + ## Utilities ### Cancellation diff --git a/internal/oauthtest/fake_authorization_server.go b/internal/oauthtest/fake_authorization_server.go index b27e00e..c180f86 100644 --- a/internal/oauthtest/fake_authorization_server.go +++ b/internal/oauthtest/fake_authorization_server.go @@ -15,6 +15,7 @@ import ( "maps" "net/http" "net/http/httptest" + "net/url" "slices" "testing" @@ -178,6 +179,8 @@ func (s *FakeAuthorizationServer) handleMetadata(w http.ResponseWriter, r *http. CodeChallengeMethodsSupported: []string{"S256"}, ClientIDMetadataDocumentSupported: cimdSupported, TokenEndpointAuthMethodsSupported: []string{"client_secret_post", "client_secret_basic"}, + // Advertise RFC 9207 support: the authorize endpoint includes "iss" in responses. + AuthorizationResponseIssParameterSupported: true, } // Set CORS headers for cross-origin client discovery. w.Header().Set("Access-Control-Allow-Origin", "*") @@ -267,8 +270,9 @@ func (s *FakeAuthorizationServer) handleAuthorize(w http.ResponseWriter, r *http } state := r.URL.Query().Get("state") + issuer := s.URL() + s.config.IssuerPath - redirectURL := fmt.Sprintf("%s?code=%s&state=%s", redirectURI, code, state) + redirectURL := fmt.Sprintf("%s?code=%s&state=%s&iss=%s", redirectURI, code, state, url.QueryEscape(issuer)) http.Redirect(w, r, redirectURL, http.StatusFound) } diff --git a/oauthex/auth_meta.go b/oauthex/auth_meta.go index 255aca9..7ebf6e2 100644 --- a/oauthex/auth_meta.go +++ b/oauthex/auth_meta.go @@ -114,6 +114,13 @@ type AuthServerMeta struct { // ClientIDMetadataDocumentSupported is a boolean indicating whether the authorization server // supports client ID metadata documents. ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported,omitempty"` + + // AuthorizationResponseIssParameterSupported indicates whether the authorization server + // provides the "iss" parameter in authorization responses per [RFC 9207]. + // When true, clients must verify the "iss" parameter is present and matches the Issuer field. + // + // [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207 + AuthorizationResponseIssParameterSupported bool `json:"authorization_response_iss_parameter_supported,omitempty"` } // GetAuthServerMeta issues a GET request to retrieve authorization server metadata From 189a85ad92fff4a5c7fef29532d30fcaa2cf10d3 Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Fri, 29 May 2026 09:29:34 +0200 Subject: [PATCH 287/418] feat: multi-round-trip request implementation (SEP-2322) (#950) ### Context Design (`design/mrtr.md`) and implementation proposal for Multi Round-Trip Requests (MRTR) per [SEP-2322](https://github.com/CaitieM20/modelcontextprotocol/blob/de6d76fba3078eda957dadb3cec51ca8ab851b5c/seps/2322-MRTR.md). ### Changes - Introduce `InputRequest` and `InputResponse` sealed interfaces with corresponding `InputRequestMap` and `InputResponseMap` types for custom map JSON codec implementation. - Extend `CallToolParams`, `GetPromptParams`, and `ReadResourceParams` with `InputResponses`, `RequestState` and an unexported `resultType` ("complete" or "input_required") for retry round-trips. The type is set by the SDK based on input requests presence. - Add client-side MRTR middleware enabled by default that automatically fulfills input requests and retries. - Added MRTROptions on ClientOptions to allow disabling the middleware and configuring the number of retries. - Add server-side MRTR middleware for backward compatibility: transparently bridges input requests to direct Elicit/CreateMessage/ListRoots calls for older clients --- design/mrtr.md | 264 +++++++++++++++++++ go.mod | 1 + go.sum | 2 + mcp/client.go | 10 +- mcp/content_nil_test.go | 3 +- mcp/mcp_test.go | 5 +- mcp/mrtr.go | 268 ++++++++++++++++++++ mcp/mrtr_test.go | 546 ++++++++++++++++++++++++++++++++++++++++ mcp/protocol.go | 397 ++++++++++++++++++++++++++++- mcp/protocol_test.go | 65 +++++ mcp/server.go | 37 ++- mcp/streamable_test.go | 12 +- 12 files changed, 1587 insertions(+), 23 deletions(-) create mode 100644 design/mrtr.md create mode 100644 mcp/mrtr.go create mode 100644 mcp/mrtr_test.go diff --git a/design/mrtr.md b/design/mrtr.md new file mode 100644 index 0000000..16321a4 --- /dev/null +++ b/design/mrtr.md @@ -0,0 +1,264 @@ +## Context + +A proposal for implementing Multi Round-Trip Requests +(MRTR) as defined in [SEP-2322](https://github.com/CaitieM20/modelcontextprotocol/blob/de6d76fba3078eda957dadb3cec51ca8ab851b5c/seps/2322-MRTR.md). + +In the new protocol version servers can't initiate requests to clients, but when a server requires additional input for completing `tools/call`, `prompts/get`, or `resources/read` it can return an incomplete result along with a set of `inputRequests`. The client fulfills them locally and retries the same call with `inputResponses` attached. + +## Goals + +**Must have:** + +* Backward compatibility. +* Correct representation on the wire. + +**Nice to have:** + +* Minimal changes to the exported API surface. +* Hard for server implementers to construct an invalid payload. +* Simple input request handling for clients. +* Protocol-version-independent code. +* Consistency with the rest of the SDK. + +## Proposal + +`ServerSession` methods return an error for new-version protocol connections. + +`InputRequest`/`InputResponse` is introduced as a sealed-interface: +```go +// Implemented by *ElicitParams, *CreateMessageParams, *ListRootsParams +type InputRequest interface{ isInputRequest() } + +type InputRequestMap map[string]InputRequest +// MarshalJSON encodes as map[string]struct{ Method string; Params InputRequest } +func (m InputRequestMap) MarshalJSON() ([]byte, error) { ... } +// UnmarshalJSON decodes from map[string]struct{ Method string; Params InputRequest } +func (m *InputRequestMap) UnmarshalJSON(data []byte) error { ... } + +// Implemented by *ElicitResult, *CreateMessageResult, *ListRootsResult. +type InputResponse interface{ isInputResult() } + +type InputResponseMap map[string]InputResponse +// MarshalJSON encodes as map[string]struct{ Method string; Result InputResponse } +func (m InputResponseMap) MarshalJSON() ([]byte, error) { ... } +// UnmarshalJSON decodes from map[string]struct{ Method string; Result InputResponse } +func (m *InputResponseMap) UnmarshalJSON(data []byte) error { ... } +``` + +All affected methods' `*Params` are extended with `InputResponseMap` and `RequestState` fields: +```go +type CallToolParams struct { + ... + InputResponses InputResponseMap `json:"inputResponses,omitempty"` + RequestState string `json:"requestState,omitempty"` +} +// Same for GetPromptParams, ReadResourceParams +``` + +`InputRequests` and `RequestState` fields are added directly to `CallToolResult`, `GetPromptResult`, and `ReadResourceResult` as exported. +Result type discriminator (completed, input_required) is unexported so that SDK users don't need to set it to the correct constant in addition to setting either `Content` or `InputRequests`. Handler execution result is validated and augmented before marshaling: +```go +type CallToolResult struct { + ... + InputRequests InputRequestMap `json:"inputRequests,omitempty"` + RequestState string `json:"requestState,omitempty"` + resultType string // set by the SDK and used in MarshalJSON() +} +// Same for GetPromptResult, ReadResourceResult. +``` +Alternatively, the field could only exist on `wire struct`, but this would make us return `complete` to older clients or empty string to newer clients, because there's no access to negotiated protocol version in `MarshalJSON`. + +Servers request additional input by constructing a correct struct literal: +```go +mcp.AddTool(s, tool, func(ctx context.Context, req *mcp.CallToolRequest, in MyIn) (*mcp.CallToolResult, MyOut, error) { + if !hasConfirmation(in) { + return &mcp.CallToolResult{ + InputRequests: InputRequestMap{"confirm": &mcp.ElicitParams{Message: "Sure?"}}, + RequestState: "state-token", + }, zero, nil + } + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "done"}}}, myOut, nil +}) +``` +The SDK validates at runtime that a handler does not return both content and `InputRequests` — doing so logs a warning and returns a `CodeInternalError` JSON-RPC error. + +An unexported receiving middleware is installed on the server for backward compatibility with older clients. When a handler returns `InputRequests` and the connected client uses a protocol version that does not support MRTR, the middleware fulfills the requests by calling `ServerSession.Elicit`/`CreateMessage`/`ListRoots` on the client directly and reinvokes the handler once with the collected `InputResponses`. If any of these calls fail, the entire request fails. Input requests are fulfilled concurrently. This lets server developers write protocol-version-independent code. + +An unexported sending middleware is installed on the client, which similarly to `urlElicitationMiddleware` will automatically invoke handlers for the corresponding methods on incomplete results and retry the original request. Clients have an option to disable it and write a retry loop manually using `NeedsInput()`: +```go +type MultiRoundTripOptions struct { + Disabled bool +} + +client := mcp.NewClient(impl, &mcp.ClientOptions{MultiRoundTrip: &mcp.MultiRoundTripOptions{Disabled: true}}) +result, err := client.CallTool(ctx, &mcp.CallToolParams{Name: "my-tool"}) +if result.NeedsInput() { ... } +``` + +`NeedsInput()` checks the unexported `resultType` field rather than `InputRequests`, correctly handling the load-shedding case where the server returns `input_required` with an empty map. + +**Pros** + +This is arguably the simplest and the most transparent approach which is also closest to the spec. +What gets explicitly set on the server can be observed on the wire and on the client. +The opt-out client middleware follows the principle of the least surprise for app developers. If client method handlers were provided they will continue to be invoked regardless of the protocol version in use. The `Disabled` option lets "power-users" build any custom handling logic. +The server middleware makes handler code protocol-version-independent — the same handler works for both old and new clients. + +**Cons** + +The biggest downside of the proposal is that server developers can construct incorrect responses (both content and input requests) and this will only be validated at runtime. + +## Alternatives considered + +### Unexported fields + +MRTR fields can be unexported, accessible only through getters, constructible only through constructor functions, and handled explicitly in custom `(Unm|M)arshalJSON`. This will make it impossible for developers to construct incorrect responses and for clients to perform an erroneous `len(result.InputRequests) > 0` check in the load-shedding case. +```go +type CallToolResult struct { + ... + inputRequests InputRequestMap + requestState string + resultType string +} + +func (r *CallToolResult) InputRequests() (InputRequestMap, bool) { ... } + +// InputRequiredResult struct exists for backward-compatibility in case of new fields being needed for input request results. +type InputRequiredResult struct { + InputRequests InputRequestMap + RequestState string +} + +// RequireInput constructs a tool call, prompt or resource result with input requests set. +// mrtrResult provides methods for setting private fields on these types. +func RequireInput[T any, TP interface { *T; mrtrResult }](r InputRequiredResult) TP { ... } +``` + +On the server: +```go +mcp.AddTool(s, tool, func(ctx context.Context, req *mcp.CallToolRequest, in MyIn) (*mcp.CallToolResult, MyOut, error) { + if !hasConfirmation(in) { + return mcp.RequireInput[mcp.CallToolResult](mcp.InputRequiredResult{ + InputRequests: mcp.InputRequestMap{"confirm": &mcp.ElicitParams{Message: "Deploy to production?"}}, + RequestState: "deployment-123", + }), nil, nil + } + return &mcp.CallToolResult{ Content: []mcp.Content{&mcp.TextContent{Text: "done"}}}, myOut, nil +}) +``` + +On the client: +```go +result, err := client.CallTool(ctx, &mcp.CallToolParams{Name: "my-tool"}) +if requests, ok := result.InputRequests(); ok { ... } +``` + +The biggest downside of this approach is the obscure data model with hidden fields. An incomplete `mcp.CallToolResult` looks like an uninitialized struct until `InputRequests` method result is examined. +In addition to this, the verbose `RequireInput` syntax (no auto type inference from assignment target) does not look idiomatic and fits poorly into the existing SDK APIs. + +--- + +### `InputRequiredError` type + +We could explore a different data channel - `error` return value. This would give us the natural "happy path is when all inputs are provided" flow on the server side, and good result interpretability on the client side (impossible to confuse with a successful response). +The new error could be converted to the correct wire representation at the marshaling stage. +```go +type InputRequiredError struct { + InputRequests InputRequestMap + RequestState string +} + +func (e *InputRequiredError) Error() string { + return fmt.Sprintf("input required: %d request(s)", len(e.InputRequests)) +} +``` + +On the server: +```go +mcp.AddTool(s, tool, func(ctx context.Context, req *mcp.CallToolRequest, in MyIn) (*mcp.CallToolResult, MyOut, error) { + if !hasConfirmation(in) { + return nil, zero, &mcp.InputRequiredError{ + InputRequests: mcp.InputRequestMap{"confirm": &mcp.ElicitParams{Message: "Sure?"}}, + RequestState: "state-token", + } + } + return &mcp.CallToolResult{ Content: []mcp.Content{&mcp.TextContent{Text: "done"}}}, myOut, nil +}) +``` + +On the client: +```go +result, err := client.CallTool(ctx, &mcp.CallToolParams{Name: "my-tool"}) +var inputReqErr *mcp.InputRequiredError +if errors.As(err, &inputReqErr) { ... } +``` + +The downsides of this approach are: +* The drift from the protocol, where MRTR is not an error flow. +* Obscure "customError -> non-error protocol type on wirte -> customError" data lifecycle. +* Things get confusing for error-processing middleware. + +--- + +### New functions + +We could introduce new functions with a different handler signature where the return type is a sealed interface. This would give us compiler-enforced correctness for values constructed by tool handlers and clients would be forced to unpack `mcp.RoundTripCallToolResult` and make a concious decision for how to handle it. +```go +type RoundTripToolHandler func(context.Context, *CallToolRequest) (RoundTripCallToolResult, error) +type RoundTripToolHandlerFor[In, Out any] func(context.Context, *CallToolRequest, In) (RoundTripCallToolResult, Out, error) + +// RoundTripCallToolResult is implemented by CallToolResult and IncompleteResult +type RoundTripCallToolResult interface { isMRTRResult() } + +type IncompleteResult struct { + ... + InputRequests InputRequestMap `json:"inputRequests,omitempty"` + RequestState string `json:"requestState,omitempty"` +} + +func (s *Server) AddRoundTripTool(t *Tool, h RoundTripToolHandler) +func AddRoundTripTool[In, Out any](s *Server, t *Tool, h RoundTripToolHandlerFor[In, Out]) +``` + +`Server.AddTool` wraps the old `ToolHandler` into a `RoundTripToolHandler` to update its function signature: +```go +mcp.AddRoundTripTool(s, tool, func(ctx context.Context, req *mcp.CallToolRequest, in MyIn) (mcp.RoundTripCallToolResult, MyOut, error) { + if needsInput(in) { + return &mcp.IncompleteResult{ + ResultType: mcp.ResultTypeInputRequired, + InputRequests: InputRequestMap{"confirm": &mcp.ElicitParams{Message: "Sure?"}}, + }, zero, nil + } + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "done"}}}, myOut, nil +}) +``` + +The downsides of this approach are: +* SEP suggests `ResultType` will potentially be extended with new values, `RoundTrip` in new function names will not allow us to cleanly extend the sealed interface with new types. But an overly generic name for new functions will make the API use-case less clear. +* Different code +* SDK takes the same action (puts it on the wire) regardless of the returned type, it exists only for enforcing correctness of the user code. +* Exported API surface bloat: +7 exported functions. + +--- + +### Exported Middleware + +We could flip "unexported MRTR middleware with opt-out option" to "exported middleware with opt-in requirement". +```go +func AutoMRTR(opts *MultiRoundTripOptions) Middleware { ... } +type MultiRoundTripOptions struct { + MaxRetries int +} +client := mcp.NewClient(impl, nil) +client.AddSendingMiddleware(mcp.AutoMRTR(&mcp.MultiRoundTripOptions{ + MaxRetries: 5, +})) +``` +This would change semantics of `*Handler` fields - depending on the protocol version in use, an extra initialization step will be required for them to "take effect". + +--- + +### Server API protocol version bridging + +Converting `ServerSession.Elicit`/`CreateMessage`/`ListRoots` calls into MRTR wire format transparently (suspend the handler, return `input_required`, resume on retry). Rejected because of a significant implementation effort and the fact that it contradicts the design goal of MRTR where servers shouldn't hold resources between round trips, and it should be possible for a retry to arrive on any server instance in a multi-server deployment. + diff --git a/go.mod b/go.mod index 860d6fa..3287a95 100644 --- a/go.mod +++ b/go.mod @@ -15,5 +15,6 @@ require ( require ( github.com/segmentio/asm v1.1.3 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index 377a7b1..c13454a 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= diff --git a/mcp/client.go b/mcp/client.go index 6e24c5a..9f6f295 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -58,13 +58,17 @@ func NewClient(impl *Implementation, options *ClientOptions) *Client { opts.Logger = ensureLogger(nil) } - return &Client{ + c := &Client{ impl: impl, opts: opts, roots: newFeatureSet(func(r *Root) string { return r.URI }), sendingMethodHandler_: defaultSendingMethodHandler, receivingMethodHandler_: defaultReceivingMethodHandler[*ClientSession], } + if opts.MultiRoundTrip == nil || !opts.MultiRoundTrip.Disabled { + c.AddSendingMiddleware(clientMultiRoundTripMiddleware()) + } + return c } // ClientOptions configures the behavior of the client. @@ -154,6 +158,10 @@ type ClientOptions struct { ResourceUpdatedHandler func(context.Context, *ResourceUpdatedNotificationRequest) LoggingMessageHandler func(context.Context, *LoggingMessageRequest) ProgressNotificationHandler func(context.Context, *ProgressNotificationClientRequest) + // MultiRoundTrip configures the automatic MultiRoundTrip (Multi Round-Trip Requests) middleware. + // By default (nil), the middleware is enabled with default settings. + // Set Disabled to true to opt out of automatic MultiRoundTrip handling. + MultiRoundTrip *MultiRoundTripOptions // If non-zero, defines an interval for regular "ping" requests. // If the peer fails to respond to pings originating from the keepalive check, // the session is automatically closed. diff --git a/mcp/content_nil_test.go b/mcp/content_nil_test.go index ba263e6..86bb86a 100644 --- a/mcp/content_nil_test.go +++ b/mcp/content_nil_test.go @@ -15,6 +15,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -223,4 +224,4 @@ func TestContentUnmarshalNilWithInvalidContent(t *testing.T) { } } -var ctrCmpOpts = []cmp.Option{cmp.AllowUnexported(mcp.CallToolResult{})} +var ctrCmpOpts = []cmp.Option{cmpopts.IgnoreUnexported(mcp.CallToolResult{}, mcp.GetPromptResult{}, mcp.ReadResourceResult{})} diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 1417323..bad3508 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -24,6 +24,7 @@ import ( "time" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/jsonrpc" @@ -206,7 +207,7 @@ func TestEndToEnd(t *testing.T) { Role: "user", }}, } - if diff := cmp.Diff(wantReview, gotReview); diff != "" { + if diff := cmp.Diff(wantReview, gotReview, ctrCmpOpts...); diff != "" { t.Errorf("prompts/get 'code_review' mismatch (-want +got):\n%s", diff) } @@ -2371,4 +2372,4 @@ func TestSetErrorPreservesContent(t *testing.T) { } } -var ctrCmpOpts = []cmp.Option{cmp.AllowUnexported(CallToolResult{})} +var ctrCmpOpts = []cmp.Option{cmpopts.IgnoreUnexported(CallToolResult{}, GetPromptResult{}, ReadResourceResult{})} diff --git a/mcp/mrtr.go b/mcp/mrtr.go new file mode 100644 index 0000000..8ad5f1a --- /dev/null +++ b/mcp/mrtr.go @@ -0,0 +1,268 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package mcp + +import ( + "context" + "fmt" + "log/slog" + "sync" + + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "golang.org/x/sync/errgroup" +) + +const maxMultiRoundTripRetries = 10 +const maxLoadSheddingMultiRoundTripRetries = 3 + +// MultiRoundTripOptions configures the client-side multi round-trip request (SEP-2322) +// middleware. The middleware is enabled by default and automatically fulfills input +// requests from the server by invoking the appropriate client handlers and +// retrying the original call. +type MultiRoundTripOptions struct { + // Disabled prevents the automatic multi-round-tirp middleware from being installed. + // When true, the client returns input-required results directly and callers must + // handle the retry loop themselves using [CallToolResult.NeedsInput], + // [GetPromptResult.NeedsInput], or [ReadResourceResult.NeedsInput]. + Disabled bool +} + +type multiRoundTripResponse interface { + setResultType(resultType) + inputRequests() map[string]InputRequest + requestState() string + hasContent() bool +} + +func handleMultiRoundTripResult(ss *ServerSession, logger *slog.Logger, res multiRoundTripResponse) error { + if res == nil { + return nil + } + hasInputRequests := res.inputRequests() != nil + + if hasInputRequests && res.hasContent() { + logger.Warn("handler returned both content and inputRequests") + return &jsonrpc.Error{ + Code: jsonrpc.CodeInternalError, + Message: "server bug: result has both content and inputRequests", + } + } + + if clientSupportsMultiRoundTrip(ss) { + // For older clients the resultType is left unset. Input requests will be handled + // by serverMultiRoundTripMiddleware client calls and handler reinvocation. + if hasInputRequests { + res.setResultType(resultTypeInputRequired) + } else { + res.setResultType(resultTypeComplete) + } + } + return nil +} + +func clientSupportsMultiRoundTrip(ss *ServerSession) bool { + protocolVersion := latestProtocolVersion + if iparams := ss.InitializeParams(); iparams != nil { + protocolVersion = iparams.ProtocolVersion + } + return protocolVersion >= protocolVersion20260630 +} + +func clientMultiRoundTripMiddleware() Middleware { + return func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method != methodCallTool && method != methodGetPrompt && method != methodReadResource { + return next(ctx, method, req) + } + + loadSheddingFailures := 0 + for retries := 1; ; retries++ { + res, err := next(ctx, method, req) + if err != nil { + return res, err + } + mrtrResult, ok := res.(multiRoundTripResponse) + if !ok { + return res, nil + } + reqMap := mrtrResult.inputRequests() + if reqMap == nil { + return res, nil + } + if len(reqMap) == 0 { + loadSheddingFailures++ + } + if loadSheddingFailures >= maxLoadSheddingMultiRoundTripRetries { + return nil, fmt.Errorf("multi-round-trip: exceeded maximum load-shedding retries (%d)", maxLoadSheddingMultiRoundTripRetries) + } + if retries >= maxMultiRoundTripRetries { + return nil, fmt.Errorf("multi-round-trip: exceeded maximum retries (%d)", maxMultiRoundTripRetries) + } + cs, ok := req.GetSession().(*ClientSession) + if !ok { + return res, nil + } + responses, err := fulfillInputRequests(ctx, cs, reqMap) + if err != nil { + return nil, err + } + setMultiRoundTripRetryParams(req, responses, mrtrResult.requestState()) + } + } + } +} + +// serverMultiRoundTripMiddleware is a receiving middleware for servers that transparently +// handles multi-round-trip for clients on older protocol versions. When a handler returns +// InputRequests and the client does not support multi-round-trip, the middleware fulfills +// the requests by calling the client directly and reinvokes the handler once with the responses. +func serverMultiRoundTripMiddleware() Middleware { + return func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method != methodCallTool && method != methodGetPrompt && method != methodReadResource { + return next(ctx, method, req) + } + + ss, ok := req.GetSession().(*ServerSession) + if !ok { + return next(ctx, method, req) + } + if clientSupportsMultiRoundTrip(ss) { + return next(ctx, method, req) + } + + res, err := next(ctx, method, req) + if err != nil { + return res, err + } + mrtrResult, ok := res.(multiRoundTripResponse) + if !ok { + return res, nil + } + reqMap := mrtrResult.inputRequests() + if reqMap == nil { + return res, nil + } + if len(reqMap) == 0 { + return nil, fmt.Errorf("the server is busy, retry later") + } + responses, err := fulfillServerInputRequests(ctx, ss, reqMap) + if err != nil { + return nil, err + } + setMultiRoundTripRetryParams(req, responses, mrtrResult.requestState()) + return next(ctx, method, req) + } + } +} + +func fulfillServerInputRequests(ctx context.Context, ss *ServerSession, requests InputRequestMap) (InputResponseMap, error) { + g, ctx := errgroup.WithContext(ctx) + var mu sync.Mutex + responses := make(InputResponseMap, len(requests)) + for id, ir := range requests { + g.Go(func() error { + resp, err := fulfillServerInputRequest(ctx, ss, ir) + if err != nil { + return fmt.Errorf("fulfilling input request %q: %w", id, err) + } + mu.Lock() + responses[id] = resp + mu.Unlock() + return nil + }) + } + if err := g.Wait(); err != nil { + return nil, fmt.Errorf("multi-round-trip: %w", err) + } + return responses, nil +} + +func fulfillServerInputRequest(ctx context.Context, ss *ServerSession, ir InputRequest) (InputResponse, error) { + switch p := ir.(type) { + case *ElicitParams: + return ss.Elicit(ctx, p) + case *CreateMessageParams: + return ss.CreateMessageWithTools(ctx, createMessageParamsToWithTools(p)) + case *CreateMessageWithToolsParams: + return ss.CreateMessageWithTools(ctx, p) + case *ListRootsParams: + return ss.ListRoots(ctx, p) + default: + return nil, fmt.Errorf("unknown input request type: %T", ir) + } +} + +func createMessageParamsToWithTools(p *CreateMessageParams) *CreateMessageWithToolsParams { + var msgs []*SamplingMessageV2 + for _, m := range p.Messages { + msgs = append(msgs, &SamplingMessageV2{Content: []Content{m.Content}, Role: m.Role}) + } + return &CreateMessageWithToolsParams{ + Meta: p.Meta, + IncludeContext: p.IncludeContext, + MaxTokens: p.MaxTokens, + Messages: msgs, + Metadata: p.Metadata, + ModelPreferences: p.ModelPreferences, + StopSequences: p.StopSequences, + SystemPrompt: p.SystemPrompt, + Temperature: p.Temperature, + } +} + +func setMultiRoundTripRetryParams(req Request, responses InputResponseMap, state string) { + switch p := req.GetParams().(type) { + case *CallToolParams: + p.InputResponses = responses + p.RequestState = state + case *CallToolParamsRaw: + p.InputResponses = responses + p.RequestState = state + case *GetPromptParams: + p.InputResponses = responses + p.RequestState = state + case *ReadResourceParams: + p.InputResponses = responses + p.RequestState = state + } +} + +func fulfillInputRequests(ctx context.Context, cs *ClientSession, requests InputRequestMap) (InputResponseMap, error) { + g, ctx := errgroup.WithContext(ctx) + var mu sync.Mutex + responses := make(InputResponseMap, len(requests)) + for id, ir := range requests { + g.Go(func() error { + resp, err := fulfillInputRequest(ctx, cs, ir) + if err != nil { + return fmt.Errorf("fulfilling input request %q: %w", id, err) + } + mu.Lock() + responses[id] = resp + mu.Unlock() + return nil + }) + } + if err := g.Wait(); err != nil { + return nil, fmt.Errorf("multi round-trip: %w", err) + } + return responses, nil +} + +func fulfillInputRequest(ctx context.Context, cs *ClientSession, ir InputRequest) (InputResponse, error) { + switch p := ir.(type) { + case *ElicitParams: + return cs.client.elicit(ctx, newClientRequest(cs, p)) + case *CreateMessageParams: + return cs.client.createMessage(ctx, &CreateMessageWithToolsRequest{Session: cs, Params: createMessageParamsToWithTools(p)}) + case *CreateMessageWithToolsParams: + return cs.client.createMessage(ctx, &CreateMessageWithToolsRequest{Session: cs, Params: p}) + case *ListRootsParams: + return cs.client.listRoots(ctx, newClientRequest(cs, p)) + default: + return nil, fmt.Errorf("unknown input request type: %T", ir) + } +} diff --git a/mcp/mrtr_test.go b/mcp/mrtr_test.go new file mode 100644 index 0000000..12eba7e --- /dev/null +++ b/mcp/mrtr_test.go @@ -0,0 +1,546 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package mcp + +import ( + "context" + "fmt" + "slices" + "sync/atomic" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/jsonschema-go/jsonschema" +) + +func TestMultiRoundTrip_ManualRetry(t *testing.T) { + type deployResult struct { + Deployed bool `json:"deployed"` + Reason string `json:"reason,omitempty"` + } + + orig := supportedProtocolVersions + supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + ctx := context.Background() + + srv := NewServer(testImpl, nil) + AddTool(srv, &Tool{Name: "deploy"}, func(ctx context.Context, req *CallToolRequest, input struct{}) (*CallToolResult, *deployResult, error) { + if len(req.Params.InputResponses) == 0 { + return &CallToolResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Deploy to production?"}}, + RequestState: "deployment-123", + }, nil, nil + } + + resp, ok := req.Params.InputResponses["confirm"] + if !ok { + return &CallToolResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Please confirm (retry)"}}, + }, nil, nil + } + + if req.Params.RequestState == "" { + return &CallToolResult{}, &deployResult{Deployed: false, Reason: "no_state"}, nil + } + if elicitResult := resp.(*ElicitResult); elicitResult != nil && elicitResult.Action != "accept" { + return &CallToolResult{}, &deployResult{Deployed: false, Reason: "cancelled"}, nil + } + + return &CallToolResult{}, &deployResult{Deployed: true}, nil + }) + + conn := mustConnect(t, srv, &ClientOptions{ + MultiRoundTrip: &MultiRoundTripOptions{Disabled: true}, + }) + + // Round 1: initiate deployment + res, err := conn.CallTool(ctx, &CallToolParams{Name: "deploy"}) + if err != nil { + t.Fatalf("CallTool() error = %v", err) + } + if !res.NeedsInput() { + t.Fatal("NeedsInput() = false, want true") + } + if got := len(res.InputRequests); got != 1 { + t.Fatalf("len(res.InputRequests) = %d, want 1", got) + } + if _, ok := res.InputRequests["confirm"].(*ElicitParams); !ok { + t.Fatalf("res.InputRequests[confirm] type = %T, want *ElicitParams", res.InputRequests["confirm"]) + } + + // Round 2: retry with confirmation + res, err = conn.CallTool(ctx, &CallToolParams{ + Name: "deploy", + InputResponses: InputResponseMap{ + "confirm": &ElicitResult{Action: "accept", Content: map[string]any{"ok": true}}, + }, + RequestState: res.RequestState, + }) + if err != nil { + t.Fatalf("CallTool() follow-up error = %v", err) + } + if res.NeedsInput() { + t.Fatal("NeedsInput() = true after follow-up, want false") + } + + if diff := cmp.Diff(map[string]any{"deployed": true}, res.StructuredContent, ctrCmpOpts...); diff != "" { + t.Errorf("result mismatch (-want +got):\n%s", diff) + } +} + +func TestMultiRoundTrip_AutoRetry(t *testing.T) { + orig := supportedProtocolVersions + supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + tests := []struct { + name string + inputRequests InputRequestMap + wantResult map[string]any + }{ + { + name: "elicit", + inputRequests: InputRequestMap{ + "confirm": &ElicitParams{Message: "Deploy?"}, + }, + wantResult: map[string]any{"ids": []any{"confirm"}}, + }, + { + name: "createMessage", + inputRequests: InputRequestMap{ + "summarize": &CreateMessageParams{ + Messages: []*SamplingMessage{{Role: "user", Content: &TextContent{Text: "summarize"}}}, + MaxTokens: 100, + }, + }, + wantResult: map[string]any{"ids": []any{"summarize"}}, + }, + { + name: "listRoots", + inputRequests: InputRequestMap{ + "roots": &ListRootsParams{}, + }, + wantResult: map[string]any{"ids": []any{"roots"}}, + }, + { + name: "all three", + inputRequests: InputRequestMap{ + "confirm": &ElicitParams{Message: "OK?"}, + "draft": &CreateMessageParams{ + Messages: []*SamplingMessage{{Role: "user", Content: &TextContent{Text: "write"}}}, + MaxTokens: 50, + }, + "roots": &ListRootsParams{}, + }, + wantResult: map[string]any{"ids": []any{"confirm", "draft", "roots"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + + srv := NewServer(testImpl, nil) + inputRequests := tt.inputRequests + AddTool(srv, &Tool{Name: "act"}, func(ctx context.Context, req *CallToolRequest, input struct{}) (*CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + return &CallToolResult{ + InputRequests: inputRequests, + RequestState: "state-1", + }, nil, nil + } + // Collect the IDs of fulfilled responses. + var ids []string + for id := range req.Params.InputResponses { + ids = append(ids, id) + } + slices.Sort(ids) + return &CallToolResult{}, map[string]any{"ids": ids}, nil + }) + + conn := mustConnect(t, srv, &ClientOptions{ + ElicitationHandler: func(_ context.Context, req *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + CreateMessageHandler: func(_ context.Context, req *CreateMessageRequest) (*CreateMessageResult, error) { + return &CreateMessageResult{ + Model: "test-model", + Role: "assistant", + Content: &TextContent{Text: "response"}, + }, nil + }, + }) + conn.client.AddRoots(&Root{URI: "file:///workspace", Name: "workspace"}) + + res, err := conn.CallTool(ctx, &CallToolParams{Name: "act"}) + if err != nil { + t.Fatalf("CallTool() error = %v", err) + } + if res.NeedsInput() { + t.Fatal("NeedsInput() = true after auto-retry, want false") + } + + // Sort the expected IDs for stable comparison. + if wantIDs, ok := tt.wantResult["ids"].([]any); ok { + slices.SortFunc(wantIDs, func(a, b any) int { + if a.(string) < b.(string) { + return -1 + } + return 1 + }) + } + + if diff := cmp.Diff(tt.wantResult, res.StructuredContent, ctrCmpOpts...); diff != "" { + t.Errorf("result mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestMultiRoundTrip_MaxRetries(t *testing.T) { + testCases := []struct { + name string + requests InputRequestMap + wantRetries int + }{ + { + name: "load shedding", + requests: InputRequestMap{}, + wantRetries: maxLoadSheddingMultiRoundTripRetries, + }, + { + name: "input request", + requests: InputRequestMap{"confirm": &ElicitParams{Message: "Again?"}}, + wantRetries: maxMultiRoundTripRetries, + }, + } + orig := supportedProtocolVersions + supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + + var serverCalls atomic.Int32 + srv := NewServer(testImpl, nil) + AddTool(srv, &Tool{Name: "loop"}, func(ctx context.Context, req *CallToolRequest, input struct{}) (*CallToolResult, any, error) { + serverCalls.Add(1) + return &CallToolResult{InputRequests: tc.requests, RequestState: "loop-state"}, nil, nil + }) + + conn := mustConnect(t, srv, &ClientOptions{ + ElicitationHandler: func(_ context.Context, req *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + }) + + _, err := conn.CallTool(ctx, &CallToolParams{Name: "loop"}) + if err == nil { + t.Fatal("CallTool() err = nil, want error for exceeded max retries") + } + if serverCalls.Load() != int32(tc.wantRetries) { + t.Errorf("serverCalls = %d, want %d", serverCalls.Load(), tc.wantRetries) + } + }) + } +} + +func TestMultiRoundTrip_ServerMiddleware(t *testing.T) { + // multiRoundTripToolHandler returns a ToolHandler (plain, non-generic) that requests + // the given inputRequests on the first call and returns the fulfilled + // response IDs on the second. + multiRoundTripToolHandler := func(inputRequests InputRequestMap) ToolHandler { + return func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { + if len(req.Params.InputResponses) == 0 { + return &CallToolResult{ + InputRequests: inputRequests, + RequestState: "state-1", + }, nil + } + var ids []string + for id := range req.Params.InputResponses { + ids = append(ids, id) + } + slices.Sort(ids) + content := &TextContent{Text: fmt.Sprintf("%v", ids)} + return &CallToolResult{Content: []Content{content}}, nil + } + } + + tests := []struct { + name string + inputRequests InputRequestMap + wantText string + }{ + { + name: "elicit via ToolHandler", + inputRequests: InputRequestMap{ + "confirm": &ElicitParams{Message: "Sure?"}, + }, + wantText: "[confirm]", + }, + { + name: "elicit and listRoots via ToolHandler", + inputRequests: InputRequestMap{ + "confirm": &ElicitParams{Message: "OK?"}, + "roots": &ListRootsParams{}, + }, + wantText: "[confirm roots]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + + srv := NewServer(testImpl, nil) + srv.AddTool( + &Tool{Name: "act", InputSchema: &jsonschema.Schema{Type: "object"}}, + multiRoundTripToolHandler(tt.inputRequests), + ) + + // Connect with an OLD protocol version where multi-round-trip is not supported. + // The server middleware should handle it transparently. + st, ct := NewInMemoryTransports() + ss, err := srv.Connect(t.Context(), st, nil) + if err != nil { + t.Fatalf("server.Connect() error = %v", err) + } + t.Cleanup(func() { _ = ss.Close() }) + + c := NewClient(testImpl, &ClientOptions{ + MultiRoundTrip: &MultiRoundTripOptions{Disabled: true}, + ElicitationHandler: func(_ context.Context, req *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + }) + c.AddRoots(&Root{URI: "file:///workspace", Name: "workspace"}) + cs, err := c.Connect(t.Context(), ct, &ClientSessionOptions{}) + if err != nil { + t.Fatalf("client.Connect() error = %v", err) + } + t.Cleanup(func() { _ = cs.Close() }) + + res, err := cs.CallTool(ctx, &CallToolParams{Name: "act"}) + if err != nil { + t.Fatalf("CallTool() error = %v", err) + } + if got := res.Content[0].(*TextContent).Text; got != tt.wantText { + t.Errorf("result text = %q, want %q", got, tt.wantText) + } + }) + } +} + +func TestMultiRoundTrip_GetPrompt_AutoRetry(t *testing.T) { + orig := supportedProtocolVersions + supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + ctx := context.Background() + + srv := NewServer(testImpl, nil) + srv.AddPrompt(&Prompt{Name: "review"}, func(_ context.Context, req *GetPromptRequest) (*GetPromptResult, error) { + if len(req.Params.InputResponses) == 0 { + return &GetPromptResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Include sensitive data?"}}, + RequestState: "prompt-state", + }, nil + } + return &GetPromptResult{ + Description: "Code review prompt", + Messages: []*PromptMessage{{Role: "user", Content: &TextContent{Text: "review this code"}}}, + }, nil + }) + + conn := mustConnect(t, srv, &ClientOptions{ + ElicitationHandler: func(_ context.Context, _ *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + }) + + res, err := conn.GetPrompt(ctx, &GetPromptParams{Name: "review"}) + if err != nil { + t.Fatalf("GetPrompt() error = %v", err) + } + if res.NeedsInput() { + t.Fatal("NeedsInput() = true after auto-retry, want false") + } + if len(res.Messages) != 1 { + t.Fatalf("len(res.Messages) = %d, want 1", len(res.Messages)) + } + if got := res.Messages[0].Content.(*TextContent).Text; got != "review this code" { + t.Errorf("message text = %q, want %q", got, "review this code") + } +} + +func TestMultiRoundTrip_GetPrompt_ManualRetry(t *testing.T) { + orig := supportedProtocolVersions + supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + ctx := context.Background() + + srv := NewServer(testImpl, nil) + srv.AddPrompt(&Prompt{Name: "review"}, func(_ context.Context, req *GetPromptRequest) (*GetPromptResult, error) { + if len(req.Params.InputResponses) == 0 { + return &GetPromptResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Include sensitive data?"}}, + RequestState: "prompt-state", + }, nil + } + return &GetPromptResult{ + Description: "Code review prompt", + Messages: []*PromptMessage{{Role: "user", Content: &TextContent{Text: "review this code"}}}, + }, nil + }) + + conn := mustConnect(t, srv, &ClientOptions{ + MultiRoundTrip: &MultiRoundTripOptions{Disabled: true}, + }) + + res, err := conn.GetPrompt(ctx, &GetPromptParams{Name: "review"}) + if err != nil { + t.Fatalf("GetPrompt() error = %v", err) + } + if !res.NeedsInput() { + t.Fatal("NeedsInput() = false, want true") + } + if _, ok := res.InputRequests["confirm"].(*ElicitParams); !ok { + t.Fatalf("InputRequests[confirm] type = %T, want *ElicitParams", res.InputRequests["confirm"]) + } + + res, err = conn.GetPrompt(ctx, &GetPromptParams{ + Name: "review", + InputResponses: InputResponseMap{"confirm": &ElicitResult{Action: "accept"}}, + RequestState: res.RequestState, + }) + if err != nil { + t.Fatalf("GetPrompt() follow-up error = %v", err) + } + if res.NeedsInput() { + t.Fatal("NeedsInput() = true after follow-up, want false") + } + if len(res.Messages) != 1 { + t.Fatalf("len(res.Messages) = %d, want 1", len(res.Messages)) + } +} + +func TestMultiRoundTrip_ReadResource_AutoRetry(t *testing.T) { + orig := supportedProtocolVersions + supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + ctx := context.Background() + + srv := NewServer(testImpl, nil) + srv.AddResource(&Resource{URI: "test://data", Name: "data"}, func(_ context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) { + if len(req.Params.InputResponses) == 0 { + return &ReadResourceResult{ + InputRequests: InputRequestMap{"auth": &ElicitParams{Message: "Authenticate?"}}, + RequestState: "resource-state", + }, nil + } + return &ReadResourceResult{ + Contents: []*ResourceContents{{URI: "test://data", Text: "resource data"}}, + }, nil + }) + + conn := mustConnect(t, srv, &ClientOptions{ + ElicitationHandler: func(_ context.Context, _ *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + }) + + res, err := conn.ReadResource(ctx, &ReadResourceParams{URI: "test://data"}) + if err != nil { + t.Fatalf("ReadResource() error = %v", err) + } + if res.NeedsInput() { + t.Fatal("NeedsInput() = true after auto-retry, want false") + } + if len(res.Contents) != 1 { + t.Fatalf("len(res.Contents) = %d, want 1", len(res.Contents)) + } + if got := res.Contents[0].Text; got != "resource data" { + t.Errorf("resource text = %q, want %q", got, "resource data") + } +} + +func TestMultiRoundTrip_ReadResource_ManualRetry(t *testing.T) { + orig := supportedProtocolVersions + supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + ctx := context.Background() + + srv := NewServer(testImpl, nil) + srv.AddResource(&Resource{URI: "test://data", Name: "data"}, func(_ context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) { + if len(req.Params.InputResponses) == 0 { + return &ReadResourceResult{ + InputRequests: InputRequestMap{"auth": &ElicitParams{Message: "Authenticate?"}}, + RequestState: "resource-state", + }, nil + } + return &ReadResourceResult{ + Contents: []*ResourceContents{{URI: "test://data", Text: "resource data"}}, + }, nil + }) + + conn := mustConnect(t, srv, &ClientOptions{ + MultiRoundTrip: &MultiRoundTripOptions{Disabled: true}, + }) + + res, err := conn.ReadResource(ctx, &ReadResourceParams{URI: "test://data"}) + if err != nil { + t.Fatalf("ReadResource() error = %v", err) + } + if !res.NeedsInput() { + t.Fatal("NeedsInput() = false, want true") + } + if _, ok := res.InputRequests["auth"].(*ElicitParams); !ok { + t.Fatalf("InputRequests[auth] type = %T, want *ElicitParams", res.InputRequests["auth"]) + } + + res, err = conn.ReadResource(ctx, &ReadResourceParams{ + URI: "test://data", + InputResponses: InputResponseMap{"auth": &ElicitResult{Action: "accept"}}, + RequestState: res.RequestState, + }) + if err != nil { + t.Fatalf("ReadResource() follow-up error = %v", err) + } + if res.NeedsInput() { + t.Fatal("NeedsInput() = true after follow-up, want false") + } + if len(res.Contents) != 1 { + t.Fatalf("len(res.Contents) = %d, want 1", len(res.Contents)) + } +} + +func mustConnect(t *testing.T, s *Server, clientOpts *ClientOptions) *ClientSession { + t.Helper() + st, ct := NewInMemoryTransports() + ss, err := s.Connect(t.Context(), st, nil) + if err != nil { + t.Fatalf("server.Connect() error = %v", err) + } + t.Cleanup(func() { + _ = ss.Close() + }) + + c := NewClient(testImpl, clientOpts) + cs, err := c.Connect(t.Context(), ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + if err != nil { + t.Fatalf("client.Connect() error = %v", err) + } + t.Cleanup(func() { + _ = cs.Close() + }) + return cs +} diff --git a/mcp/protocol.go b/mcp/protocol.go index fcba83f..bebdc19 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -13,6 +13,178 @@ import ( "github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug" ) +// resultType indicates whether a result is complete or requires further input +// from the client via the multi round-trip request protocol. +type resultType string + +const ( + // resultTypeComplete indicates the result is final. + // This is the default when ResultType is empty. + resultTypeComplete resultType = "complete" + + // resultTypeInputRequired indicates the server needs additional client + // input before it can complete the request. The client should fulfill the + // InputRequests and retry the call with the responses. + resultTypeInputRequired resultType = "input_required" +) + +// InputRequest is a type for parameters that a server can include in the response +// to request input from client (SEP-2322). Implementations are [*ElicitParams], +// [*CreateMessageParams], and [*ListRootsParams]. +type InputRequest interface{ isInputRequest() } + +// InputRequestMap maps server-assigned request IDs to [InputRequest] values. +// It is used in result types to tell the client what input the server needs. +type InputRequestMap map[string]InputRequest + +func (m InputRequestMap) MarshalJSON() ([]byte, error) { + if m == nil { + return json.Marshal(map[string]any(nil)) + } + type wire struct { + Method string `json:"method"` + Params InputRequest `json:"params,omitempty"` + } + typeToMethod := func(v InputRequest) (string, error) { + switch v.(type) { + case *ElicitParams: + return methodElicit, nil + case *CreateMessageParams, *CreateMessageWithToolsParams: + return methodCreateMessage, nil + case *ListRootsParams: + return methodListRoots, nil + default: + return "", fmt.Errorf("unsupported type: %T", v) + } + } + converted := map[string]*wire{} + for k, v := range m { + method, err := typeToMethod(v) + if err != nil { + return nil, err + } + converted[k] = &wire{Method: method, Params: v} + } + return json.Marshal(converted) +} + +func (m *InputRequestMap) UnmarshalJSON(data []byte) error { + type raw struct { + Method string `json:"method"` + Params json.RawMessage `json:"params"` + } + var rawMap map[string]*raw + if err := json.Unmarshal(data, &rawMap); err != nil { + return err + } + if rawMap == nil { + return nil + } + result := make(InputRequestMap, len(rawMap)) + for k, raw := range rawMap { + switch raw.Method { + case methodElicit: + var p ElicitParams + if err := json.Unmarshal(raw.Params, &p); err != nil { + return err + } + result[k] = &p + case methodCreateMessage: + var p CreateMessageWithToolsParams + if err := json.Unmarshal(raw.Params, &p); err != nil { + return err + } + result[k] = &p + case methodListRoots: + var p ListRootsParams + if err := json.Unmarshal(raw.Params, &p); err != nil { + return err + } + result[k] = &p + default: + return fmt.Errorf("unsupported InputRequest method: %q", raw.Method) + } + } + *m = result + return nil +} + +// InputResponse is a type for results that a client sends back when fulfilling +// a server input request (SEP-2322). Implementations are [*ElicitResult], +// [*CreateMessageResult], and [*ListRootsResult]. +type InputResponse interface{ isInputResponse() } + +// InputResponseMap maps request IDs (from [InputRequestMap]) to [InputResponse] +// values. It is used in params types when retrying a call after an +// input-required result. +type InputResponseMap map[string]InputResponse + +func (m InputResponseMap) MarshalJSON() ([]byte, error) { + type wire struct { + Method string `json:"method"` + Result InputResponse `json:"result,omitempty"` + } + typeToMethod := func(v InputResponse) (string, error) { + switch v.(type) { + case *ElicitResult: + return methodElicit, nil + case *CreateMessageResult, *CreateMessageWithToolsResult: + return methodCreateMessage, nil + case *ListRootsResult: + return methodListRoots, nil + default: + return "", fmt.Errorf("unsupported type: %T", v) + } + } + converted := map[string]*wire{} + for k, v := range m { + method, err := typeToMethod(v) + if err != nil { + return nil, err + } + converted[k] = &wire{Method: method, Result: v} + } + return json.Marshal(converted) +} + +func (m *InputResponseMap) UnmarshalJSON(data []byte) error { + type raw struct { + Method string `json:"method"` + Result json.RawMessage `json:"result"` + } + var rawMap map[string]*raw + if err := json.Unmarshal(data, &rawMap); err != nil { + return err + } + result := make(InputResponseMap, len(rawMap)) + for k, raw := range rawMap { + switch raw.Method { + case methodElicit: + var p ElicitResult + if err := json.Unmarshal(raw.Result, &p); err != nil { + return err + } + result[k] = &p + case methodCreateMessage: + var p CreateMessageWithToolsResult + if err := json.Unmarshal(raw.Result, &p); err != nil { + return err + } + result[k] = &p + case methodListRoots: + var p ListRootsResult + if err := json.Unmarshal(raw.Result, &p); err != nil { + return err + } + result[k] = &p + default: + return fmt.Errorf("unsupported InputResponse method: %q", raw.Method) + } + } + *m = result + return nil +} + // Optional annotations for the client. The client can use annotations to inform // how objects are used or displayed. type Annotations struct { @@ -46,6 +218,14 @@ type CallToolParams struct { // Arguments holds the tool arguments. It can hold any value that can be // marshaled to JSON. Arguments any `json:"arguments,omitempty"` + + // InputResponses maps input request IDs to responses, provided when + // retrying a call after receiving a result with ResultType + // ResultTypeInputRequired. + InputResponses InputResponseMap `json:"inputResponses,omitempty"` + // RequestState is the opaque state from the previous input-required result. + // The client must echo this back when retrying. + RequestState string `json:"requestState,omitempty"` } // CallToolParamsRaw is passed to tool handlers on the server. Its arguments @@ -61,6 +241,14 @@ type CallToolParamsRaw struct { // is the responsibility of the tool handler to unmarshal and validate the // Arguments (see [AddTool]). Arguments json.RawMessage `json:"arguments,omitempty"` + + // InputResponses maps input request IDs to responses, provided when + // retrying a call after receiving a result with ResultType + // ResultTypeInputRequired. + InputResponses InputResponseMap `json:"inputResponses,omitempty"` + // RequestState is the opaque state from the previous input-required result. + // The client must echo this back when retrying. + RequestState string `json:"requestState,omitempty"` } // A CallToolResult is the server's response to a tool call. @@ -107,6 +295,24 @@ type CallToolResult struct { // the Content field. IsError bool `json:"isError,omitempty"` + // InputRequests is a map of server-assigned IDs to input requests. + // Populated only when ResultType is ResultTypeInputRequired. + // The client must fulfill these and echo the IDs back in InputResponses + // when retrying the call. + InputRequests InputRequestMap `json:"inputRequests,omitempty"` + + // RequestState is an opaque string the client must echo back when + // retrying after an input-required result. Servers use this to carry + // context between independent requests. + // + // Unauthenticated servers must encrypt, sign and verify this value. + RequestState string `json:"requestState,omitempty"` + + // ResultType indicates whether this result is complete or requires further + // client input. Empty or ResultTypeComplete means the call succeeded + // normally. ResultTypeInputRequired means the client should fulfill the + // InputRequests and retry the call. + resultType resultType // The error passed to setError, if any. // It is not marshaled, and therefore it is only visible on the server. // Its only use is in server sending middleware, where it can be accessed @@ -145,13 +351,49 @@ func (r *CallToolResult) GetError() error { func (*CallToolResult) isResult() {} -// UnmarshalJSON handles the unmarshalling of content into the Content -// interface. +func (r *CallToolResult) setResultType(rt resultType) { r.resultType = rt } +func (r *CallToolResult) requestState() string { return r.RequestState } +func (r *CallToolResult) inputRequests() map[string]InputRequest { + if r == nil { + return nil + } + return r.InputRequests +} +func (r *CallToolResult) hasContent() bool { + return len(r.Content) > 0 || r.StructuredContent != nil +} + +// NeedsInput reports whether this result requires further client input. +// This is true when the server returned ResultType "input_required". +// When NeedsInput returns true, check InputRequests for the set of +// requests the server needs fulfilled before retrying the call. +// An empty InputRequests with NeedsInput true indicates load-shedding. +func (r *CallToolResult) NeedsInput() bool { return r.resultType == resultTypeInputRequired } + +func (x *CallToolResult) MarshalJSON() ([]byte, error) { + type res CallToolResult // avoid recursion + type wire struct { + res + ResultType resultType `json:"resultType,omitempty"` + InputRequests json.RawMessage `json:"inputRequests,omitempty"` // shadows res.InputRequests + } + w := wire{res: res(*x), ResultType: x.resultType} + if x.InputRequests != nil { + ir, err := json.Marshal(x.InputRequests) + if err != nil { + return nil, err + } + w.InputRequests = ir + } + return json.Marshal(w) +} + func (x *CallToolResult) UnmarshalJSON(data []byte) error { type res CallToolResult // avoid recursion var wire struct { res - Content []*wireContent `json:"content"` + Content []*wireContent `json:"content"` + ResultType resultType `json:"resultType"` } if err := internaljson.Unmarshal(data, &wire); err != nil { return err @@ -160,6 +402,7 @@ func (x *CallToolResult) UnmarshalJSON(data []byte) error { if wire.res.Content, err = contentsFromWire(wire.Content, nil); err != nil { return err } + wire.res.resultType = wire.ResultType *x = CallToolResult(wire.res) return nil } @@ -426,6 +669,7 @@ type CreateMessageParams struct { } func (x *CreateMessageParams) isParams() {} +func (x *CreateMessageParams) isInputRequest() {} func (x *CreateMessageParams) isNil() bool { return x == nil } func (x *CreateMessageParams) GetProgressToken() any { return getProgressToken(x) } func (x *CreateMessageParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -453,6 +697,7 @@ type CreateMessageWithToolsParams struct { } func (x *CreateMessageWithToolsParams) isParams() {} +func (x *CreateMessageWithToolsParams) isInputRequest() {} func (x *CreateMessageWithToolsParams) isNil() bool { return x == nil } func (x *CreateMessageWithToolsParams) GetProgressToken() any { return getProgressToken(x) } func (x *CreateMessageWithToolsParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -553,7 +798,8 @@ type CreateMessageResult struct { StopReason string `json:"stopReason,omitempty"` } -func (*CreateMessageResult) isResult() {} +func (*CreateMessageResult) isResult() {} +func (*CreateMessageResult) isInputResponse() {} func (r *CreateMessageResult) UnmarshalJSON(data []byte) error { type result CreateMessageResult // avoid recursion var wire struct { @@ -598,7 +844,8 @@ var createMessageWithToolsResultAllow = map[string]bool{ "tool_use": true, } -func (*CreateMessageWithToolsResult) isResult() {} +func (*CreateMessageWithToolsResult) isResult() {} +func (*CreateMessageWithToolsResult) isInputResponse() {} // MarshalJSON marshals the result. When Content has a single element, it is // marshaled as a single object for compatibility with pre-2025-11-25 @@ -657,6 +904,13 @@ type GetPromptParams struct { Arguments map[string]string `json:"arguments,omitempty"` // The name of the prompt or prompt template. Name string `json:"name"` + + // InputResponses maps input request IDs to responses, provided when + // retrying a call after receiving a result with ResultType + // ResultTypeInputRequired. + InputResponses InputResponseMap `json:"inputResponses,omitempty"` + // RequestState is the opaque state from the previous input-required result. + RequestState string `json:"requestState,omitempty"` } func (x *GetPromptParams) isParams() {} @@ -672,10 +926,67 @@ type GetPromptResult struct { // An optional description for the prompt. Description string `json:"description,omitempty"` Messages []*PromptMessage `json:"messages"` + + // InputRequests is populated when ResultType is ResultTypeInputRequired. + // See [CallToolResult.InputRequests]. + InputRequests InputRequestMap `json:"inputRequests,omitempty"` + // RequestState is the opaque state for multi-round-trip retries. + // See [CallToolResult.RequestState]. + RequestState string `json:"requestState,omitempty"` + + // ResultType indicates whether this result is complete or requires further + // client input. See [CallToolResult.ResultType] for details. + resultType resultType } func (*GetPromptResult) isResult() {} +func (r *GetPromptResult) setResultType(rt resultType) { r.resultType = rt } +func (r *GetPromptResult) requestState() string { return r.RequestState } +func (r *GetPromptResult) inputRequests() map[string]InputRequest { + if r == nil { + return nil + } + return r.InputRequests +} +func (r *GetPromptResult) hasContent() bool { return len(r.Messages) > 0 } + +// NeedsInput reports whether this result requires further client input. +// See [CallToolResult.NeedsInput] for details. +func (r *GetPromptResult) NeedsInput() bool { return r.resultType == resultTypeInputRequired } + +func (x *GetPromptResult) MarshalJSON() ([]byte, error) { + type res GetPromptResult + type wire struct { + res + ResultType resultType `json:"resultType,omitempty"` + InputRequests json.RawMessage `json:"inputRequests,omitempty"` // shadows res.InputRequests + } + w := wire{res: res(*x), ResultType: x.resultType} + if x.InputRequests != nil { + ir, err := json.Marshal(x.InputRequests) + if err != nil { + return nil, err + } + w.InputRequests = ir + } + return json.Marshal(w) +} + +func (x *GetPromptResult) UnmarshalJSON(data []byte) error { + type res GetPromptResult + var wire struct { + res + ResultType resultType `json:"resultType"` + } + if err := internaljson.Unmarshal(data, &wire); err != nil { + return err + } + wire.res.resultType = wire.ResultType + *x = GetPromptResult(wire.res) + return nil +} + // InitializeParams is sent by the client to initialize the session. type InitializeParams struct { // This property is reserved by the protocol to allow clients and servers to @@ -845,6 +1156,7 @@ type ListRootsParams struct { } func (x *ListRootsParams) isParams() {} +func (x *ListRootsParams) isInputRequest() {} func (x *ListRootsParams) isNil() bool { return x == nil } func (x *ListRootsParams) GetProgressToken() any { return getProgressToken(x) } func (x *ListRootsParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -859,7 +1171,8 @@ type ListRootsResult struct { Roots []*Root `json:"roots"` } -func (*ListRootsResult) isResult() {} +func (*ListRootsResult) isResult() {} +func (*ListRootsResult) isInputResponse() {} type ListToolsParams struct { // This property is reserved by the protocol to allow clients and servers to @@ -1104,6 +1417,13 @@ type ReadResourceParams struct { // The URI of the resource to read. The URI can use any protocol; it is up to // the server how to interpret it. URI string `json:"uri"` + + // InputResponses maps input request IDs to responses, provided when + // retrying a call after receiving a result with ResultType + // ResultTypeInputRequired. + InputResponses InputResponseMap `json:"inputResponses,omitempty"` + // RequestState is the opaque state from the previous input-required result. + RequestState string `json:"requestState,omitempty"` } func (x *ReadResourceParams) isParams() {} @@ -1117,10 +1437,67 @@ type ReadResourceResult struct { // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` Contents []*ResourceContents `json:"contents"` + + // InputRequests is populated when ResultType is ResultTypeInputRequired. + // See [CallToolResult.InputRequests]. + InputRequests InputRequestMap `json:"inputRequests,omitempty"` + // RequestState is the opaque state for multi-round-trip retries. + // See [CallToolResult.RequestState]. + RequestState string `json:"requestState,omitempty"` + + // ResultType indicates whether this result is complete or requires further + // client input. See [CallToolResult.ResultType] for details. + resultType resultType } func (*ReadResourceResult) isResult() {} +func (r *ReadResourceResult) setResultType(rt resultType) { r.resultType = rt } +func (r *ReadResourceResult) requestState() string { return r.RequestState } +func (r *ReadResourceResult) inputRequests() map[string]InputRequest { + if r == nil { + return nil + } + return r.InputRequests +} +func (r *ReadResourceResult) hasContent() bool { return len(r.Contents) > 0 } + +// NeedsInput reports whether this result requires further client input. +// See [CallToolResult.NeedsInput] for details. +func (r *ReadResourceResult) NeedsInput() bool { return r.resultType == resultTypeInputRequired } + +func (x *ReadResourceResult) MarshalJSON() ([]byte, error) { + type res ReadResourceResult + type wire struct { + res + ResultType resultType `json:"resultType,omitempty"` + InputRequests json.RawMessage `json:"inputRequests,omitempty"` // shadows res.InputRequests + } + w := wire{res: res(*x), ResultType: x.resultType} + if x.InputRequests != nil { + ir, err := json.Marshal(x.InputRequests) + if err != nil { + return nil, err + } + w.InputRequests = ir + } + return json.Marshal(w) +} + +func (x *ReadResourceResult) UnmarshalJSON(data []byte) error { + type res ReadResourceResult + var wire struct { + res + ResultType resultType `json:"resultType"` + } + if err := internaljson.Unmarshal(data, &wire); err != nil { + return err + } + wire.res.resultType = wire.ResultType + *x = ReadResourceResult(wire.res) + return nil +} + // A known resource that the server is capable of reading. type Resource struct { // See [specification/2025-06-18/basic/index#general-fields] for notes on _meta @@ -1520,8 +1897,9 @@ type ElicitParams struct { ElicitationID string `json:"elicitationId,omitempty"` } -func (x *ElicitParams) isParams() {} -func (x *ElicitParams) isNil() bool { return x == nil } +func (x *ElicitParams) isParams() {} +func (x *ElicitParams) isInputRequest() {} +func (x *ElicitParams) isNil() bool { return x == nil } func (x *ElicitParams) GetProgressToken() any { return getProgressToken(x) } func (x *ElicitParams) SetProgressToken(t any) { setProgressToken(x, t) } @@ -1541,7 +1919,8 @@ type ElicitResult struct { Content map[string]any `json:"content,omitempty"` } -func (*ElicitResult) isResult() {} +func (*ElicitResult) isResult() {} +func (*ElicitResult) isInputResponse() {} // ElicitationCompleteParams is sent from the server to the client, informing it that an out-of-band elicitation interaction has completed. type ElicitationCompleteParams struct { diff --git a/mcp/protocol_test.go b/mcp/protocol_test.go index 2bab644..edf3623 100644 --- a/mcp/protocol_test.go +++ b/mcp/protocol_test.go @@ -1151,6 +1151,71 @@ func TestToWithTools_Conversion(t *testing.T) { } } +func TestInputRequestMapJSON(t *testing.T) { + t.Run("nil is omitted from JSON", func(t *testing.T) { + result := CallToolResult{Content: []Content{&TextContent{Text: "ok"}}} + data, err := json.Marshal(&result) + if err != nil { + t.Fatal(err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + if _, ok := raw["inputRequests"]; ok { + t.Errorf("nil InputRequests should be omitted, got %s", raw["inputRequests"]) + } + }) + + t.Run("non-nil empty round-trips", func(t *testing.T) { + result := CallToolResult{ + Content: []Content{&TextContent{Text: "ok"}}, + InputRequests: InputRequestMap{}, + } + data, err := json.Marshal(&result) + if err != nil { + t.Fatal(err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + if string(raw["inputRequests"]) != "{}" { + t.Errorf("empty InputRequests should marshal to {}, got %s", raw["inputRequests"]) + } + var got CallToolResult + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if got.InputRequests == nil { + t.Error("empty InputRequests should round-trip as non-nil") + } + }) + + t.Run("populated round-trips", func(t *testing.T) { + result := CallToolResult{ + Content: []Content{&TextContent{Text: "ok"}}, + InputRequests: InputRequestMap{ + "r1": &ElicitParams{Message: "confirm?"}, + }, + } + data, err := json.Marshal(&result) + if err != nil { + t.Fatal(err) + } + var got CallToolResult + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if got.InputRequests == nil { + t.Fatal("InputRequests should not be nil after round-trip") + } + if _, ok := got.InputRequests["r1"]; !ok { + t.Error("expected key r1 in InputRequests") + } + }) +} + func TestContentUnmarshal(t *testing.T) { // Verify that types with a Content field round-trip properly. roundtrip := func(in, out any) { diff --git a/mcp/server.go b/mcp/server.go index 8d24147..b86b3e6 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -187,7 +187,7 @@ func NewServer(impl *Implementation, options *ServerOptions) *Server { opts.Logger = ensureLogger(nil) } - return &Server{ + s := &Server{ impl: impl, opts: opts, prompts: newFeatureSet(func(p *serverPrompt) string { return p.prompt.Name }), @@ -199,6 +199,8 @@ func NewServer(impl *Implementation, options *ServerOptions) *Server { resourceSubscriptions: make(map[string]map[*ServerSession]bool), pendingNotifications: make(map[string]*time.Timer), } + s.AddReceivingMiddleware(serverMultiRoundTripMiddleware()) + return s } // AddPrompt adds a [Prompt] to the server, or replaces one with the same name. @@ -370,8 +372,12 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out], cache *SchemaCa } // Marshal the output and put the RawMessage in the StructuredContent field. + // Skip when the handler returned input requests (multi round-trip): content and + // inputRequests are mutually exclusive on the wire. var outval any = out - if elemZero != nil { + if res.InputRequests != nil { + outval = nil + } else if elemZero != nil { // Avoid typed nil, which will serialize as JSON null. // Instead, use the zero value of the unpointered type. var z Out @@ -742,7 +748,13 @@ func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetProm Message: fmt.Sprintf("unknown prompt %q", req.Params.Name), } } - return prompt.handler(ctx, req) + res, err := prompt.handler(ctx, req) + if err == nil && res != nil { + if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil { + return nil, err + } + } + return res, err } func (s *Server) listTools(_ context.Context, req *ListToolsRequest) (*ListToolsResult, error) { @@ -775,10 +787,15 @@ func (s *Server) callTool(ctx context.Context, req *CallToolRequest) (*CallToolR } } res, err := st.handler(ctx, req) - if err == nil && res != nil && res.Content == nil { - res2 := *res - res2.Content = []Content{} // avoid "null" - res = &res2 + if err == nil && res != nil { + if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil { + return nil, err + } + if res.Content == nil && res.resultType != resultTypeInputRequired { + res2 := *res + res2.Content = []Content{} // avoid "null" + res = &res2 + } } return res, err } @@ -826,6 +843,12 @@ func (s *Server) readResource(ctx context.Context, req *ReadResourceRequest) (*R if err != nil { return nil, err } + if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil { + return nil, err + } + if res.resultType == resultTypeInputRequired { + return res, nil + } if res == nil || res.Contents == nil { return nil, fmt.Errorf("reading resource %s: read handler returned nil information", uri) } diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index e566bf2..53806da 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -769,6 +769,12 @@ func req(id int64, method string, params any) *jsonrpc.Request { return r } +func completeCallToolResult() *CallToolResult { + r := &CallToolResult{Content: []Content{}} + r.resultType = resultTypeComplete + return r +} + func resp(id int64, result any, err error) *jsonrpc.Response { return &jsonrpc.Response{ ID: jsonrpc2.Int64ID(id), @@ -1947,7 +1953,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { }, messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})}, wantStatusCode: http.StatusOK, - wantMessages: []jsonrpc.Message{resp(2, &CallToolResult{Content: []Content{}}, nil)}, + wantMessages: []jsonrpc.Message{resp(2, completeCallToolResult(), nil)}, }, { method: "POST", @@ -1991,7 +1997,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { }, messages: []jsonrpc.Message{req(6, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})}, wantStatusCode: http.StatusOK, - wantMessages: []jsonrpc.Message{resp(6, &CallToolResult{Content: []Content{}}, nil)}, + wantMessages: []jsonrpc.Message{resp(6, completeCallToolResult(), nil)}, }, { method: "POST", @@ -2007,7 +2013,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { Arguments: map[string]any{"region": "us-west1", "query": "SELECT 1"}, })}, wantStatusCode: http.StatusOK, - wantMessages: []jsonrpc.Message{resp(7, &CallToolResult{Content: []Content{}}, nil)}, + wantMessages: []jsonrpc.Message{resp(7, completeCallToolResult(), nil)}, }, { method: "POST", From dfb45f1e119ea40e63e0de563cbb06834a58ace1 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Fri, 29 May 2026 14:46:34 +0200 Subject: [PATCH 288/418] mcp: add optional issuer validator for pre-registered client validation (SEP-2352) (#946) Fixes #978 --- auth/authorization_code.go | 3 + auth/authorization_code_test.go | 82 +++++++++++++++++++++++++ auth/extauth/client_credentials.go | 6 +- auth/extauth/client_credentials_test.go | 45 ++++++++++++++ internal/authutil/util.go | 13 ++++ internal/authutil/util_test.go | 29 +++++++++ oauthex/auth_meta.go | 6 +- oauthex/client.go | 9 +++ oauthex/client_test.go | 2 +- 9 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 internal/authutil/util.go create mode 100644 internal/authutil/util_test.go diff --git a/auth/authorization_code.go b/auth/authorization_code.go index 144f0ad..a3daeec 100644 --- a/auth/authorization_code.go +++ b/auth/authorization_code.go @@ -507,6 +507,9 @@ func (h *AuthorizationCodeHandler) handleRegistration(ctx context.Context, asm * // 2. Attempt to use pre-registered client configuration. preCfg := h.config.PreregisteredClient if preCfg != nil { + if preCfg.Issuer != "" && !authutil.IssuersEqual(preCfg.Issuer, asm.Issuer) { + return nil, fmt.Errorf("authorization server issuer %q does not match pre-registered credentials issuer %q", asm.Issuer, preCfg.Issuer) + } authStyle := selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported) clientSecret := "" if preCfg.ClientSecretAuth != nil { diff --git a/auth/authorization_code_test.go b/auth/authorization_code_test.go index d4de06c..c84b003 100644 --- a/auth/authorization_code_test.go +++ b/auth/authorization_code_test.go @@ -609,6 +609,8 @@ func TestHandleRegistration(t *testing.T) { asm *oauthex.AuthServerMeta want *resolvedClientConfig wantError bool + issuerMatch bool + issuerSuffix string }{ { name: "ClientIDMetadataDocument", @@ -647,6 +649,79 @@ func TestHandleRegistration(t *testing.T) { authStyle: oauth2.AuthStyleInParams, }, }, + { + name: "Preregistered_IssuerMatch", + serverConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "pre_client_id": { + Secret: "pre_client_secret", + }, + }, + }, + handlerConfig: &AuthorizationCodeHandlerConfig{ + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "pre_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "pre_client_secret", + }, + Issuer: "", // set dynamically in the test + }, + }, + want: &resolvedClientConfig{ + registrationType: registrationTypePreregistered, + clientID: "pre_client_id", + clientSecret: "pre_client_secret", + authStyle: oauth2.AuthStyleInParams, + }, + issuerMatch: true, + }, + { + name: "Preregistered_IssuerMismatch", + serverConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "pre_client_id": { + Secret: "pre_client_secret", + }, + }, + }, + handlerConfig: &AuthorizationCodeHandlerConfig{ + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "pre_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "pre_client_secret", + }, + Issuer: "https://other-issuer.example.com", + }, + }, + wantError: true, + }, + { + name: "Preregistered_IssuerMatchTrailingSlash", + serverConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "pre_client_id": { + Secret: "pre_client_secret", + }, + }, + }, + handlerConfig: &AuthorizationCodeHandlerConfig{ + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "pre_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "pre_client_secret", + }, + Issuer: "", // set dynamically in the test (with trailing slash) + }, + }, + want: &resolvedClientConfig{ + registrationType: registrationTypePreregistered, + clientID: "pre_client_id", + clientSecret: "pre_client_secret", + authStyle: oauth2.AuthStyleInParams, + }, + issuerMatch: true, + issuerSuffix: "/", + }, { name: "NoneSupported", handlerConfig: &AuthorizationCodeHandlerConfig{ @@ -660,6 +735,10 @@ func TestHandleRegistration(t *testing.T) { t.Run(tt.name, func(t *testing.T) { s := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{RegistrationConfig: tt.serverConfig}) s.Start(t) + // Set the Issuer dynamically if requested by the test case. + if tt.issuerMatch { + tt.handlerConfig.PreregisteredClient.Issuer = s.URL() + tt.issuerSuffix + } tt.handlerConfig.AuthorizationCodeFetcher = func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { return nil, nil } @@ -679,6 +758,9 @@ func TestHandleRegistration(t *testing.T) { } return } + if tt.wantError { + t.Fatal("handleRegistration() expected error, got nil") + } if got.registrationType != tt.want.registrationType { t.Errorf("handleRegistration() registrationType = %v, want %v", got.registrationType, tt.want.registrationType) } diff --git a/auth/extauth/client_credentials.go b/auth/extauth/client_credentials.go index b95fcae..c65d8b0 100644 --- a/auth/extauth/client_credentials.go +++ b/auth/extauth/client_credentials.go @@ -128,6 +128,11 @@ func (h *ClientCredentialsHandler) Authorize(ctx context.Context, req *http.Requ } } + creds := h.config.Credentials + if creds.Issuer != "" && !authutil.IssuersEqual(creds.Issuer, asm.Issuer) { + return fmt.Errorf("authorization server issuer %q does not match pre-registered credentials issuer %q", asm.Issuer, creds.Issuer) + } + // Determine requestedScopes: use PRM's scopes_supported if available. requestedScopes := scopesFromChallenges(wwwChallenges) if len(requestedScopes) == 0 && len(prm.ScopesSupported) > 0 { @@ -140,7 +145,6 @@ func (h *ClientCredentialsHandler) Authorize(ctx context.Context, req *http.Requ requestedScopes = authutil.UnionScopes(h.grantedScopes[asm.Issuer], requestedScopes) // Step 3: Exchange client credentials for an access token. - creds := h.config.Credentials cfg := &clientcredentials.Config{ ClientID: creds.ClientID, ClientSecret: creds.ClientSecretAuth.ClientSecret, diff --git a/auth/extauth/client_credentials_test.go b/auth/extauth/client_credentials_test.go index 550e56a..b2973de 100644 --- a/auth/extauth/client_credentials_test.go +++ b/auth/extauth/client_credentials_test.go @@ -221,6 +221,51 @@ func TestClientCredentialsHandler_Authorize(t *testing.T) { } }) + t.Run("issuer mismatch", func(t *testing.T) { + config := validClientCredentialsConfig() + config.Credentials.Issuer = "https://other-issuer.example.com" + handler, err := NewClientCredentialsHandler(config) + if err != nil { + t.Fatal(err) + } + + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: http.Header{}, + Body: http.NoBody, + } + req := httptest.NewRequest("GET", resourceURL, nil) + err = handler.Authorize(t.Context(), req, resp) + if err == nil { + t.Fatal("expected Authorize to fail with issuer mismatch") + } + if !strings.Contains(err.Error(), "does not match") { + t.Errorf("error %q does not mention issuer mismatch", err.Error()) + } + }) + + t.Run("issuer match ignoring trailing slash", func(t *testing.T) { + config := validClientCredentialsConfig() + // authServer.URL() has no trailing slash; configure with one to + // verify the comparison tolerates the difference (per RFC 8414 §3.3 + // normalization applied in oauthex.IssuersEqual). + config.Credentials.Issuer = authServer.URL() + "/" + handler, err := NewClientCredentialsHandler(config) + if err != nil { + t.Fatal(err) + } + + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: http.Header{}, + Body: http.NoBody, + } + req := httptest.NewRequest("GET", resourceURL, nil) + if err := handler.Authorize(t.Context(), req, resp); err != nil { + t.Fatalf("Authorize() unexpected error = %v", err) + } + }) + t.Run("PRM via resource_metadata in challenge", func(t *testing.T) { prmMux := http.NewServeMux() prmMux.Handle("/custom-prm", auth.ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{ diff --git a/internal/authutil/util.go b/internal/authutil/util.go new file mode 100644 index 0000000..880d1bf --- /dev/null +++ b/internal/authutil/util.go @@ -0,0 +1,13 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package authutil + +import "strings" + +// IssuersEqual reports whether two OAuth 2.0 authorization server issuer +// identifiers refer to the same server comparing them without the final trailing slash. +func IssuersEqual(a, b string) bool { + return strings.TrimSuffix(a, "/") == strings.TrimSuffix(b, "/") +} diff --git a/internal/authutil/util_test.go b/internal/authutil/util_test.go new file mode 100644 index 0000000..9d3ff7a --- /dev/null +++ b/internal/authutil/util_test.go @@ -0,0 +1,29 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package authutil + +import "testing" + +func TestIssuersEqual(t *testing.T) { + tests := []struct { + a, b string + want bool + }{ + {"https://issuer.example.com", "https://issuer.example.com", true}, + {"https://issuer.example.com/", "https://issuer.example.com", true}, + {"https://issuer.example.com", "https://issuer.example.com/", true}, + {"https://issuer.example.com/", "https://issuer.example.com/", true}, + {"https://issuer.example.com/tenant", "https://issuer.example.com/tenant", true}, + {"https://issuer.example.com/tenant/", "https://issuer.example.com/tenant", true}, + {"https://issuer.example.com", "https://other.example.com", false}, + {"https://issuer.example.com/a", "https://issuer.example.com/b", false}, + {"", "", true}, + } + for _, tt := range tests { + if got := IssuersEqual(tt.a, tt.b); got != tt.want { + t.Errorf("IssuersEqual(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want) + } + } +} diff --git a/oauthex/auth_meta.go b/oauthex/auth_meta.go index 7ebf6e2..711b17a 100644 --- a/oauthex/auth_meta.go +++ b/oauthex/auth_meta.go @@ -12,7 +12,8 @@ import ( "errors" "fmt" "net/http" - "strings" + + "github.com/modelcontextprotocol/go-sdk/internal/authutil" ) // AuthServerMeta represents the metadata for an OAuth 2.0 authorization server, @@ -153,8 +154,7 @@ func GetAuthServerMeta(ctx context.Context, metadataURL, issuer string, c *http. } return nil, fmt.Errorf("%v", err) // Do not expose error types. } - if strings.TrimRight(asm.Issuer, "/") != strings.TrimRight(issuer, "/") { - // Validate the Issuer field (see RFC 8414, section 3.3). + if !authutil.IssuersEqual(asm.Issuer, issuer) { return nil, fmt.Errorf("metadata issuer %q does not match issuer URL %q", asm.Issuer, issuer) } diff --git a/oauthex/client.go b/oauthex/client.go index e8f9918..1b19eed 100644 --- a/oauthex/client.go +++ b/oauthex/client.go @@ -18,6 +18,15 @@ type ClientCredentials struct { // This is the most common authentication method for confidential clients. // OPTIONAL. If not provided, the client is treated as a public client. ClientSecretAuth *ClientSecretAuth + + // Issuer is the issuer identifier of the authorization server these + // credentials are registered with. Pre-registered credentials are bound + // to a specific authorization server; when set, an error is returned if + // the discovered authorization server does not match, per SEP-2352. + // The comparison ignores a single trailing slash, matching the + // tolerance applied during RFC 8414 Section 3.3 metadata validation. + // OPTIONAL. + Issuer string } // ClientSecretAuth holds client secret authentication credentials. diff --git a/oauthex/client_test.go b/oauthex/client_test.go index b78e9c8..34d8188 100644 --- a/oauthex/client_test.go +++ b/oauthex/client_test.go @@ -73,7 +73,7 @@ func TestClientCredentials_ValidateCoversAllAuthFields(t *testing.T) { var pointerFields int for i := range typ.NumField() { f := typ.Field(i) - if f.Name == "ClientID" { + if f.Name == "ClientID" || f.Name == "Issuer" { continue } if f.Type.Kind() != reflect.Ptr { From 8805aa85f7531bf478dd2ddf5406aaf222ff5e76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomas=20Daba=C5=A1inskas?= Date: Sun, 31 May 2026 11:25:49 +0300 Subject: [PATCH 289/418] mcp: add configurable keepalive failure threshold (#982) mcp: add configurable keepalive failure threshold Introduce `KeepAliveFailureThreshold` option in both `ClientOptions` and `ServerOptions` to control how many consecutive keepalive ping failures are tolerated before closing the session. This aligns with the MCP spec's guidance that "multiple failed pings MAY trigger a connection reset," allowing operators to tune resilience against transient network hiccups without immediately tearing down otherwise healthy sessions. A threshold of 0 or 1 (the default) closes on the first failure, preserving existing behavior. Higher values let isolated misses pass while still closing the session once consecutive failures reach the threshold. A successful ping resets the counter. Tolerated failures are logged at WARN level; the final failure that closes the session is logged at ERROR level. This is rework of #979. --- mcp/client.go | 9 +++++- mcp/mcp_test.go | 76 +++++++++++++++++++++++++++++++++++++++++++++++++ mcp/server.go | 9 +++++- mcp/shared.go | 51 +++++++++++++++++++++++++-------- 4 files changed, 131 insertions(+), 14 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index 9f6f295..979172b 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -166,6 +166,13 @@ type ClientOptions struct { // If the peer fails to respond to pings originating from the keepalive check, // the session is automatically closed. KeepAlive time.Duration + // KeepAliveFailureThreshold is the number of consecutive keepalive ping + // failures tolerated before the session is closed. A value of 0 or 1 + // closes the session on the first failure (the default). Higher values + // align with the spec's "multiple failed pings MAY trigger a connection + // reset" guidance, letting a transient miss pass without tearing down an + // otherwise live session. Has no effect unless KeepAlive is non-zero. + KeepAliveFailureThreshold int } // toolContextKeyType is the context key type for passing tool definitions @@ -441,7 +448,7 @@ func (cs *ClientSession) registerElicitationWaiter(elicitationID string) (await // startKeepalive starts the keepalive mechanism for this client session. func (cs *ClientSession) startKeepalive(interval time.Duration) { - startKeepalive(cs, interval, &cs.keepaliveCancel, cs.client.opts.Logger) + startKeepalive(cs, interval, cs.client.opts.KeepAliveFailureThreshold, &cs.keepaliveCancel, cs.client.opts.Logger) } // AddRoots adds the given roots to the client, diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index bad3508..5c8e7d1 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -1920,6 +1920,82 @@ func TestKeepAliveFailure_Logged(t *testing.T) { }) } +// scriptedKeepaliveSession is a keepaliveSession test double whose Ping +// returns errors from a script (one entry consumed per call; the last entry +// repeats once exhausted), and records how many times Close was called. Ping +// returns immediately so the keepalive loop's pace is driven purely by the +// ticker, making the test deterministic under synctest. +type scriptedKeepaliveSession struct { + pingErrs []error + pingCalls atomic.Int64 + closeCalls atomic.Int64 +} + +func (s *scriptedKeepaliveSession) Ping(context.Context, *PingParams) error { + n := int(s.pingCalls.Add(1)) - 1 + if n >= len(s.pingErrs) { + n = len(s.pingErrs) - 1 + } + return s.pingErrs[n] +} + +func (s *scriptedKeepaliveSession) Close() error { + s.closeCalls.Add(1) + return nil +} + +// TestStartKeepalive_FailureThreshold verifies that the session is kept alive +// across consecutive ping failures below the threshold and only closed once the +// threshold is reached. +func TestStartKeepalive_FailureThreshold(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + const interval = 100 * time.Millisecond + sess := &scriptedKeepaliveSession{pingErrs: []error{errors.New("boom")}} + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + var cancel context.CancelFunc + startKeepalive(sess, interval, 3, &cancel, logger) + defer cancel() + + // After two ticks → two failures, still below threshold 3: not closed. + time.Sleep(2*interval + interval/2) + synctest.Wait() + if got := sess.closeCalls.Load(); got != 0 { + t.Fatalf("session closed below threshold: closeCalls=%d (pingCalls=%d)", got, sess.pingCalls.Load()) + } + + // Third tick → third failure reaches threshold: session closed. + time.Sleep(interval) + synctest.Wait() + if got := sess.closeCalls.Load(); got != 1 { + t.Fatalf("expected one Close at threshold, got closeCalls=%d (pingCalls=%d)", got, sess.pingCalls.Load()) + } + }) +} + +// TestStartKeepalive_SuccessResetsFailures verifies that a successful ping +// resets the consecutive-failure counter, so an isolated failure between +// successes never accumulates toward the threshold. +func TestStartKeepalive_SuccessResetsFailures(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + const interval = 100 * time.Millisecond + // fail, success, fail, fail, then success (the tail repeats): the run + // never has 3 consecutive failures, so the session is never closed. + sess := &scriptedKeepaliveSession{pingErrs: []error{ + errors.New("boom"), nil, errors.New("boom"), errors.New("boom"), nil, + }} + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + var cancel context.CancelFunc + startKeepalive(sess, interval, 3, &cancel, logger) + defer cancel() + + time.Sleep(6 * interval) + synctest.Wait() + if got := sess.closeCalls.Load(); got != 0 { + t.Fatalf("session closed despite a success resetting the counter: closeCalls=%d (pingCalls=%d)", got, sess.pingCalls.Load()) + } + }) +} + func TestAddTool_DuplicateNoPanicAndNoDuplicate(t *testing.T) { // Adding the same tool pointer twice should not panic and should not // produce duplicates in the server's tool list. diff --git a/mcp/server.go b/mcp/server.go index b86b3e6..693bd39 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -79,6 +79,13 @@ type ServerOptions struct { // If the peer fails to respond to pings originating from the keepalive check, // the session is automatically closed. KeepAlive time.Duration + // KeepAliveFailureThreshold is the number of consecutive keepalive ping + // failures tolerated before the session is closed. A value of 0 or 1 + // closes the session on the first failure (the default). Higher values + // align with the spec's "multiple failed pings MAY trigger a connection + // reset" guidance, letting a transient miss pass without tearing down an + // otherwise live session. Has no effect unless KeepAlive is non-zero. + KeepAliveFailureThreshold int // Function called when a client session subscribes to a resource. SubscribeHandler func(context.Context, *SubscribeRequest) error // Function called when a client session unsubscribes from a resource. @@ -1605,7 +1612,7 @@ func (ss *ServerSession) Wait() error { // startKeepalive starts the keepalive mechanism for this server session. func (ss *ServerSession) startKeepalive(interval time.Duration) { - startKeepalive(ss, interval, &ss.keepaliveCancel, ss.server.opts.Logger) + startKeepalive(ss, interval, ss.server.opts.KeepAliveFailureThreshold, &ss.keepaliveCancel, ss.server.opts.Logger) } // pageToken is the internal structure for the opaque pagination cursor. diff --git a/mcp/shared.go b/mcp/shared.go index 1caacac..afa566a 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -751,9 +751,20 @@ type keepaliveSession interface { // It assigns the cancel function to the provided cancelPtr and starts a goroutine // that sends ping messages at the specified interval. // -// logger must be non-nil; ping failures (which terminate the keepalive loop and -// close the session) are reported via logger so they are not silently dropped. -func startKeepalive(session keepaliveSession, interval time.Duration, cancelPtr *context.CancelFunc, logger *slog.Logger) { +// failureThreshold is the number of consecutive ping failures tolerated before +// the session is closed; a value below 1 is treated as 1 (close on the first +// failure). A successful ping resets the counter. This mirrors the spec's +// "multiple failed pings MAY trigger a connection reset" language, letting a +// transient miss pass without tearing down an otherwise live session. +// +// logger must be non-nil; ping failures (both the tolerated ones and the final +// one that closes the session) are reported via logger so they are not silently +// dropped. +func startKeepalive(session keepaliveSession, interval time.Duration, failureThreshold int, cancelPtr *context.CancelFunc, logger *slog.Logger) { + if failureThreshold < 1 { + failureThreshold = 1 + } + ctx, cancel := context.WithCancel(context.Background()) // Assign cancel function before starting goroutine to avoid race condition. // We cannot return it because the caller may need to cancel during the @@ -764,6 +775,7 @@ func startKeepalive(session keepaliveSession, interval time.Duration, cancelPtr ticker := time.NewTicker(interval) defer ticker.Stop() + consecutiveFailures := 0 for { select { case <-ctx.Done(): @@ -772,17 +784,32 @@ func startKeepalive(session keepaliveSession, interval time.Duration, cancelPtr pingCtx, pingCancel := context.WithTimeout(context.Background(), interval/2) err := session.Ping(pingCtx, nil) pingCancel() - if err != nil { - if errors.Is(err, jsonrpc2.ErrMethodNotFound) { - // Peer doesn't support ping, stop the keepalive process. - return - } - // Ping failed; log it before closing the session so the - // failure is observable to operators. See #218. - logger.Error("keepalive ping failed; closing session", "error", err) - _ = session.Close() + if err == nil { + consecutiveFailures = 0 + continue + } + if errors.Is(err, jsonrpc2.ErrMethodNotFound) { + // Peer doesn't support ping, stop the keepalive process. return } + consecutiveFailures++ + if consecutiveFailures < failureThreshold { + // Tolerate transient failures below the threshold; log so + // the misses are still observable to operators. See #218. + logger.Warn("keepalive ping failed; tolerating below threshold", + "error", err, + "consecutiveFailures", consecutiveFailures, + "failureThreshold", failureThreshold) + continue + } + // Threshold reached; log before closing the session so the + // failure is observable to operators. See #218. + logger.Error("keepalive ping failed; closing session", + "error", err, + "consecutiveFailures", consecutiveFailures, + "failureThreshold", failureThreshold) + _ = session.Close() + return } } }() From 6d2bbff7d8534cec198ec4563f1e5dc6fa4cc78e Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Sun, 31 May 2026 16:35:46 +0800 Subject: [PATCH 290/418] fix: add implementation description metadata (#981) ## Summary - add the optional `description` field to `mcp.Implementation` - keep the field omitted when empty - cover JSON marshal/unmarshal behavior with a focused regression test Fixes #977. ## To verify - `go test ./mcp -run TestImplementationDescriptionJSON -count=1` - `go test ./mcp -run TestServerRequest_PerRequestAccessors -count=1` - `go test ./mcp -count=1` - `go test ./...` - `git diff --check` Co-authored-by: Guglielmo Colombo --- mcp/protocol.go | 10 ++++++---- mcp/shared_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/mcp/protocol.go b/mcp/protocol.go index bebdc19..f401d22 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -1935,16 +1935,18 @@ type ElicitationCompleteParams struct { func (x *ElicitationCompleteParams) isParams() {} func (x *ElicitationCompleteParams) isNil() bool { return x == nil } -// An Implementation describes the name and version of an MCP implementation, with an optional -// title for UI representation. +// An Implementation describes the name and version of an MCP implementation, with +// optional display metadata. type Implementation struct { // Intended for programmatic or logical use, but used as a display name in past // specs or fallback (if title isn't present). Name string `json:"name"` // Intended for UI and end-user contexts — optimized to be human-readable and // easily understood, even by those unfamiliar with domain-specific terminology. - Title string `json:"title,omitempty"` - Version string `json:"version"` + Title string `json:"title,omitempty"` + // A human-readable description of the implementation. + Description string `json:"description,omitempty"` + Version string `json:"version"` // WebsiteURL for the server, if any. WebsiteURL string `json:"websiteUrl,omitempty"` // Icons for the Server, if any. diff --git a/mcp/shared_test.go b/mcp/shared_test.go index e4f563d..065d00b 100644 --- a/mcp/shared_test.go +++ b/mcp/shared_test.go @@ -245,6 +245,39 @@ func TestServerRequest_PerRequestAccessors_Empty(t *testing.T) { } } +func TestImplementationDescriptionJSON(t *testing.T) { + impl := &Implementation{ + Name: "greeter", + Title: "Greeter", + Description: "Example server for greeting tools", + Version: "v1.0.0", + } + got, err := json.Marshal(impl) + if err != nil { + t.Fatal(err) + } + want := `{"name":"greeter","title":"Greeter","description":"Example server for greeting tools","version":"v1.0.0"}` + if string(got) != want { + t.Fatalf("Implementation JSON = %s, want %s", got, want) + } + + var roundTrip Implementation + if err := json.Unmarshal(got, &roundTrip); err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(impl, &roundTrip); diff != "" { + t.Fatalf("Implementation round trip mismatch (-want +got):\n%s", diff) + } + + got, err = json.Marshal(&Implementation{Name: "greeter", Version: "v1.0.0"}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(got), "description") { + t.Fatalf("empty description should be omitted, got %s", 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. From ad054d3c5e3edb77f3034230455d8d75eb57e105 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:48:53 +0200 Subject: [PATCH 291/418] build(deps): bump github/codeql-action from 4.35.2 to 4.36.0 (#986) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.2 to 4.36.0.
Release notes

Sourced from github/codeql-action's releases.

v4.36.0

  • Breaking change: Bump the minimum required CodeQL bundle version to 2.19.4. #3894
  • Add support for SHA-256 Git object IDs. #3893
  • Update default CodeQL bundle version to 2.25.5. #3926

v4.35.5

  • We have improved how the JavaScript bundles for the CodeQL Action are generated to avoid duplication across bundles and reduce the size of the repository by around 70%. This should have no effect on the runtime behaviour of the CodeQL Action. #3899
  • For performance and accuracy reasons, improved incremental analysis will now only be enabled on a pull request when diff-informed analysis is also enabled for that run. If diff-informed analysis is unavailable (for example, because the PR diff ranges could not be computed), the action will fall back to a full analysis. #3791
  • If multiple inputs are provided for the GitHub-internal analysis-kinds input, only code-scanning will be enabled. The analysis-kinds input is experimental, for GitHub-internal use only, and may change without notice at any time. #3892
  • Added an experimental change which, when running a Code Scanning analysis for a PR with improved incremental analysis enabled, prefers CodeQL CLI versions that have a cached overlay-base database for the configured languages. This speeds up analysis for a repository when there is not yet a cached overlay-base database for the latest CLI version. We expect to roll this change out to everyone in May. #3880

v4.35.4

  • Update default CodeQL bundle version to 2.25.4. #3881

v4.35.3

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.19.3 and earlier. These versions of CodeQL were discontinued on 9 April 2026 alongside GitHub Enterprise Server 3.15, and will be unsupported by the next minor release of the CodeQL Action. #3837
  • Configurations for private registries that use Cloudsmith or GCP OIDC are now accepted. #3850
  • Best-effort connection tests for private registries now use GET requests instead of HEAD for better compatibility with various registry implementations. For NuGet feeds, the test is now always performed against the service index. #3853
  • Fixed a bug where two diagnostics produced within the same millisecond could overwrite each other on disk, causing one of them to be lost. #3852
  • Update default CodeQL bundle version to 2.25.3. #3865
Changelog

Sourced from github/codeql-action's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

No user facing changes.

4.36.0 - 22 May 2026

  • Breaking change: Bump the minimum required CodeQL bundle version to 2.19.4. #3894
  • Add support for SHA-256 Git object IDs. #3893
  • Update default CodeQL bundle version to 2.25.5. #3926

4.35.5 - 15 May 2026

  • We have improved how the JavaScript bundles for the CodeQL Action are generated to avoid duplication across bundles and reduce the size of the repository by around 70%. This should have no effect on the runtime behaviour of the CodeQL Action. #3899
  • For performance and accuracy reasons, improved incremental analysis will now only be enabled on a pull request when diff-informed analysis is also enabled for that run. If diff-informed analysis is unavailable (for example, because the PR diff ranges could not be computed), the action will fall back to a full analysis. #3791
  • If multiple inputs are provided for the GitHub-internal analysis-kinds input, only code-scanning will be enabled. The analysis-kinds input is experimental, for GitHub-internal use only, and may change without notice at any time. #3892
  • Added an experimental change which, when running a Code Scanning analysis for a PR with improved incremental analysis enabled, prefers CodeQL CLI versions that have a cached overlay-base database for the configured languages. This speeds up analysis for a repository when there is not yet a cached overlay-base database for the latest CLI version. We expect to roll this change out to everyone in May. #3880

4.35.4 - 07 May 2026

  • Update default CodeQL bundle version to 2.25.4. #3881

4.35.3 - 01 May 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.19.3 and earlier. These versions of CodeQL were discontinued on 9 April 2026 alongside GitHub Enterprise Server 3.15, and will be unsupported by the next minor release of the CodeQL Action. #3837
  • Configurations for private registries that use Cloudsmith or GCP OIDC are now accepted. #3850
  • Best-effort connection tests for private registries now use GET requests instead of HEAD for better compatibility with various registry implementations. For NuGet feeds, the test is now always performed against the service index. #3853
  • Fixed a bug where two diagnostics produced within the same millisecond could overwrite each other on disk, causing one of them to be lost. #3852
  • Update default CodeQL bundle version to 2.25.3. #3865

4.35.2 - 15 Apr 2026

  • The undocumented TRAP cache cleanup feature that could be enabled using the CODEQL_ACTION_CLEANUP_TRAP_CACHES environment variable is deprecated and will be removed in May 2026. If you are affected by this, we recommend disabling TRAP caching by passing the trap-caching: false input to the init Action. #3795
  • The Git version 2.36.0 requirement for improved incremental analysis now only applies to repositories that contain submodules. #3789
  • Python analysis on GHES no longer extracts the standard library, relying instead on models of the standard library. This should result in significantly faster extraction and analysis times, while the effect on alerts should be minimal. #3794
  • Fixed a bug in the validation of OIDC configurations for private registries that was added in CodeQL Action 4.33.0 / 3.33.0. #3807
  • Update default CodeQL bundle version to 2.25.2. #3823

4.35.1 - 27 Mar 2026

4.35.0 - 27 Mar 2026

... (truncated)

Commits
  • 7211b7c Merge pull request #3927 from github/update-v4.36.0-ebc2d9e2b
  • 7740f2f Update changelog for v4.36.0
  • ebc2d9e Merge pull request #3926 from github/update-bundle/codeql-bundle-v2.25.5
  • d1f74b7 Add changelog note
  • 2dc40ce Update default bundle to codeql-bundle-v2.25.5
  • 8449852 Merge pull request #3910 from github/henrymercer/repo-size-diff-check
  • 72ac23c Update excluded required check list
  • c5297a2 Merge pull request #3919 from github/henrymercer/workflow-concurrency
  • 8ffeae7 CI: Automatically cancel non-generated workflows
  • f3f52bf Revert getErrorMessage import
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github/codeql-action&package-manager=github_actions&previous-version=4.35.2&new-version=4.36.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- .github/workflows/scorecard.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4aa9179..ca0d732 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -38,11 +38,11 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Initialize CodeQL - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index bb8b659..c8035cb 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -73,6 +73,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: sarif_file: results.sarif From d54751a8eec734558a36560faa1e19e9c966e5ee Mon Sep 17 00:00:00 2001 From: BorisTyshkevich <125603338+BorisTyshkevich@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:12:44 +0200 Subject: [PATCH 292/418] auth: add AllowMissingExpiration option to RequireBearerTokenOptions (#971) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add an `AllowMissingExpiration bool` field to `RequireBearerTokenOptions` that opts the middleware out of the existing `tokenInfo.Expiration.IsZero()` reject. - Default false preserves the existing strict behaviour: every TokenInfo must carry an Expiration. ## Motivation Some IdPs emit session-bound bearer tokens that do not carry a standalone `exp` claim — the token's lifetime is bounded by an external session and is not advertised in-band. Resource servers integrating with such IdPs today have to synthesise a fake Expiration from their TokenVerifier callback (dishonest data through the auth layer) or fork `RequireBearerToken` to bypass the check. This option lets the verifier honestly report "no expiration in the token" via a zero `Expiration` and have the middleware accept it — while still applying the elapsed-validity check for tokens that DO carry an Expiration. Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Guglielmo Colombo --- auth/auth.go | 22 +++++++++++++++++++--- auth/auth_test.go | 5 +++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/auth/auth.go b/auth/auth.go index 40fa259..f205870 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -47,6 +47,21 @@ type RequireBearerTokenOptions struct { ResourceMetadataURL string // The required scopes. Scopes []string + // AllowMissingExpiration opts the middleware out of the + // `tokenInfo.Expiration.IsZero()` reject. Default false preserves the + // existing strict behaviour (every TokenInfo must carry an Expiration). + // + // Some IdPs emit session-bound bearer tokens that do not carry a standalone + // `exp` claim — the token's lifetime is bounded by an external session and + // is not advertised in-band. Resource servers integrating with such IdPs + // need to opt in to validating the rest of the token (scopes, signature + // via the verifier callback, etc.) without requiring the expiration field + // to be present. + // + // When enabled, the verifier is still responsible for any session-level + // validity check it can perform; this option only relaxes the middleware's + // own expiration enforcement. + AllowMissingExpiration bool } type tokenInfoKey struct{} @@ -131,9 +146,10 @@ func verify(req *http.Request, verifier TokenVerifier, opts *RequireBearerTokenO // Check expiration. if tokenInfo.Expiration.IsZero() { - return nil, "token missing expiration", http.StatusUnauthorized - } - if tokenInfo.Expiration.Before(time.Now()) { + if opts == nil || !opts.AllowMissingExpiration { + return nil, "token missing expiration", http.StatusUnauthorized + } + } else if tokenInfo.Expiration.Before(time.Now()) { return nil, "token expired", http.StatusUnauthorized } return tokenInfo, "", 0 diff --git a/auth/auth_test.go b/auth/auth_test.go index 4028c90..fe523a1 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -62,6 +62,11 @@ func TestVerify(t *testing.T) { "no expiration", nil, "Bearer noexp", "token missing expiration", 401, }, + { + "no expiration with AllowMissingExpiration accepts", + &RequireBearerTokenOptions{AllowMissingExpiration: true}, "Bearer noexp", + "", 0, + }, { "expired", nil, "Bearer expired", "token expired", 401, From c60a318d223cb6a9d0c25b04963b0e9ea54ff6ae Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Tue, 2 Jun 2026 14:17:01 +0200 Subject: [PATCH 293/418] mcp: Implement support for SEP-2575 on client side (#975) Implements the client half of the SEP-2575 / `2026-06-30` protocol. This CL is client-only; server-side `server/discover` is still a `MethodNotFound` stub. ### Changes - **`server/discover` probe in `Client.Connect()`** (`client.go`). When the caller opts into `protocolVersion >= 2026-06-30`, the client probes the server with `server/discover` before falling back to the legacy `initialize` handshake. - **Per-request `_meta` injection** (`shared.go`, `injectRequestMeta`). For`>= 2026-06-30` sessions every outgoing request gets the SEP-2575 triple (`protocolVersion`, `clientInfo`, `clientCapabilities`). - **Server-side fix**: `ServerSession.handle` no longer pre-populates `state.InitializeParams` from a `server/discover` request's `_meta`, so a subsequent fallback `initialize` is not rejected as a duplicate. ### Fallback rules Client falls back to the legacy `initialize` handshake when discover returns: - `MethodNotFound` (-32601), - `UnsupportedProtocolVersionError` (-32004), - an HTTP `Bad Request` (substring-matched on the error message, to interop with pre-2026-06-30 servers). Fixes #966 --- internal/jsonrpc2/conn.go | 2 +- internal/jsonrpc2/wire.go | 4 +- mcp/client.go | 142 ++++++++++++ mcp/client_test.go | 224 ++++++++++++++++++ mcp/mrtr_test.go | 22 ++ mcp/protocol.go | 24 ++ mcp/requests.go | 1 + mcp/server.go | 23 +- mcp/shared.go | 4 + mcp/streamable.go | 33 ++- mcp/streamable_client_test.go | 423 ++++++++++++++++++++++++++++++++++ mcp/streamable_test.go | 204 +++++++++------- 12 files changed, 1010 insertions(+), 96 deletions(-) diff --git a/internal/jsonrpc2/conn.go b/internal/jsonrpc2/conn.go index 72cc740..df6ef5e 100644 --- a/internal/jsonrpc2/conn.go +++ b/internal/jsonrpc2/conn.go @@ -720,7 +720,7 @@ func (c *Connection) write(ctx context.Context, msg Message) error { // For cancelled or rejected requests, we don't set the writeErr (which would // break the connection). They can just be returned to the caller. - if err != nil && ctx.Err() == nil && !errors.Is(err, ErrRejected) { + if err != nil && ctx.Err() == nil && !errors.Is(err, ErrRejected) && !errors.Is(err, ErrUnsupportedProtocolVersion) && !errors.Is(err, ErrMethodNotFound) { // The call to Write failed, and since ctx.Err() is nil we can't attribute // the failure (even indirectly) to Context cancellation. The writer appears // to be broken, and future writes are likely to also fail. diff --git a/internal/jsonrpc2/wire.go b/internal/jsonrpc2/wire.go index 4d123f2..b0beae0 100644 --- a/internal/jsonrpc2/wire.go +++ b/internal/jsonrpc2/wire.go @@ -31,7 +31,7 @@ var ( // ErrUnknown should be used for all non coded errors. ErrUnknown = NewError(-32001, "unknown error") // ErrServerClosing is returned for calls that arrive while the server is closing. - ErrServerClosing = NewError(-32004, "server is closing") + ErrServerClosing = NewError(-32006, "server is closing") // ErrClientClosing is a dummy error returned for calls initiated while the client is closing. ErrClientClosing = NewError(-32003, "client is closing") @@ -45,6 +45,8 @@ var ( // should be returned to the caller to indicate that the specific request is // invalid in the current context. ErrRejected = NewError(-32005, "rejected by transport") + // ErrUnsupportedProtocolVersion is returned when a server does not support the protocol version. + ErrUnsupportedProtocolVersion = NewError(-32004, "unsupported protocol version") ) const wireVersion = "2.0" diff --git a/mcp/client.go b/mcp/client.go index 979172b..5f142fb 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -165,6 +165,7 @@ type ClientOptions struct { // If non-zero, defines an interval for regular "ping" requests. // If the peer fails to respond to pings originating from the keepalive check, // the session is automatically closed. + // NOTE: The keepalive feature is only available for protocol versions < 2026-06-30 KeepAlive time.Duration // KeepAliveFailureThreshold is the number of consecutive keepalive ping // failures tolerated before the session is closed. A value of 0 or 1 @@ -283,6 +284,27 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp if opts != nil && opts.protocolVersion != "" { protocolVersion = opts.protocolVersion } + + if protocolVersion >= protocolVersion20260630 { + // Per SEP-2575, try the stateless server/discover RPC first. If the server + // signals it doesn't support it, fall back to the legacy initialize + // handshake. + discoverCtx := context.WithValue(ctx, protocolVersionContextKey{}, protocolVersion) + discRes, fallback, err := c.discover(discoverCtx, cs) + if err != nil { + return nil, err + } + if !fallback { + cs.state.InitializeResult = discRes + if hc, ok := cs.mcpConn.(clientConnection); ok { + hc.sessionUpdated(cs.state) + } + return cs, nil + } + // Fallback to the legacy initialize handshake. + protocolVersion = protocolVersion20251125 + } + params := &InitializeParams{ ProtocolVersion: protocolVersion, ClientInfo: c.impl, @@ -314,6 +336,66 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp return cs, nil } +// discover sends a SEP-2575 server/discover request to probe the server for +// stateless protocol support. +// +// The return values have three possible combinations: +// - (result, false, nil): discovery succeeded; caller should skip legacy initialization. +// - (nil, true, nil): the server explicitly signaled it doesn't support +// discovery (Method not found, or UnsupportedProtocolVersionError, or version mismatch); +// caller should fall back to the legacy initialize handshake. +// - (nil, false, err): any other failure (transport error, malformed response, etc.); +// caller should propagate the error. +func (c *Client) discover(ctx context.Context, cs *ClientSession) (*InitializeResult, bool, error) { + protocolVersion := protocolVersionFromContext(ctx) + caps := c.capabilities(protocolVersion) + params := &DiscoverParams{ + Meta: Meta{ + MetaKeyProtocolVersion: protocolVersion, + MetaKeyClientInfo: c.impl, + MetaKeyClientCapabilities: caps, + }, + } + req := &DiscoverRequest{Session: cs, Params: params} + res, err := handleSend[*DiscoverResult](ctx, methodDiscover, req) + if err != nil { + // According to SEP-2575, only the two signals below (MethodNotFound + // and UnsupportedProtocolVersionError) should trigger a fallback. + var werr *jsonrpc.Error + if errors.As(err, &werr) && (werr.Code == jsonrpc.CodeMethodNotFound || werr.Code == CodeUnsupportedProtocolVersion) { + return nil, true, nil + } + return nil, false, err + } + + // Pick the highest protocol version that both the server and this SDK support. + // Since supportedProtocolVersions is defined in descending order (newest to oldest), + // the first match we find is the highest supported version. + var negotiated string + if slices.Contains(res.SupportedVersions, protocolVersion) { + negotiated = protocolVersion + } else { + for _, v := range supportedProtocolVersions { + if slices.Contains(res.SupportedVersions, v) { + negotiated = v + break + } + } + } + if negotiated == "" || negotiated < protocolVersion20260630 { + // If there is no overlap, fall back to initialize so version + // negotiation can happen via the legacy path. + return nil, true, nil + } + + return &InitializeResult{ + Capabilities: res.Capabilities, + Instructions: res.Instructions, + ProtocolVersion: negotiated, + ServerInfo: res.ServerInfo, + }, false, nil +} + // A ClientSession is a logical connection with an MCP server. Its // methods can be used to send requests or notifications to the server. Create // a session by calling [Client.Connect]. @@ -354,6 +436,42 @@ type clientSessionState struct { func (cs *ClientSession) InitializeResult() *InitializeResult { return cs.state.InitializeResult } +// usesNewProtocol reports whether this session has negotiated a protocol +// version >= 2026-06-30, which requires the SEP-2575 per-request `_meta` +// triple on every outgoing request. +func (cs *ClientSession) usesNewProtocol() bool { + res := cs.state.InitializeResult + return res != nil && res.ProtocolVersion >= protocolVersion20260630 +} + +// injectRequestMeta populates the SEP-2575 per-request `_meta` triple +// (protocolVersion, clientInfo, clientCapabilities) on the given outgoing +// request params. Keys already present in params.Meta are not overwritten. +func injectRequestMeta[T any, P interface { + *T + Params +}](cs *ClientSession, params P) P { + res := cs.state.InitializeResult + if params.isNil() { + params = new(T) + } + m := params.GetMeta() + if m == nil { + m = map[string]any{} + } + if _, ok := m[MetaKeyProtocolVersion]; !ok { + m[MetaKeyProtocolVersion] = res.ProtocolVersion + } + if _, ok := m[MetaKeyClientInfo]; !ok { + m[MetaKeyClientInfo] = cs.client.impl + } + if _, ok := m[MetaKeyClientCapabilities]; !ok { + m[MetaKeyClientCapabilities] = cs.client.capabilities(res.ProtocolVersion) + } + params.SetMeta(m) + return params +} + func (cs *ClientSession) ID() string { if c, ok := cs.mcpConn.(hasSessionID); ok { return c.SessionID() @@ -1014,16 +1132,25 @@ func (cs *ClientSession) Ping(ctx context.Context, params *PingParams) error { // ListPrompts lists prompts that are currently available on the server. func (cs *ClientSession) ListPrompts(ctx context.Context, params *ListPromptsParams) (*ListPromptsResult, error) { + if cs.usesNewProtocol() { + params = injectRequestMeta(cs, params) + } return handleSend[*ListPromptsResult](ctx, methodListPrompts, newClientRequest(cs, orZero[Params](params))) } // GetPrompt gets a prompt from the server. func (cs *ClientSession) GetPrompt(ctx context.Context, params *GetPromptParams) (*GetPromptResult, error) { + if cs.usesNewProtocol() { + params = injectRequestMeta(cs, params) + } return handleSend[*GetPromptResult](ctx, methodGetPrompt, newClientRequest(cs, orZero[Params](params))) } // ListTools lists tools that are currently available on the server. func (cs *ClientSession) ListTools(ctx context.Context, params *ListToolsParams) (*ListToolsResult, error) { + if cs.usesNewProtocol() { + params = injectRequestMeta(cs, params) + } result, err := handleSend[*ListToolsResult](ctx, methodListTools, newClientRequest(cs, orZero[Params](params))) if err != nil { return nil, err @@ -1047,6 +1174,9 @@ func (cs *ClientSession) CallTool(ctx context.Context, params *CallToolParams) ( if tool := cs.getCachedTool(params.Name); tool != nil { ctx = context.WithValue(ctx, toolContextKey, tool) } + if cs.usesNewProtocol() { + params = injectRequestMeta(cs, params) + } return handleSend[*CallToolResult](ctx, methodCallTool, newClientRequest(cs, orZero[Params](params))) } @@ -1057,20 +1187,32 @@ func (cs *ClientSession) SetLoggingLevel(ctx context.Context, params *SetLogging // ListResources lists the resources that are currently available on the server. func (cs *ClientSession) ListResources(ctx context.Context, params *ListResourcesParams) (*ListResourcesResult, error) { + if cs.usesNewProtocol() { + params = injectRequestMeta(cs, params) + } return handleSend[*ListResourcesResult](ctx, methodListResources, newClientRequest(cs, orZero[Params](params))) } // ListResourceTemplates lists the resource templates that are currently available on the server. func (cs *ClientSession) ListResourceTemplates(ctx context.Context, params *ListResourceTemplatesParams) (*ListResourceTemplatesResult, error) { + if cs.usesNewProtocol() { + params = injectRequestMeta(cs, params) + } return handleSend[*ListResourceTemplatesResult](ctx, methodListResourceTemplates, newClientRequest(cs, orZero[Params](params))) } // ReadResource asks the server to read a resource and return its contents. func (cs *ClientSession) ReadResource(ctx context.Context, params *ReadResourceParams) (*ReadResourceResult, error) { + if cs.usesNewProtocol() { + params = injectRequestMeta(cs, params) + } return handleSend[*ReadResourceResult](ctx, methodReadResource, newClientRequest(cs, orZero[Params](params))) } func (cs *ClientSession) Complete(ctx context.Context, params *CompleteParams) (*CompleteResult, error) { + if cs.usesNewProtocol() { + params = injectRequestMeta(cs, params) + } return handleSend[*CompleteResult](ctx, methodComplete, newClientRequest(cs, orZero[Params](params))) } diff --git a/mcp/client_test.go b/mcp/client_test.go index 609fd50..1d62952 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -8,11 +8,14 @@ import ( "context" "fmt" "log/slog" + "sync/atomic" "testing" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) type Item struct { @@ -617,3 +620,224 @@ func TestClientCapabilitiesOverWire(t *testing.T) { }) } } + +// TestClientConnectDiscover exercises the SEP-2575 server/discover probe that +// Client.Connect now sends before falling back to the legacy initialize +// handshake. +// +// Each subtest installs a server-side receiving middleware that intercepts the +// "server/discover" method and returns a canned response: a successful +// DiscoverResult, a "Method not found" error, an UnsupportedProtocolVersion +// error, an unrelated failure, or a DiscoverResult whose supportedVersions +// don't overlap with the SDK. The test then asserts the resulting session +// state and whether the legacy initialize handshake ran. +func TestClientConnectDiscover(t *testing.T) { + // Temporarily enable 2026-06-30 support in the SDK for this test + oldSupported := supportedProtocolVersions + supportedProtocolVersions = append([]string{protocolVersion20260630}, supportedProtocolVersions...) + t.Cleanup(func() { + supportedProtocolVersions = oldSupported + }) + + const otherVersionsOnly = "1999-01-01" + + tests := []struct { + name string + // discoverHandler decides how the server replies to server/discover. + // Returning (nil, nil) means "let the default stub handle it" (which + // returns ErrMethodNotFound). + discoverHandler func() (Result, error) + wantConnectErr bool + // wantInitialize is true if the legacy initialize handshake should + // have run (i.e. discover signaled "not supported"). + wantInitialize bool + // wantVersion is the protocol version expected to end up on + // ClientSession.state.InitializeResult after Connect returns. + wantVersion string + }{ + { + name: "discover success skips initialize", + discoverHandler: func() (Result, error) { + return &DiscoverResult{ + SupportedVersions: []string{protocolVersion20260630}, + Capabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: true}, + }, + ServerInfo: &Implementation{Name: "discoverServer", Version: "v1.0.0"}, + }, nil + }, + wantInitialize: false, + wantVersion: protocolVersion20260630, + }, + { + name: "method not found falls back to initialize", + discoverHandler: func() (Result, error) { + return nil, jsonrpc2.ErrMethodNotFound + }, + wantInitialize: true, + wantVersion: latestProtocolVersion, + }, + { + name: "no overlapping supported version falls back to initialize", + discoverHandler: func() (Result, error) { + return &DiscoverResult{ + SupportedVersions: []string{otherVersionsOnly}, + Capabilities: &ServerCapabilities{}, + ServerInfo: &Implementation{Name: "discoverServer", Version: "v1.0.0"}, + }, nil + }, + wantInitialize: true, + wantVersion: latestProtocolVersion, + }, + { + name: "unexpected error propagates and aborts Connect", + discoverHandler: func() (Result, error) { + return nil, &jsonrpc.Error{ + Code: jsonrpc.CodeInternalError, + Message: "boom", + } + }, + wantConnectErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + + var ( + gotDiscover atomic.Bool + gotInitialize atomic.Bool + ) + + s := NewServer(testImpl, nil) + s.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + switch method { + case methodDiscover: + gotDiscover.Store(true) + return tc.discoverHandler() + case methodInitialize: + gotInitialize.Store(true) + } + return next(ctx, method, req) + } + }) + + ct, st := NewInMemoryTransports() + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server Connect: %v", err) + } + defer ss.Close() + + c := NewClient(testImpl, nil) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + if tc.wantConnectErr { + if err == nil { + _ = cs.Close() + t.Fatal("Connect succeeded, want error") + } + if !gotDiscover.Load() { + t.Error("server did not receive server/discover") + } + if gotInitialize.Load() { + t.Error("server received initialize but discover should have aborted Connect") + } + return + } + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer cs.Close() + + if !gotDiscover.Load() { + t.Error("server did not receive server/discover") + } + if got, want := gotInitialize.Load(), tc.wantInitialize; got != want { + t.Errorf("initialize invoked = %v, want %v", got, want) + } + ir := cs.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil after Connect") + } + if got, want := ir.ProtocolVersion, tc.wantVersion; got != want { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q", got, want) + } + }) + } +} + +// TestClientConnectDiscover_RequestContents verifies that the server/discover +// request sent by Client.Connect carries the SEP-2575 per-request _meta triple: +// protocolVersion, clientInfo, and clientCapabilities. +func TestClientConnectDiscover_RequestContents(t *testing.T) { + ctx := context.Background() + + type captured struct { + params *DiscoverParams + } + var got captured + + s := NewServer(testImpl, nil) + s.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == methodDiscover { + sr, ok := req.(*ServerRequest[*DiscoverParams]) + if !ok { + t.Errorf("discover req has unexpected type %T", req) + return nil, jsonrpc2.ErrMethodNotFound + } + got.params = sr.Params + // Make discover "not supported" so Connect proceeds (we only + // care about the discover request payload here). + return nil, jsonrpc2.ErrMethodNotFound + } + return next(ctx, method, req) + } + }) + + ct, st := NewInMemoryTransports() + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server Connect: %v", err) + } + defer ss.Close() + + clientImpl := &Implementation{Name: "discover-probe-client", Version: "v9.9.9"} + c := NewClient(clientImpl, &ClientOptions{ + CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) { + return nil, nil + }, + }) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer cs.Close() + + if got.params == nil { + t.Fatal("server did not receive server/discover") + } + + meta := got.params.GetMeta() + if v, _ := meta[MetaKeyProtocolVersion].(string); v != protocolVersion20260630 { + t.Errorf("_meta[%s] = %q, want %q", MetaKeyProtocolVersion, v, protocolVersion20260630) + } + // _meta values round-trip through JSON, so on the server side they + // arrive as map[string]any rather than the typed Go pointers we sent. + info, ok := meta[MetaKeyClientInfo].(map[string]any) + if !ok { + t.Fatalf("_meta[%s] = %T, want map[string]any", MetaKeyClientInfo, meta[MetaKeyClientInfo]) + } + if got, want := info["name"], any(clientImpl.Name); got != want { + t.Errorf("clientInfo.name = %v, want %v", got, want) + } + caps, ok := meta[MetaKeyClientCapabilities].(map[string]any) + if !ok { + t.Fatalf("_meta[%s] = %T, want map[string]any", MetaKeyClientCapabilities, meta[MetaKeyClientCapabilities]) + } + if _, ok := caps["sampling"]; !ok { + t.Errorf("clientCapabilities.sampling missing (CreateMessageHandler was set); got %v", caps) + } +} diff --git a/mcp/mrtr_test.go b/mcp/mrtr_test.go index 12eba7e..e1e61b2 100644 --- a/mcp/mrtr_test.go +++ b/mcp/mrtr_test.go @@ -525,6 +525,28 @@ func TestMultiRoundTrip_ReadResource_ManualRetry(t *testing.T) { func mustConnect(t *testing.T, s *Server, clientOpts *ClientOptions) *ClientSession { t.Helper() + + // The mrtr tests require negotiating the 2026-06-30 protocol version. + // Server.discover is currently a stub that returns ErrMethodNotFound, which + // would cause Client.Connect to fall back to the legacy initialize handshake + // and downgrade the negotiated version. Install a receiving middleware that + // answers server/discover with a DiscoverResult advertising 2026-06-30 so + // the client can negotiate the new protocol via the discover path. + // + // TODO: Remove this once the server has a proper discover implementation. + s.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == methodDiscover { + return &DiscoverResult{ + SupportedVersions: []string{protocolVersion20260630}, + Capabilities: &ServerCapabilities{}, + ServerInfo: testImpl, + }, nil + } + return next(ctx, method, req) + } + }) + st, ct := NewInMemoryTransports() ss, err := s.Connect(t.Context(), st, nil) if err != nil { diff --git a/mcp/protocol.go b/mcp/protocol.go index f401d22..f5b79ae 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -1071,6 +1071,29 @@ type ListPromptsParams struct { Cursor string `json:"cursor,omitempty"` } +type DiscoverParams struct { + Meta `json:"_meta,omitempty"` +} + +func (x *DiscoverParams) isParams() {} +func (x *DiscoverParams) isNil() bool { return x == nil } +func (x *DiscoverParams) GetProgressToken() any { return getProgressToken(x) } +func (x *DiscoverParams) SetProgressToken(t any) { setProgressToken(x, t) } + +type DiscoverResult struct { + Meta `json:"_meta,omitempty"` + // The versions of the Model Context Protocol that the server supports. + SupportedVersions []string `json:"supportedVersions"` + // The server's capabilities. + Capabilities *ServerCapabilities `json:"capabilities"` + // Information about the server implementation. + ServerInfo *Implementation `json:"serverInfo"` + // Instructions describing how to use the server and its features. + Instructions string `json:"instructions,omitempty"` +} + +func (*DiscoverResult) isResult() {} + func (x *ListPromptsParams) isParams() {} func (x *ListPromptsParams) isNil() bool { return x == nil } func (x *ListPromptsParams) GetProgressToken() any { return getProgressToken(x) } @@ -2041,6 +2064,7 @@ const ( methodCallTool = "tools/call" notificationCancelled = "notifications/cancelled" methodComplete = "completion/complete" + methodDiscover = "server/discover" methodCreateMessage = "sampling/createMessage" methodElicit = "elicitation/create" notificationElicitationComplete = "notifications/elicitation/complete" diff --git a/mcp/requests.go b/mcp/requests.go index 4280941..36368c9 100644 --- a/mcp/requests.go +++ b/mcp/requests.go @@ -25,6 +25,7 @@ type ( type ( CreateMessageRequest = ClientRequest[*CreateMessageParams] CreateMessageWithToolsRequest = ClientRequest[*CreateMessageWithToolsParams] + DiscoverRequest = ClientRequest[*DiscoverParams] ElicitRequest = ClientRequest[*ElicitParams] initializedClientRequest = ClientRequest[*InitializedParams] InitializeRequest = ClientRequest[*InitializeParams] diff --git a/mcp/server.go b/mcp/server.go index 693bd39..ba03fbf 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -764,6 +764,13 @@ func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetProm return res, err } +// discover is the server-side handler for the SEP-2575 "server/discover" RPC. +// +// TODO: Complete implementation. +func (s *Server) discover(context.Context, *ServerRequest[*DiscoverParams]) (*DiscoverResult, error) { + return nil, jsonrpc2.ErrMethodNotFound +} + func (s *Server) listTools(_ context.Context, req *ListToolsRequest) (*ListToolsResult, error) { s.mu.Lock() defer s.mu.Unlock() @@ -1416,6 +1423,7 @@ func (s *Server) AddReceivingMiddleware(middleware ...Middleware) { // curating these method flags. var serverMethodInfos = map[string]methodInfo{ methodComplete: newServerMethodInfo(serverMethod((*Server).complete), 0), + methodDiscover: newServerMethodInfo(serverMethod((*Server).discover), missingParamsOK), methodInitialize: initializeMethodInfo(), methodPing: newServerMethodInfo(serverSessionMethod((*ServerSession).ping), missingParamsOK), methodListPrompts: newServerMethodInfo(serverMethod((*Server).listPrompts), missingParamsOK), @@ -1489,12 +1497,6 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, return nil, perRequestErr } - if !initialized && validatedMeta.usesNewProtocol && validatedMeta.initializeParams != nil { - ss.updateState(func(state *ServerSessionState) { - state.InitializeParams = validatedMeta.initializeParams - }) - } - switch req.Method { case methodInitialize, methodPing, notificationInitialized: if validatedMeta.usesNewProtocol { @@ -1504,11 +1506,20 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, Message: fmt.Sprintf("%q is not supported in the new protocol", req.Method), } } + case methodDiscover: + // In case of methodDiscover call the state.initializeParams is populated + // within the discover handle function to make sure the method is supported + // when the user is probing a pre-2026-06-30 server. default: if !initialized && !validatedMeta.usesNewProtocol { 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) } + if !initialized && validatedMeta.usesNewProtocol && validatedMeta.initializeParams != nil { + ss.updateState(func(state *ServerSessionState) { + state.InitializeParams = validatedMeta.initializeParams + }) + } } // modelcontextprotocol/go-sdk#26: handle calls asynchronously, and diff --git a/mcp/shared.go b/mcp/shared.go index afa566a..a0232d3 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -105,6 +105,7 @@ func defaultSendingMethodHandler(ctx context.Context, method string, req Request // capabilities, so any panic here is a bug. params = initParams.toV2() } + // Notifications don't have results. if strings.HasPrefix(method, "notifications/") { return nil, req.GetSession().getConn().Notify(ctx, method, params) @@ -344,6 +345,9 @@ func clientSessionMethod[P Params, R Result](f func(*ClientSession, context.Cont // MCP-specific error codes. const ( + // CodeUnsupportedProtocolVersion is the JSON-RPC error code defined by + // SEP-2575 for UnsupportedProtocolVersionError. + CodeUnsupportedProtocolVersion = -32004 // CodeHeaderMismatch indicates that HTTP headers do not match the corresponding values // in the request body, or that required headers are missing or malformed. CodeHeaderMismatch = -32001 diff --git a/mcp/streamable.go b/mcp/streamable.go index 0f4e65b..e6a9bfe 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1844,6 +1844,13 @@ func (c *streamableClientConn) sessionUpdated(state clientSessionState) { c.initializedResult = state.InitializeResult c.mu.Unlock() + // When the protocol version is >= 2026-06-30, the standalone HTTP GET + // SSE stream is removed. + if state.InitializeResult == nil || + state.InitializeResult.ProtocolVersion >= protocolVersion20260630 { + return + } + // Start the standalone SSE stream as soon as we have the initialized // result, if continuous listening is enabled. // @@ -1903,7 +1910,7 @@ func (c *streamableClientConn) connectStandaloneSSE() { return } summary := "standalone SSE stream" - if err := c.checkResponse(summary, resp); err != nil { + if err := c.checkResponse(c.ctx, summary, resp); err != nil { c.fail(err) return } @@ -2046,10 +2053,11 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e } } - if err := c.checkResponse(requestSummary, resp); err != nil { + if err := c.checkResponse(ctx, requestSummary, resp); err != nil { // Only fail the connection for non-transient errors. // Transient errors (wrapped with ErrRejected) should not break the connection. - if !errors.Is(err, jsonrpc2.ErrRejected) { + // ErrMethodNotFound and ErrUnsupportedProtocolVersion should not break the connection as they trigger the initialize fallback. + if !errors.Is(err, jsonrpc2.ErrRejected) && !errors.Is(err, jsonrpc2.ErrMethodNotFound) && !errors.Is(err, jsonrpc2.ErrUnsupportedProtocolVersion) { c.fail(err) } return err @@ -2143,6 +2151,8 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request) error { } if c.initializedResult != nil { req.Header.Set(protocolVersionHeader, c.initializedResult.ProtocolVersion) + } else if v := protocolVersionFromContext(req.Context()); v != "" { + req.Header.Set(protocolVersionHeader, v) } if c.sessionID != "" { req.Header.Set(sessionIDHeader, c.sessionID) @@ -2228,7 +2238,7 @@ func (c *streamableClientConn) handleSSE(ctx context.Context, requestSummary str } resp = newResp - if err := c.checkResponse(requestSummary, resp); err != nil { + if err := c.checkResponse(ctx, requestSummary, resp); err != nil { c.fail(err) return } @@ -2239,7 +2249,7 @@ func (c *streamableClientConn) handleSSE(ctx context.Context, requestSummary str // translates it into an error if the request was unsuccessful. // // The response body is close if a non-nil error is returned. -func (c *streamableClientConn) checkResponse(requestSummary string, resp *http.Response) (err error) { +func (c *streamableClientConn) checkResponse(ctx context.Context, requestSummary string, resp *http.Response) (err error) { defer func() { if err != nil { resp.Body.Close() @@ -2259,6 +2269,19 @@ func (c *streamableClientConn) checkResponse(requestSummary string, resp *http.R return fmt.Errorf("%w: %s: %v", jsonrpc2.ErrRejected, requestSummary, http.StatusText(resp.StatusCode)) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { + // Read the body and if we can detect vPre servers that + // reject "server/discover" as unsupported method with a plain HTTP 400, + // then return jsonrpc2.ErrMethodNotFound or jsonrpc2.ErrUnsupportedProtocolVersion to trigger the fallback. + protocolVersion := protocolVersionFromContext(ctx) + if protocolVersion != "" && protocolVersion >= protocolVersion20260630 { + body, _ := io.ReadAll(resp.Body) + if strings.Contains(string(body), fmt.Sprintf("%s: %q unsupported", jsonrpc2.ErrNotHandled, methodDiscover)) { + return fmt.Errorf("%s: %w: %v", requestSummary, jsonrpc2.ErrMethodNotFound, http.StatusText(resp.StatusCode)) + } + if strings.Contains(string(body), "Unsupported protocol version") { + return fmt.Errorf("%s: %w: %v", requestSummary, jsonrpc2.ErrUnsupportedProtocolVersion, http.StatusText(resp.StatusCode)) + } + } return fmt.Errorf("%s: %v", requestSummary, http.StatusText(resp.StatusCode)) } return nil diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 517e51a..785e894 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -6,6 +6,7 @@ package mcp import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -102,6 +103,23 @@ func (s *fakeStreamableServer) ServeHTTP(w http.ResponseWriter, req *http.Reques resp, ok := s.responses[key] if !ok { + if key.jsonrpcMethod == "server/discover" { + // Return MethodNotFound to trigger fallback to legacy initialize. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + respErr := &jsonrpc.Error{ + Code: jsonrpc.CodeMethodNotFound, + Message: `method not found: "server/discover"`, + } + var id jsonrpc.ID + if jsonrpcReq != nil { + id = jsonrpcReq.ID + } + respMsg, _ := jsonrpc2.NewResponse(id, nil, respErr) + data, _ := jsonrpc2.EncodeMessage(respMsg) + w.Write(data) + return + } s.t.Errorf("missing response for %v", key) http.Error(w, "no response", http.StatusInternalServerError) return @@ -1212,3 +1230,408 @@ func TestStreamableClientOAuth_RetrieveError(t *testing.T) { t.Fatalf("client.Connect() error = %v, want %v", err, errTestAuthorizeFailed) } } + +// discoverResult is the canned successful DiscoverResult returned by +// fakeStreamableServer setups in the tests below. +var discoverResult = &DiscoverResult{ + SupportedVersions: []string{protocolVersion20260630}, + Capabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: true}, + }, + ServerInfo: &Implementation{Name: "discoverServer", Version: "v1.0.0"}, + Instructions: "test discover", +} + +// TestStreamableClientConnect_DiscoverSuccess verifies that Client.Connect on +// the streamable transport: +// - sends a POST server/discover with Mcp-Protocol-Version: 2026-06-30 and +// the SEP-2575 per-request _meta triple in the body, +// - on a successful DiscoverResult, skips the legacy initialize handshake +// entirely, and +// - seeds ClientSession.InitializeResult() from the discover response, +// picking a mutually-supported protocol version. +func TestStreamableClientConnect_DiscoverSuccess(t *testing.T) { + ctx := context.Background() + + // Temporarily enable 2026-06-30 support in the SDK for this test + oldSupported := supportedProtocolVersions + supportedProtocolVersions = append([]string{protocolVersion20260630}, supportedProtocolVersions...) + t.Cleanup(func() { + supportedProtocolVersions = oldSupported + }) + + var ( + gotDiscoverMu sync.Mutex + gotDiscover *jsonrpc.Request + ) + + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodDiscover, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "sess-1", + }, + wantProtocolVersion: protocolVersion20260630, + responseFunc: func(r *jsonrpc.Request) (string, int) { + gotDiscoverMu.Lock() + gotDiscover = r + gotDiscoverMu.Unlock() + return jsonBody(t, resp(1, discoverResult, nil)), http.StatusOK + }, + }, + {"DELETE", "sess-1", "", ""}: {optional: true}, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer session.Close() + + if missing := fake.missingRequests(); len(missing) > 0 { + t.Errorf("missing expected requests: %v", missing) + } + + gotDiscoverMu.Lock() + defer gotDiscoverMu.Unlock() + if gotDiscover == nil { + t.Fatal("server did not receive server/discover") + } + + // Inspect the discover request body for the SEP-2575 _meta triple. + var body struct { + Meta map[string]any `json:"_meta"` + } + if err := json.Unmarshal(gotDiscover.Params, &body); err != nil { + t.Fatalf("decoding discover params: %v", err) + } + if v, _ := body.Meta[MetaKeyProtocolVersion].(string); v != protocolVersion20260630 { + t.Errorf("_meta[%s] = %q, want %q", MetaKeyProtocolVersion, v, protocolVersion20260630) + } + if _, ok := body.Meta[MetaKeyClientInfo]; !ok { + t.Errorf("_meta[%s] missing", MetaKeyClientInfo) + } + if _, ok := body.Meta[MetaKeyClientCapabilities]; !ok { + t.Errorf("_meta[%s] missing", MetaKeyClientCapabilities) + } + + ir := session.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil after Connect") + } + if got, want := ir.ProtocolVersion, protocolVersion20260630; got != want { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q", got, want) + } + if ir.ServerInfo == nil || ir.ServerInfo.Name != "discoverServer" { + t.Errorf("InitializeResult.ServerInfo = %+v, want Name=discoverServer", ir.ServerInfo) + } + if ir.Instructions != "test discover" { + t.Errorf("InitializeResult.Instructions = %q, want %q", ir.Instructions, "test discover") + } +} + +// TestStreamableClientConnect_DiscoverMethodNotFound verifies that Client.Connect +// falls back to the legacy initialize handshake when the server responds to +// server/discover with a JSON-RPC "Method not found" error. +func TestStreamableClientConnect_DiscoverMethodNotFound(t *testing.T) { + ctx := context.Background() + + // Each request gets a fresh jsonrpc2 ID from the same client connection. + // Use responseFunc to echo the request's ID back so the client matches + // the response to the in-flight call regardless of ordering. + echoErr := func(err error) func(*jsonrpc.Request) (string, int) { + return func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, &jsonrpc.Response{ID: r.ID, Error: err.(*jsonrpc.Error)}), http.StatusOK + } + } + echoResult := func(result any) func(*jsonrpc.Request) (string, int) { + return func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(result)}), http.StatusOK + } + } + + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodDiscover, ""}: { + header: header{"Content-Type": "application/json"}, + wantProtocolVersion: protocolVersion20260630, + responseFunc: echoErr(&jsonrpc.Error{ + Code: jsonrpc.CodeMethodNotFound, + Message: "method not found", + }), + }, + {"POST", "", methodInitialize, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "fallback", + }, + responseFunc: echoResult(initResult), + }, + {"POST", "fallback", notificationInitialized, ""}: { + status: http.StatusAccepted, + wantProtocolVersion: latestProtocolVersion, + }, + {"GET", "fallback", "", ""}: { + header: header{"Content-Type": "text/event-stream"}, + wantProtocolVersion: latestProtocolVersion, + optional: true, + }, + {"DELETE", "fallback", "", ""}: {optional: true}, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, nil) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer session.Close() + + if got := session.InitializeResult().ProtocolVersion; got != latestProtocolVersion { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (initialize fallback)", got, latestProtocolVersion) + } +} + +// TestStreamableClientConnect_DiscoverUnsupportedVersion verifies that +// Client.Connect falls back to the legacy initialize handshake when the +// server responds to server/discover with the SEP-2575 +// UnsupportedProtocolVersionError JSON-RPC code. +func TestStreamableClientConnect_DiscoverUnsupportedVersion(t *testing.T) { + ctx := context.Background() + + echoErr := func(err error) func(*jsonrpc.Request) (string, int) { + return func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, &jsonrpc.Response{ID: r.ID, Error: err.(*jsonrpc.Error)}), http.StatusOK + } + } + echoResult := func(result any) func(*jsonrpc.Request) (string, int) { + return func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(result)}), http.StatusOK + } + } + + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodDiscover, ""}: { + header: header{"Content-Type": "application/json"}, + wantProtocolVersion: protocolVersion20260630, + responseFunc: echoErr(&jsonrpc.Error{ + Code: CodeUnsupportedProtocolVersion, + Message: "unsupported protocol version", + }), + }, + {"POST", "", methodInitialize, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "fallback", + }, + responseFunc: echoResult(initResult), + }, + {"POST", "fallback", notificationInitialized, ""}: { + status: http.StatusAccepted, + wantProtocolVersion: latestProtocolVersion, + }, + {"GET", "fallback", "", ""}: { + header: header{"Content-Type": "text/event-stream"}, + wantProtocolVersion: latestProtocolVersion, + optional: true, + }, + {"DELETE", "fallback", "", ""}: {optional: true}, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, nil) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer session.Close() + + if got := session.InitializeResult().ProtocolVersion; got != latestProtocolVersion { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (initialize fallback)", got, latestProtocolVersion) + } +} + +// TestStreamableClientConnect_DiscoverPropagatesOtherErrors verifies that +// Client.Connect does NOT fall back to initialize when server/discover +// returns an unrelated JSON-RPC error (here, CodeInternalError). The Connect +// call should fail with the propagated error rather than masking it. +func TestStreamableClientConnect_DiscoverPropagatesOtherErrors(t *testing.T) { + ctx := context.Background() + + var sawInitialize atomic.Bool + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodDiscover, ""}: { + header: header{"Content-Type": "application/json"}, + wantProtocolVersion: protocolVersion20260630, + responseFunc: func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, &jsonrpc.Response{ + ID: r.ID, + Error: &jsonrpc.Error{ + Code: jsonrpc.CodeInternalError, + Message: "boom", + }, + }), http.StatusOK + }, + }, + {"POST", "", methodInitialize, ""}: { + responseFunc: func(r *jsonrpc.Request) (string, int) { + sawInitialize.Store(true) + return jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(initResult)}), http.StatusOK + }, + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "fallback", + }, + optional: true, + }, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + if err == nil { + _ = session.Close() + t.Fatal("Connect succeeded; want propagated error") + } + if sawInitialize.Load() { + t.Error("server received initialize; Connect should have aborted on the discover error") + } +} + +// TestStreamableClientConnect_DiscoverMethodNotFoundVPre verifies that +// Client.Connect falls back to the legacy initialize handshake when a +// pre-SEP-2575 (vPre) server rejects server/discover. +func TestStreamableClientConnect_DiscoverMethodNotFoundVPre(t *testing.T) { + ctx := context.Background() + + echoResult := func(result any) func(*jsonrpc.Request) (string, int) { + return func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(result)}), http.StatusOK + } + } + + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodDiscover, ""}: { + wantProtocolVersion: protocolVersion20260630, + // Reproduce the exact body a vPre server produces via + // http.Error(w, err.Error(), 400) where err comes from + // checkRequest. http.Error appends a trailing newline. + body: "JSON RPC not handled: \"server/discover\" unsupported\n", + status: http.StatusBadRequest, + header: header{"Content-Type": "text/plain; charset=utf-8"}, + }, + {"POST", "", methodInitialize, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "fallback", + }, + responseFunc: echoResult(initResult), + }, + {"POST", "fallback", notificationInitialized, ""}: { + status: http.StatusAccepted, + wantProtocolVersion: latestProtocolVersion, + }, + {"DELETE", "fallback", "", ""}: {optional: true}, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{ + Endpoint: httpServer.URL, + DisableStandaloneSSE: true, + } + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer session.Close() + + if got := session.InitializeResult().ProtocolVersion; got != latestProtocolVersion { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (initialize fallback)", got, latestProtocolVersion) + } +} + +// TestStreamableClientConnect_DiscoverUnsupportedProtocolVersion verifies that +// Client.Connect falls back to the legacy initialize handshake when a +// server rejects server/discover with a plain HTTP 400 containing "Unsupported protocol version". +func TestStreamableClientConnect_DiscoverUnsupportedVersionVPre(t *testing.T) { + ctx := context.Background() + + echoResult := func(result any) func(*jsonrpc.Request) (string, int) { + return func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(result)}), http.StatusOK + } + } + + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodDiscover, ""}: { + wantProtocolVersion: protocolVersion20260630, + body: "Bad Request: Unsupported protocol version\n", + status: http.StatusBadRequest, + header: header{"Content-Type": "text/plain; charset=utf-8"}, + }, + {"POST", "", methodInitialize, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "fallback", + }, + responseFunc: echoResult(initResult), + }, + {"POST", "fallback", notificationInitialized, ""}: { + status: http.StatusAccepted, + wantProtocolVersion: latestProtocolVersion, + }, + {"DELETE", "fallback", "", ""}: {optional: true}, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{ + Endpoint: httpServer.URL, + DisableStandaloneSSE: true, + } + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer session.Close() + + if got := session.InitializeResult().ProtocolVersion; got != latestProtocolVersion { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (initialize fallback)", got, latestProtocolVersion) + } +} diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 53806da..a27696a 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -2049,68 +2049,6 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { }) } -// TODO: Remove this once client operations will automatically inject metadata in the requests -func injectMetaToRequest(req *http.Request) error { - if req.Body == nil { - return nil - } - body, err := io.ReadAll(req.Body) - if err != nil { - return err - } - req.Body.Close() - - var val any - if err := json.Unmarshal(body, &val); err == nil { - var method string - if m, ok := val.(map[string]any); ok { - method, _ = m["method"].(string) - } else if list, ok := val.([]any); ok && len(list) > 0 { - if m, ok := list[0].(map[string]any); ok { - method, _ = m["method"].(string) - } - } - - if method == "initialize" || method == "notifications/initialized" || strings.HasPrefix(method, "notifications/") { - req.Header.Set(protocolVersionHeader, "2025-11-25") - } else { - req.Header.Set(protocolVersionHeader, minVersionForStandardHeaders) - - var msgs []map[string]any - if m, ok := val.(map[string]any); ok { - msgs = []map[string]any{m} - } else if list, ok := val.([]any); ok { - for _, item := range list { - if m, ok := item.(map[string]any); ok { - msgs = append(msgs, m) - } - } - } - - for _, m := range msgs { - params, _ := m["params"].(map[string]any) - if params == nil { - params = make(map[string]any) - m["params"] = params - } - meta, _ := params["_meta"].(map[string]any) - if meta == nil { - meta = make(map[string]any) - params["_meta"] = meta - } - meta[MetaKeyProtocolVersion] = minVersionForStandardHeaders - meta[MetaKeyClientInfo] = map[string]any{"name": "testClient", "version": "v1.0.0"} - meta[MetaKeyClientCapabilities] = map[string]any{} - } - body, _ = json.Marshal(val) - } - } - - req.Body = io.NopCloser(bytes.NewReader(body)) - req.ContentLength = int64(len(body)) - return nil -} - // TestStreamableMcpHeaderValidationErrorFormat verifies that header // validation errors return a JSON-RPC error with code -32001 and // Content-Type application/json, per SEP-2243. @@ -2131,7 +2069,15 @@ func TestStreamableMcpHeaderValidationErrorFormat(t *testing.T) { }) defer handler.closeAll() - httpServer := httptest.NewServer(mustNotPanic(t, handler)) + // TODO(SEP-2575): drop discoverInterceptor and hit `handler` directly + // once Server.discover returns a real DiscoverResult instead of + // MethodNotFound. See comment on discoverInterceptor for details. + wrapped := discoverInterceptor(t, handler, + []string{minVersionForStandardHeaders}, + &ServerCapabilities{Tools: &ToolCapabilities{}}, + &Implementation{Name: "testServer", Version: "v1.0.0"}, + ) + httpServer := httptest.NewServer(mustNotPanic(t, wrapped)) defer httpServer.Close() // Use the MCP client with a custom RoundTripper to inject a bad header. @@ -2140,9 +2086,6 @@ func TestStreamableMcpHeaderValidationErrorFormat(t *testing.T) { customClient := &http.Client{ Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { - if err := injectMetaToRequest(req); err != nil { - return nil, err - } var originalMethodHeader string if req.Header.Get(methodHeader) == "tools/call" { originalMethodHeader = req.Header.Get(methodHeader) @@ -2296,15 +2239,20 @@ func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) { Stateless: true, }) defer handler.closeAll() - httpServer := httptest.NewServer(mustNotPanic(t, handler)) + // TODO(SEP-2575): drop discoverInterceptor and hit `handler` directly + // once Server.discover returns a real DiscoverResult instead of + // MethodNotFound. See comment on discoverInterceptor for details. + wrapped := discoverInterceptor(t, handler, + []string{minVersionForStandardHeaders}, + &ServerCapabilities{Tools: &ToolCapabilities{ListChanged: true}}, + &Implementation{Name: "testServer", Version: "v1.0.0"}, + ) + httpServer := httptest.NewServer(mustNotPanic(t, wrapped)) defer httpServer.Close() var capturedHeaders http.Header customClient := &http.Client{ Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { - if err := injectMetaToRequest(req); err != nil { - return nil, err - } if req.Header.Get(methodHeader) == "tools/call" { capturedHeaders = req.Header.Clone() } @@ -2326,7 +2274,9 @@ func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) { defer session.Close() // ListTools to populate the tool cache (needed for param headers). - if _, err := session.ListTools(ctx, nil); err != nil { + // Pass a non-nil params so the SEP-2575 per-request _meta triple is + // injected; injectMeta is a no-op when params is nil. + if _, err := session.ListTools(ctx, &ListToolsParams{}); err != nil { t.Fatal(err) } @@ -2411,23 +2361,21 @@ func TestStreamableFilterValidToolsIntegration(t *testing.T) { Stateless: true, }) defer handler.closeAll() - httpServer := httptest.NewServer(mustNotPanic(t, handler)) + // TODO(SEP-2575): drop discoverInterceptor and hit `handler` directly + // once Server.discover returns a real DiscoverResult instead of + // MethodNotFound. See comment on discoverInterceptor for details. + wrapped := discoverInterceptor(t, handler, + []string{minVersionForStandardHeaders}, + &ServerCapabilities{Tools: &ToolCapabilities{ListChanged: true}}, + &Implementation{Name: "testServer", Version: "v1.0.0"}, + ) + httpServer := httptest.NewServer(mustNotPanic(t, wrapped)) defer httpServer.Close() - customClient := &http.Client{ - Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { - if err := injectMetaToRequest(req); err != nil { - return nil, err - } - return http.DefaultTransport.RoundTrip(req) - }), - } - client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil) ctx := context.Background() session, err := client.Connect(ctx, &StreamableClientTransport{ - Endpoint: httpServer.URL, - HTTPClient: customClient, + Endpoint: httpServer.URL, }, &ClientSessionOptions{protocolVersion: minVersionForStandardHeaders}) if err != nil { t.Fatal(err) @@ -2742,6 +2690,51 @@ func TestStreamableSessionTimeout(t *testing.T) { handler.mu.Unlock() } +// discoverInterceptor wraps an HTTP handler so that POST requests carrying a +// server/discover JSON-RPC request are answered with a canned DiscoverResult +// advertising the given supportedVersions. All other requests are forwarded +// to next unchanged. +// +// TODO(SEP-2575): this is a workaround for tests that need an end-to-end +// SEP-2575 session (e.g. to exercise the Mcp-Method / Mcp-Param-* request +// headers gated on protocol >= 2026-06-30) while the server-side +// Server.discover implementation still returns MethodNotFound. Once +// server-side discover is implemented, this helper can be removed and the +// tests can hit the real handler directly. +func discoverInterceptor(t *testing.T, next http.Handler, supportedVersions []string, capabilities *ServerCapabilities, serverInfo *Implementation) http.Handler { + t.Helper() + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + next.ServeHTTP(w, req) + return + } + body, err := io.ReadAll(req.Body) + req.Body.Close() + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + req.Body = io.NopCloser(bytes.NewReader(body)) + msg, err := jsonrpc.DecodeMessage(body) + if err != nil { + next.ServeHTTP(w, req) + return + } + r, ok := msg.(*jsonrpc.Request) + if !ok || r.Method != methodDiscover { + next.ServeHTTP(w, req) + return + } + result := &DiscoverResult{ + SupportedVersions: supportedVersions, + Capabilities: capabilities, + ServerInfo: serverInfo, + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(result)}))) + }) +} + // mustNotPanic is a helper to enforce that test handlers do not panic (see // issue #556). func mustNotPanic(t *testing.T, h http.Handler) http.Handler { @@ -3592,3 +3585,48 @@ func TestStreamableStateless_AcceptsNewProtocol(t *testing.T) { t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody) } } + +// TestStreamableClientUnsupportedVersionFallback exercises the full +// SEP-2575 fallback. The client requests protocolVersion20260630, which the server does +// not advertise in supportedProtocolVersions. The server therefore rejects +// the server/discover POST at the transport-level header validation with a +// plain HTTP 400 ("Bad Request: Unsupported protocol version ..."). The +// streamable client must recognize this body, keep the connection alive, and +// successfully complete the legacy initialize handshake. +// +// TODO: once 20260630 is part of supportedProtocolVersion on server side, modify the list in the test to keep it consistent. +func TestStreamableClientUnsupportedVersionFallback(t *testing.T) { + ctx := context.Background() + + server := NewServer(testImpl, nil) + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return server }, + nil, + ) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + client := NewClient(testImpl, nil) + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer session.Close() + + ir := session.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil; expected legacy initialize to populate it") + } + if ir.ProtocolVersion != latestProtocolVersion { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback)", + ir.ProtocolVersion, latestProtocolVersion) + } + + // Verify the session is fully usable after the fallback by issuing a + // real call against the server. + if err := session.Ping(ctx, nil); err != nil { + t.Errorf("Ping after fallback initialize: %v", err) + } +} From 5045d86fb477fbc195ea5949355b1183466b5f4a Mon Sep 17 00:00:00 2001 From: BorisTyshkevich <125603338+BorisTyshkevich@users.noreply.github.com> Date: Fri, 5 Jun 2026 12:16:43 +0200 Subject: [PATCH 294/418] auth: add ClockSkew option to RequireBearerTokenOptions (#969) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add a \`ClockSkew time.Duration\` field to \`RequireBearerTokenOptions\` that bounds the tolerance applied to a token's \`Expiration\` when checking elapsed validity. - Default zero preserves the existing strict comparison; positive values let resource servers tolerate the drift typical of distributed deployments and cloud-managed IdPs. ## Motivation Real-world resource servers running behind a CDN, in distributed deployments, or talking to an authorization server whose clock drifts a few seconds (common with cloud-managed IdPs) need a small positive tolerance when checking token expiration. Strict-equality comparison rejects tokens that are valid by the issuer's clock but momentarily appear expired by the verifier's. The reverse case — an issuer's clock running slightly fast at /token issuance — also reaches the verifier as a token whose \`exp\` lies microseconds in the past. ## Test plan - [x] \`go test ./auth/...\` passes. - [x] New \`TestRequireBearerToken_ClockSkew\` exercises four cases: fresh accept, strict-expired reject, within-skew accept, beyond-skew reject. - [x] Existing \`TestRequireBearerToken\` continues to pass with \`ClockSkew\` defaulting to zero. Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Guglielmo Colombo --- auth/auth.go | 24 ++++++++++++++++--- auth/auth_test.go | 60 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/auth/auth.go b/auth/auth.go index f205870..dc02d7d 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -62,6 +62,19 @@ type RequireBearerTokenOptions struct { // validity check it can perform; this option only relaxes the middleware's // own expiration enforcement. AllowMissingExpiration bool + // ClockSkew bounds the tolerance applied to a token's Expiration when + // deciding whether it has elapsed. A token is rejected only if + // Expiration + ClockSkew is before the current time. Zero (the default) + // preserves strict comparison: any expired token is rejected immediately. + // + // Resource servers running behind a CDN, in distributed deployments, or + // communicating with an authorization server whose clock drifts a few + // seconds (common with cloud-managed IdPs) need a small positive value + // here to avoid rejecting tokens that are valid by the issuer's clock + // but momentarily appear expired by the verifier's. The same tolerance + // guards against an issuer's clock running slightly fast at /token + // issuance time. + ClockSkew time.Duration } type tokenInfoKey struct{} @@ -144,12 +157,17 @@ func verify(req *http.Request, verifier TokenVerifier, opts *RequireBearerTokenO } } - // Check expiration. + if opts == nil { + opts = &RequireBearerTokenOptions{} + } + // Check expiration, with optional clock-skew tolerance. Skew only applies + // when an expiration is present; a missing expiration is governed solely by + // AllowMissingExpiration. if tokenInfo.Expiration.IsZero() { - if opts == nil || !opts.AllowMissingExpiration { + if !opts.AllowMissingExpiration { return nil, "token missing expiration", http.StatusUnauthorized } - } else if tokenInfo.Expiration.Before(time.Now()) { + } else if tokenInfo.Expiration.Add(opts.ClockSkew).Before(time.Now()) { return nil, "token expired", http.StatusUnauthorized } return tokenInfo, "", 0 diff --git a/auth/auth_test.go b/auth/auth_test.go index fe523a1..654ca26 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -289,3 +289,63 @@ func TestRequireBearerToken(t *testing.T) { }) } } + +// TestRequireBearerToken_ClockSkew verifies that the ClockSkew option +// extends the expiration check tolerance: a token whose Expiration is in the +// recent past is accepted iff the elapsed interval is within ClockSkew. +func TestRequireBearerToken_ClockSkew(t *testing.T) { + tests := []struct { + name string + clockSkew time.Duration + expiredAgo time.Duration + wantStatus int + }{ + { + name: "no skew, fresh token accepted", + clockSkew: 0, + expiredAgo: -time.Minute, // expires in 1 minute + wantStatus: http.StatusOK, + }, + { + name: "no skew, expired token rejected", + clockSkew: 0, + expiredAgo: 5 * time.Second, // expired 5s ago + wantStatus: http.StatusUnauthorized, + }, + { + name: "with skew, recently-expired token accepted", + clockSkew: 30 * time.Second, + expiredAgo: 5 * time.Second, + wantStatus: http.StatusOK, + }, + { + name: "with skew, token expired beyond tolerance rejected", + clockSkew: 10 * time.Second, + expiredAgo: 30 * time.Second, + wantStatus: http.StatusUnauthorized, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + verifier := func(_ context.Context, _ string, _ *http.Request) (*TokenInfo, error) { + return &TokenInfo{Expiration: time.Now().Add(-tt.expiredAgo)}, nil + } + handler := RequireBearerToken(verifier, &RequireBearerTokenOptions{ + ClockSkew: tt.clockSkew, + })(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer anything") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus) + } + }) + } +} From d9714c61f11be185ba0cb994bff16ce2a072e318 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Mon, 8 Jun 2026 15:09:07 +0200 Subject: [PATCH 295/418] mcp: Implement retry on advised supportedVersions in UnsupportedProtocolVersion error (#989) ## Description This PR introduces a retry mechanism for the discover method according to SEP-2575 (Issue #966) --- mcp/client.go | 72 +++++++++++++++-------------- mcp/client_test.go | 112 +++++++++++++++++++++++++++++++++++++++++++++ mcp/protocol.go | 11 +++++ mcp/shared.go | 11 +++++ 4 files changed, 172 insertions(+), 34 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index 5f142fb..1e0e18a 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -290,18 +290,41 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp // signals it doesn't support it, fall back to the legacy initialize // handshake. discoverCtx := context.WithValue(ctx, protocolVersionContextKey{}, protocolVersion) - discRes, fallback, err := c.discover(discoverCtx, cs) - if err != nil { + // We try to discover the server's capabilities. If the server rejects the + // requested version but specifies which versions it supports, we negotiate + // a mutually supported version and try again. + for range 2 { + discRes, err := c.discover(discoverCtx, cs) + if err == nil { + cs.state.InitializeResult = discRes + if hc, ok := cs.mcpConn.(clientConnection); ok { + hc.sessionUpdated(cs.state) + } + return cs, nil + } + + var werr *jsonrpc.Error + if !errors.As(err, &werr) { + return nil, err + } + // Try to negotiate a mutually supported version if the server + // reports an UnsupportedProtocolVersionError with a supported version. + if werr.Code == CodeUnsupportedProtocolVersion && werr.Data != nil { + var data UnsupportedProtocolVersionData + if err := json.Unmarshal(werr.Data, &data); err == nil { + if negotiatedVersion := negotiateMutuallySupportedVersion(data.Supported); negotiatedVersion != "" && negotiatedVersion >= protocolVersion20260630 { + discoverCtx = context.WithValue(ctx, protocolVersionContextKey{}, negotiatedVersion) + continue + } + } + } + // MethodNotFound and UnsupportedProtocolVersion trigger a fallback to legacy initialize. + if werr.Code == jsonrpc.CodeMethodNotFound || werr.Code == CodeUnsupportedProtocolVersion { + break + } return nil, err } - if !fallback { - cs.state.InitializeResult = discRes - if hc, ok := cs.mcpConn.(clientConnection); ok { - hc.sessionUpdated(cs.state) - } - return cs, nil - } - // Fallback to the legacy initialize handshake. + // Fallback to the legacy initialize handshake with the legacy protocol version. protocolVersion = protocolVersion20251125 } @@ -338,15 +361,7 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp // discover sends a SEP-2575 server/discover request to probe the server for // stateless protocol support. -// -// The return values have three possible combinations: -// - (result, false, nil): discovery succeeded; caller should skip legacy initialization. -// - (nil, true, nil): the server explicitly signaled it doesn't support -// discovery (Method not found, or UnsupportedProtocolVersionError, or version mismatch); -// caller should fall back to the legacy initialize handshake. -// - (nil, false, err): any other failure (transport error, malformed response, etc.); -// caller should propagate the error. -func (c *Client) discover(ctx context.Context, cs *ClientSession) (*InitializeResult, bool, error) { +func (c *Client) discover(ctx context.Context, cs *ClientSession) (*InitializeResult, error) { protocolVersion := protocolVersionFromContext(ctx) caps := c.capabilities(protocolVersion) params := &DiscoverParams{ @@ -359,13 +374,7 @@ func (c *Client) discover(ctx context.Context, cs *ClientSession) (*InitializeRe req := &DiscoverRequest{Session: cs, Params: params} res, err := handleSend[*DiscoverResult](ctx, methodDiscover, req) if err != nil { - // According to SEP-2575, only the two signals below (MethodNotFound - // and UnsupportedProtocolVersionError) should trigger a fallback. - var werr *jsonrpc.Error - if errors.As(err, &werr) && (werr.Code == jsonrpc.CodeMethodNotFound || werr.Code == CodeUnsupportedProtocolVersion) { - return nil, true, nil - } - return nil, false, err + return nil, err } // Pick the highest protocol version that both the server and this SDK support. @@ -375,17 +384,12 @@ func (c *Client) discover(ctx context.Context, cs *ClientSession) (*InitializeRe if slices.Contains(res.SupportedVersions, protocolVersion) { negotiated = protocolVersion } else { - for _, v := range supportedProtocolVersions { - if slices.Contains(res.SupportedVersions, v) { - negotiated = v - break - } - } + negotiated = negotiateMutuallySupportedVersion(res.SupportedVersions) } if negotiated == "" || negotiated < protocolVersion20260630 { // If there is no overlap, fall back to initialize so version // negotiation can happen via the legacy path. - return nil, true, nil + return nil, jsonrpc2.ErrUnsupportedProtocolVersion } return &InitializeResult{ @@ -393,7 +397,7 @@ func (c *Client) discover(ctx context.Context, cs *ClientSession) (*InitializeRe Instructions: res.Instructions, ProtocolVersion: negotiated, ServerInfo: res.ServerInfo, - }, false, nil + }, nil } // A ClientSession is a logical connection with an MCP server. Its diff --git a/mcp/client_test.go b/mcp/client_test.go index 1d62952..75d1c4f 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -6,6 +6,7 @@ package mcp import ( "context" + "encoding/json" "fmt" "log/slog" "sync/atomic" @@ -841,3 +842,114 @@ func TestClientConnectDiscover_RequestContents(t *testing.T) { t.Errorf("clientCapabilities.sampling missing (CreateMessageHandler was set); got %v", caps) } } + +// If the server does not support the requested version, it returns an +// UnsupportedProtocolVersionError containing its list of supported +// versions. The client selects a mutually supported version from the list +// and retries. +func TestClientConnectDiscover_UnsupportedVersionNegotiation(t *testing.T) { + // Temporarily enable 2026-06-30 support in the SDK for this test so it + // is a candidate during negotiation. + oldSupported := supportedProtocolVersions + supportedProtocolVersions = append([]string{protocolVersion20260630}, supportedProtocolVersions...) + t.Cleanup(func() { + supportedProtocolVersions = oldSupported + }) + + ctx := context.Background() + + const ( + unsupportedClientVersion = "2099-12-31" + serverNegotiatedVersion = protocolVersion20260630 + ) + + var ( + discoverCalls atomic.Int32 + gotInitialize atomic.Bool + firstRequestedVersion atomic.Value // string + secondRequestedVersion atomic.Value // string + ) + + s := NewServer(testImpl, nil) + s.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + switch method { + case methodDiscover: + sr, ok := req.(*ServerRequest[*DiscoverParams]) + if !ok { + t.Errorf("discover req has unexpected type %T", req) + return nil, jsonrpc2.ErrMethodNotFound + } + requested, _ := sr.Params.GetMeta()[MetaKeyProtocolVersion].(string) + + n := discoverCalls.Add(1) + switch n { + case 1: + firstRequestedVersion.Store(requested) + data, err := json.Marshal(UnsupportedProtocolVersionData{ + Supported: []string{serverNegotiatedVersion}, + Requested: requested, + }) + if err != nil { + t.Fatalf("marshal error data: %v", err) + } + return nil, &jsonrpc.Error{ + Code: CodeUnsupportedProtocolVersion, + Message: "unsupported protocol version", + Data: data, + } + case 2: + secondRequestedVersion.Store(requested) + return &DiscoverResult{ + SupportedVersions: []string{serverNegotiatedVersion}, + Capabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: true}, + }, + ServerInfo: &Implementation{Name: "discoverServer", Version: "v1.0.0"}, + }, nil + default: + t.Errorf("unexpected discover call #%d", n) + return nil, jsonrpc2.ErrMethodNotFound + } + case methodInitialize: + gotInitialize.Store(true) + } + return next(ctx, method, req) + } + }) + + ct, st := NewInMemoryTransports() + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server Connect: %v", err) + } + defer ss.Close() + + c := NewClient(testImpl, nil) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: unsupportedClientVersion}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer cs.Close() + + if got, want := discoverCalls.Load(), int32(2); got != want { + t.Errorf("server/discover call count = %d, want %d", got, want) + } + if got, _ := firstRequestedVersion.Load().(string); got != unsupportedClientVersion { + t.Errorf("first discover requested version = %q, want %q", got, unsupportedClientVersion) + } + if got, _ := secondRequestedVersion.Load().(string); got != serverNegotiatedVersion { + t.Errorf("retry discover requested version = %q, want %q (server's advertised supported version)", got, serverNegotiatedVersion) + } + if gotInitialize.Load() { + t.Error("legacy initialize handshake ran, but negotiated discover should have succeeded") + } + + ir := cs.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil after Connect") + } + if got, want := ir.ProtocolVersion, serverNegotiatedVersion; got != want { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q", got, want) + } +} diff --git a/mcp/protocol.go b/mcp/protocol.go index f5b79ae..d838067 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -2103,3 +2103,14 @@ const ( // MetaKeyClientCapabilities carries the client's [ClientCapabilities]. MetaKeyClientCapabilities = "io.modelcontextprotocol/clientCapabilities" ) + +// UnsupportedProtocolVersionData is the SEP-2575 payload carried in the +// `data` field of a JSON-RPC error response with code +// [CodeUnsupportedProtocolVersion]. The server uses it to advertise which +// versions it supports so the client can pick a mutually supported one. +type UnsupportedProtocolVersionData struct { + // Supported is the list of protocol versions the server supports. + Supported []string `json:"supported"` + // Requested is the protocol version the client asked for. + Requested string `json:"requested"` +} diff --git a/mcp/shared.go b/mcp/shared.go index a0232d3..88c2464 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -64,6 +64,17 @@ func negotiatedVersion(clientVersion string) string { return clientVersion } +// negotiateMutuallySupportedVersion returns a protocol version that is supported +// by both the client and the server. +func negotiateMutuallySupportedVersion(supported []string) string { + for _, ver := range supportedProtocolVersions { + if slices.Contains(supported, ver) { + return ver + } + } + return "" +} + // A MethodHandler handles MCP messages. // For methods, exactly one of the return values must be nil. // For notifications, both must be nil. From 00d364284050b0787dd8741eb0310d23cf22e59a Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Mon, 8 Jun 2026 15:56:38 +0200 Subject: [PATCH 296/418] mcp: add support for logging level (SEP-2575) (#997) ## Description This PR adds the support for logging feature in meta. Fixes #966 --- mcp/protocol.go | 2 ++ mcp/server.go | 31 +++++++++++++++++++++++++------ mcp/shared.go | 7 ++++--- mcp/shared_test.go | 33 +++++++++++++++++++++++++++++++++ mcp/streamable.go | 4 +++- 5 files changed, 67 insertions(+), 10 deletions(-) diff --git a/mcp/protocol.go b/mcp/protocol.go index d838067..4ba8600 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -2102,6 +2102,8 @@ const ( MetaKeyClientInfo = "io.modelcontextprotocol/clientInfo" // MetaKeyClientCapabilities carries the client's [ClientCapabilities]. MetaKeyClientCapabilities = "io.modelcontextprotocol/clientCapabilities" + // MetaKeyLogLevel identifies the desired log level for the request. + MetaKeyLogLevel = "io.modelcontextprotocol/logLevel" ) // UnsupportedProtocolVersionData is the SEP-2575 payload carried in the diff --git a/mcp/server.go b/mcp/server.go index ba03fbf..5f3cbc0 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1367,13 +1367,28 @@ func (ss *ServerSession) Elicit(ctx context.Context, params *ElicitParams) (*Eli return res, nil } +// logLevelContextKey carries the per-request log level from +// [ServerSession.handle] to [ServerSession.Log] for new-protocol +// (>= 2026-06-30) requests. The level is scoped to a single in-flight request +// — including handler goroutines that call [ServerSession.Log] concurrently — +// rather than to the session, which avoids races between concurrent requests +// and aligns with SEP-2575's per-request opt-in model. The value type is +// [LoggingLevel]; an empty string means the request opted out of log messages. +type logLevelContextKey struct{} + // Log sends a log message to the client. -// The message is not sent if the client has not called SetLevel, or if its level -// is below that of the last SetLevel. +// +// For new-protocol (>= 2026-06-30) requests, the level is taken from the +// originating request's `_meta` field (SEP-2575); an absent or empty value +// suppresses the message per spec. For old-protocol requests, the level is +// taken from the session state set via `logging/setLevel`. func (ss *ServerSession) Log(ctx context.Context, params *LoggingMessageParams) error { - ss.mu.Lock() - logLevel := ss.state.LogLevel - ss.mu.Unlock() + logLevel, ok := ctx.Value(logLevelContextKey{}).(LoggingLevel) + if !ok { + ss.mu.Lock() + logLevel = ss.state.LogLevel + ss.mu.Unlock() + } if logLevel == "" { // The spec is unclear, but seems to imply that no log messages are sent until the client // sets the level. @@ -1498,7 +1513,7 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, } switch req.Method { - case methodInitialize, methodPing, notificationInitialized: + case methodInitialize, methodPing, notificationInitialized, methodSetLevel: if validatedMeta.usesNewProtocol { ss.server.opts.Logger.Error("method removed in the new protocol", "method", req.Method) return nil, &jsonrpc.Error{ @@ -1533,6 +1548,10 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, // server->client calls and notifications to the incoming request from which // they originated. See [idContextKey] for details. ctx = context.WithValue(ctx, idContextKey{}, req.ID) + // For new-protocol requests, propagate the per-request log level. + if validatedMeta.usesNewProtocol { + ctx = context.WithValue(ctx, logLevelContextKey{}, validatedMeta.logLevel) + } return handleReceive(ctx, ss, req) } diff --git a/mcp/shared.go b/mcp/shared.go index 88c2464..1aa4290 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -498,6 +498,7 @@ func extractRequestMeta(rawParams json.RawMessage) Meta { type validatedMeta struct { usesNewProtocol bool initializeParams *InitializeParams + logLevel LoggingLevel } // validateRequestMeta inspects a JSON-RPC request to detect whether it follows @@ -518,8 +519,7 @@ func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) { if !ok { return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil } - // Notifications do not carry full client identity. In new protocol, only cancel notification - // is allowed in STDIO. + // Notifications do not carry full client identity. if !req.IsCall() { return &validatedMeta{usesNewProtocol: true, initializeParams: nil}, nil } @@ -537,11 +537,12 @@ func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) { Message: fmt.Sprintf("missing or invalid _meta field %q", MetaKeyClientCapabilities), } } + logLevel, _ := decodeMetaValue[LoggingLevel](meta, MetaKeyLogLevel) return &validatedMeta{usesNewProtocol: true, initializeParams: &InitializeParams{ ProtocolVersion: protocolVersion, Capabilities: capabilities, ClientInfo: clientInfo, - }}, nil + }, logLevel: logLevel}, nil } // A Request is a method request with parameters and additional information, such as the session. diff --git a/mcp/shared_test.go b/mcp/shared_test.go index 065d00b..62f6583 100644 --- a/mcp/shared_test.go +++ b/mcp/shared_test.go @@ -21,6 +21,7 @@ func TestValidateRequestMeta(t *testing.T) { isNotification bool params any wantUsesNew bool + wantLogLevel LoggingLevel wantErrContains string }{ { @@ -101,6 +102,35 @@ func TestValidateRequestMeta(t *testing.T) { params: json.RawMessage(`{"_meta": "not an object", "name": "x"}`), wantUsesNew: false, }, + { + name: "new protocol with logLevel", + method: methodCallTool, + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260630, + MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, + MetaKeyClientCapabilities: map[string]any{}, + MetaKeyLogLevel: "warning", + }, + "name": "x", + }, + wantUsesNew: true, + wantLogLevel: "warning", + }, + { + name: "new protocol without logLevel", + 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, + wantLogLevel: "", + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -127,6 +157,9 @@ func TestValidateRequestMeta(t *testing.T) { if usesNew != tc.wantUsesNew { t.Errorf("usesNewProtocol = %v, want %v", usesNew, tc.wantUsesNew) } + if vmeta != nil && vmeta.logLevel != tc.wantLogLevel { + t.Errorf("logLevel = %q, want %q", vmeta.logLevel, tc.wantLogLevel) + } if tc.wantErrContains == "" { if err != nil { t.Errorf("unexpected error: %v", err) diff --git a/mcp/streamable.go b/mcp/streamable.go index e6a9bfe..a46c984 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -441,7 +441,9 @@ func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (opts *S if !hasInitialized && !usesNewProtocol { state.InitializedParams = new(InitializedParams) } - state.LogLevel = "info" + if !usesNewProtocol { + state.LogLevel = "info" + } return &ServerSessionOptions{ State: state, }, usesNewProtocol, nil From dd978160abb30bf7e4db06520cb2176a4f26eba6 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Mon, 8 Jun 2026 17:52:10 +0200 Subject: [PATCH 297/418] mcp: Implement server-side support for discover method (SEP-2575) (#987) ## Summary Part 3 of the SEP-2575 ("Make MCP Stateless") implementation. Builds on PR #975 which landed the client-side discover/fallback flow and the per-request `_meta` plumbing. This PR adds the **server-side** counterparts: - `server/discover` handler (replaces the stub),ProtocolVersionSupporter` - transport hook for per-transport version filtering, - transport-agnostic version validation and method-removal enforcement in `ServerSession.handle`, - HTTP-level wire compliance for SEP-2575 error codes/status codes, - bug fix in `internal/jsonrpc2`,`processResult` with an `MCPGODEBUG` opt-out for backward compatibility. --- docs/mcpgodebug.md | 11 ++ internal/docs/mcpgodebug.src.md | 11 ++ internal/jsonrpc2/conn.go | 21 +- internal/jsonrpc2/wire.go | 2 +- mcp/client_test.go | 333 ++++++++++++++++++++++++++------ mcp/mrtr_test.go | 21 -- mcp/server.go | 70 ++++++- mcp/server_test.go | 8 + mcp/shared.go | 6 +- mcp/streamable.go | 163 +++++++++++++--- mcp/streamable_test.go | 232 +++++++++++++++------- mcp/transport.go | 13 ++ 12 files changed, 703 insertions(+), 188 deletions(-) diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index f1e6237..40958c2 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -39,6 +39,17 @@ Options listed below were added and will be removed in the 1.9.0 version of the restoring the previous behavior. The default behavior was changed so that stateless servers ignore session IDs entirely and reject `DELETE` with 405. +- `nomethodnotfoundcodeinerror` added. If set to `1`, the jsonrpc2 layer will not + include the MethodNotFound Error (`-32601`) in the error response when the + requested method in STDIO transport is not found. The default behavior was + changed to include the MethodNotFound Error in the error response when the + requested method in STDIO transport is not found. + +- `noprotocolerrorbody` added. If set to `1`, the streamable HTTP client will + not attempt to decode the JSON-RPC error body of a non-2xx HTTP response, + restoring the previous behavior. The default behavior was changed so that + the client always attempts to surface the underlying JSON-RPC error. + ### 1.6.0 Options listed below were added and will be removed in the 1.8.0 version of the SDK. diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index 88639a2..a8e1a92 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -38,6 +38,17 @@ Options listed below were added and will be removed in the 1.9.0 version of the restoring the previous behavior. The default behavior was changed so that stateless servers ignore session IDs entirely and reject `DELETE` with 405. +- `nomethodnotfoundcodeinerror` added. If set to `1`, the jsonrpc2 layer will not + include the MethodNotFound Error (`-32601`) in the error response when the + requested method in STDIO transport is not found. The default behavior was + changed to include the MethodNotFound Error in the error response when the + requested method in STDIO transport is not found. + +- `noprotocolerrorbody` added. If set to `1`, the streamable HTTP client will + not attempt to decode the JSON-RPC error body of a non-2xx HTTP response, + restoring the previous behavior. The default behavior was changed so that + the client always attempts to surface the underlying JSON-RPC error. + ### 1.6.0 Options listed below were added and will be removed in the 1.8.0 version of the SDK. diff --git a/internal/jsonrpc2/conn.go b/internal/jsonrpc2/conn.go index df6ef5e..3b4bc57 100644 --- a/internal/jsonrpc2/conn.go +++ b/internal/jsonrpc2/conn.go @@ -14,8 +14,25 @@ import ( "time" "github.com/modelcontextprotocol/go-sdk/internal/json" + "github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug" ) +// nomethodnotfoundcodeinerror is a compatibility parameter that restores the +// pre-fix behavior of [processResult], where wrapped [ErrNotHandled] or +// [ErrMethodNotFound] errors returned by request handlers were not +// recognized as "method not found" signals. The original switch statement +// compared sentinel errors with ==, which never matched errors returned via +// fmt.Errorf("%w: ...", ErrNotHandled, ...) — including the ones produced +// by checkRequest. As a result the wire error response carried code 0 +// instead of code -32601. The fix uses errors.Is to recognize wrapped +// sentinels and append the method name to the message. +// +// To restore the previous behavior, set MCPGODEBUG=nomethodnotfoundcodeinerror=1. +// This option will be removed in a future SDK version. +// See the documentation for the mcpgodebug package for instructions on how +// to use it. +var nomethodnotfoundcodeinerror = mcpgodebug.Value("nomethodnotfoundcodeinerror") + // Connection manages the jsonrpc2 protocol, connecting responses back to their // calls. Connection is bidirectional; it does not have a designated server or // client end. @@ -648,9 +665,7 @@ func (c *Connection) handleAsync() { // processResult processes the result of a request and, if appropriate, sends a response. func (c *Connection) processResult(from any, req *incomingRequest, result any, err error) error { - switch err { - case ErrNotHandled, ErrMethodNotFound: - // Add detail describing the unhandled method. + if nomethodnotfoundcodeinerror != "1" && (errors.Is(err, ErrNotHandled) || errors.Is(err, ErrMethodNotFound)) { err = fmt.Errorf("%w: %q", ErrMethodNotFound, req.Method) } diff --git a/internal/jsonrpc2/wire.go b/internal/jsonrpc2/wire.go index b0beae0..1215f14 100644 --- a/internal/jsonrpc2/wire.go +++ b/internal/jsonrpc2/wire.go @@ -33,7 +33,7 @@ var ( // ErrServerClosing is returned for calls that arrive while the server is closing. ErrServerClosing = NewError(-32006, "server is closing") // ErrClientClosing is a dummy error returned for calls initiated while the client is closing. - ErrClientClosing = NewError(-32003, "client is closing") + ErrClientClosing = NewError(-32007, "client is closing") // The following errors have special semantics for MCP transports diff --git a/mcp/client_test.go b/mcp/client_test.go index 75d1c4f..16b6b76 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -6,9 +6,9 @@ package mcp import ( "context" - "encoding/json" "fmt" "log/slog" + "slices" "sync/atomic" "testing" @@ -678,6 +678,17 @@ func TestClientConnectDiscover(t *testing.T) { wantInitialize: true, wantVersion: latestProtocolVersion, }, + { + name: "unsupported protocol version falls back to initialize", + discoverHandler: func() (Result, error) { + return nil, &jsonrpc.Error{ + Code: CodeUnsupportedProtocolVersion, + Message: "unsupported protocol version", + } + }, + wantInitialize: true, + wantVersion: latestProtocolVersion, + }, { name: "no overlapping supported version falls back to initialize", discoverHandler: func() (Result, error) { @@ -773,6 +784,10 @@ func TestClientConnectDiscover(t *testing.T) { // request sent by Client.Connect carries the SEP-2575 per-request _meta triple: // protocolVersion, clientInfo, and clientCapabilities. func TestClientConnectDiscover_RequestContents(t *testing.T) { + orig := supportedProtocolVersions + supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) + t.Cleanup(func() { supportedProtocolVersions = orig }) + ctx := context.Background() type captured struct { @@ -843,31 +858,263 @@ func TestClientConnectDiscover_RequestContents(t *testing.T) { } } -// If the server does not support the requested version, it returns an -// UnsupportedProtocolVersionError containing its list of supported -// versions. The client selects a mutually supported version from the list -// and retries. -func TestClientConnectDiscover_UnsupportedVersionNegotiation(t *testing.T) { - // Temporarily enable 2026-06-30 support in the SDK for this test so it - // is a candidate during negotiation. - oldSupported := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260630}, supportedProtocolVersions...) - t.Cleanup(func() { - supportedProtocolVersions = oldSupported +// TestInMemory_E2E_DiscoverSuccess is a full end-to-end smoke test for +// SEP-2575 over a non-HTTP transport. +func TestInMemory_E2E_DiscoverSuccess(t *testing.T) { + ctx := context.Background() + + orig := supportedProtocolVersions + supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + server := NewServer(&Implementation{Name: "stdio-like-server", Version: "v1"}, nil) + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + client := NewClient(&Implementation{Name: "stdio-like-client", Version: "v1"}, nil) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + if err != nil { + t.Fatalf("client.Connect: %v", err) + } + defer cs.Close() + + ir := cs.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil; discover should have populated it") + } + if ir.ProtocolVersion != protocolVersion20260630 { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (negotiated via discover, no initialize)", + ir.ProtocolVersion, protocolVersion20260630) + } + if ir.ServerInfo == nil || ir.ServerInfo.Name != "stdio-like-server" { + t.Errorf("InitializeResult.ServerInfo = %+v, want name=stdio-like-server", ir.ServerInfo) + } + + // Prove the session is usable. + if _, err := cs.ListTools(ctx, nil); err != nil { + t.Errorf("ListTools after discover: %v", err) + } +} + +// TestInMemory_E2E_DiscoverFallback_NoOverlap verifies the fallback path +// over an InMemory (STDIO-equivalent) transport: the client probes with +// _meta.protocolVersion = 2026-06-30, but the server's supported list does +// NOT include that version (the default for an SDK server that hasn't +// shimmed supportedProtocolVersions). +func TestInMemory_E2E_DiscoverFallback_NoOverlap(t *testing.T) { + ctx := context.Background() + orig := supportedProtocolVersions + supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + server := NewServer(&Implementation{Name: "vpre-like-server", Version: "v1"}, nil) + // Intercept discover and reply as if we were a server that only + // supports legacy versions. + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == methodDiscover { + return &DiscoverResult{ + SupportedVersions: []string{protocolVersion20251125}, + Capabilities: &ServerCapabilities{}, + ServerInfo: &Implementation{Name: "vpre-like-server", Version: "v1"}, + }, nil + } + return next(ctx, method, req) + } }) + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + if err != nil { + t.Fatalf("client.Connect: %v", err) + } + defer cs.Close() + + ir := cs.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil after fallback initialize") + } + if ir.ProtocolVersion != latestProtocolVersion { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback after no-overlap discover)", + ir.ProtocolVersion, latestProtocolVersion) + } + + // Prove the session is usable after fallback. + if _, err := cs.ListTools(ctx, nil); err != nil { + t.Errorf("ListTools after fallback initialize: %v", err) + } +} + +// TestInMemory_E2E_DiscoverFallback_MethodNotFound verifies the fallback +// path over InMemory when the server doesn't know about server/discover at +// all (simulating a true pre-SEP-2575 server). +func TestInMemory_E2E_DiscoverFallback_MethodNotFound(t *testing.T) { + ctx := context.Background() + + orig := supportedProtocolVersions + supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + server := NewServer(&Implementation{Name: "vpre-server", Version: "v1"}, nil) + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == methodDiscover { + return nil, jsonrpc2.ErrMethodNotFound + } + return next(ctx, method, req) + } + }) + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + if err != nil { + t.Fatalf("client.Connect: %v", err) + } + defer cs.Close() + + ir := cs.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil after fallback initialize") + } + if ir.ProtocolVersion != latestProtocolVersion { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback after MethodNotFound)", + ir.ProtocolVersion, latestProtocolVersion) + } + + if _, err := cs.ListTools(ctx, nil); err != nil { + t.Errorf("ListTools after fallback initialize: %v", err) + } +} + +// TestInMemory_E2E_DiscoverFallback_UnsupportedProtocolVersion verifies the +// fallback path when the server explicitly rejects the discover probe with +// CodeUnsupportedProtocolVersion (the structured SEP-2575 signal). This +// exercises Path A of the fallback logic in client.go. +func TestInMemory_E2E_DiscoverFallback_UnsupportedProtocolVersion(t *testing.T) { + ctx := context.Background() + + orig := supportedProtocolVersions + supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + server := NewServer(&Implementation{Name: "strict-server", Version: "v1"}, nil) + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == methodDiscover { + return nil, &jsonrpc.Error{ + Code: CodeUnsupportedProtocolVersion, + Message: "unsupported protocol version", + } + } + return next(ctx, method, req) + } + }) + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + if err != nil { + t.Fatalf("client.Connect: %v", err) + } + defer cs.Close() + + ir := cs.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil after fallback initialize") + } + if ir.ProtocolVersion != latestProtocolVersion { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback after UnsupportedProtocolVersion)", + ir.ProtocolVersion, latestProtocolVersion) + } +} + +// TestInMemory_E2E_DiscoverPropagatesOtherErrors verifies that an unrelated +// error from the discover handler aborts Connect and does NOT silently +// fall back. +func TestInMemory_E2E_DiscoverPropagatesOtherErrors(t *testing.T) { + ctx := context.Background() + + orig := supportedProtocolVersions + supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + var sawInitialize atomic.Bool + server := NewServer(&Implementation{Name: "broken-server", Version: "v1"}, nil) + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + switch method { + case methodDiscover: + return nil, &jsonrpc.Error{ + Code: jsonrpc.CodeInternalError, + Message: "boom", + } + case methodInitialize: + sawInitialize.Store(true) + } + return next(ctx, method, req) + } + }) + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + if err == nil { + _ = cs.Close() + t.Fatal("Connect succeeded; want propagated discover error") + } + if sawInitialize.Load() { + t.Error("server received initialize; Connect should have aborted on the discover error") + } +} + +// TestClientConnectDiscover_UnsupportedVersionNegotiation verifies the +// per SEP-2575 Version Negotiation Flow: when the client probes server/discover +// with a protocol version the server doesn't implement, the server's +// UnsupportedProtocolVersionError carries Data.Supported, and the client +// selects a mutually supported version from that list and retries. +func TestClientConnectDiscover_UnsupportedVersionNegotiation(t *testing.T) { + oldSupported := supportedProtocolVersions + supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(oldSupported)...) + t.Cleanup(func() { supportedProtocolVersions = oldSupported }) + ctx := context.Background() - const ( - unsupportedClientVersion = "2099-12-31" - serverNegotiatedVersion = protocolVersion20260630 - ) + const unsupportedClientVersion = "2099-12-31" var ( - discoverCalls atomic.Int32 - gotInitialize atomic.Bool - firstRequestedVersion atomic.Value // string - secondRequestedVersion atomic.Value // string + discoverCalls atomic.Int32 + gotInitialize atomic.Bool + observedDiscoverVersion atomic.Value // string ) s := NewServer(testImpl, nil) @@ -880,37 +1127,10 @@ func TestClientConnectDiscover_UnsupportedVersionNegotiation(t *testing.T) { t.Errorf("discover req has unexpected type %T", req) return nil, jsonrpc2.ErrMethodNotFound } - requested, _ := sr.Params.GetMeta()[MetaKeyProtocolVersion].(string) - - n := discoverCalls.Add(1) - switch n { - case 1: - firstRequestedVersion.Store(requested) - data, err := json.Marshal(UnsupportedProtocolVersionData{ - Supported: []string{serverNegotiatedVersion}, - Requested: requested, - }) - if err != nil { - t.Fatalf("marshal error data: %v", err) - } - return nil, &jsonrpc.Error{ - Code: CodeUnsupportedProtocolVersion, - Message: "unsupported protocol version", - Data: data, - } - case 2: - secondRequestedVersion.Store(requested) - return &DiscoverResult{ - SupportedVersions: []string{serverNegotiatedVersion}, - Capabilities: &ServerCapabilities{ - Tools: &ToolCapabilities{ListChanged: true}, - }, - ServerInfo: &Implementation{Name: "discoverServer", Version: "v1.0.0"}, - }, nil - default: - t.Errorf("unexpected discover call #%d", n) - return nil, jsonrpc2.ErrMethodNotFound + if v, _ := sr.Params.GetMeta()[MetaKeyProtocolVersion].(string); v != "" { + observedDiscoverVersion.Store(v) } + discoverCalls.Add(1) case methodInitialize: gotInitialize.Store(true) } @@ -932,14 +1152,11 @@ func TestClientConnectDiscover_UnsupportedVersionNegotiation(t *testing.T) { } defer cs.Close() - if got, want := discoverCalls.Load(), int32(2); got != want { - t.Errorf("server/discover call count = %d, want %d", got, want) + if got, want := discoverCalls.Load(), int32(1); got != want { + t.Errorf("server/discover handler call count = %d, want %d (first probe is rejected by the dispatcher; only the retry reaches the handler)", got, want) } - if got, _ := firstRequestedVersion.Load().(string); got != unsupportedClientVersion { - t.Errorf("first discover requested version = %q, want %q", got, unsupportedClientVersion) - } - if got, _ := secondRequestedVersion.Load().(string); got != serverNegotiatedVersion { - t.Errorf("retry discover requested version = %q, want %q (server's advertised supported version)", got, serverNegotiatedVersion) + if got, _ := observedDiscoverVersion.Load().(string); got != protocolVersion20260630 { + t.Errorf("retry discover requested version = %q, want %q (highest mutually supported version)", got, protocolVersion20260630) } if gotInitialize.Load() { t.Error("legacy initialize handshake ran, but negotiated discover should have succeeded") @@ -949,7 +1166,7 @@ func TestClientConnectDiscover_UnsupportedVersionNegotiation(t *testing.T) { if ir == nil { t.Fatal("InitializeResult is nil after Connect") } - if got, want := ir.ProtocolVersion, serverNegotiatedVersion; got != want { + if got, want := ir.ProtocolVersion, protocolVersion20260630; got != want { t.Errorf("InitializeResult.ProtocolVersion = %q, want %q", got, want) } } diff --git a/mcp/mrtr_test.go b/mcp/mrtr_test.go index e1e61b2..230eb3b 100644 --- a/mcp/mrtr_test.go +++ b/mcp/mrtr_test.go @@ -526,27 +526,6 @@ func TestMultiRoundTrip_ReadResource_ManualRetry(t *testing.T) { func mustConnect(t *testing.T, s *Server, clientOpts *ClientOptions) *ClientSession { t.Helper() - // The mrtr tests require negotiating the 2026-06-30 protocol version. - // Server.discover is currently a stub that returns ErrMethodNotFound, which - // would cause Client.Connect to fall back to the legacy initialize handshake - // and downgrade the negotiated version. Install a receiving middleware that - // answers server/discover with a DiscoverResult advertising 2026-06-30 so - // the client can negotiate the new protocol via the discover path. - // - // TODO: Remove this once the server has a proper discover implementation. - s.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { - return func(ctx context.Context, method string, req Request) (Result, error) { - if method == methodDiscover { - return &DiscoverResult{ - SupportedVersions: []string{protocolVersion20260630}, - Capabilities: &ServerCapabilities{}, - ServerInfo: testImpl, - }, nil - } - return next(ctx, method, req) - } - }) - st, ct := NewInMemoryTransports() ss, err := s.Connect(t.Context(), st, nil) if err != nil { diff --git a/mcp/server.go b/mcp/server.go index 5f3cbc0..adcf698 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -766,9 +766,45 @@ func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetProm // discover is the server-side handler for the SEP-2575 "server/discover" RPC. // -// TODO: Complete implementation. -func (s *Server) discover(context.Context, *ServerRequest[*DiscoverParams]) (*DiscoverResult, error) { - return nil, jsonrpc2.ErrMethodNotFound +// It returns the protocol versions supported by the underlying transport, +// the server's capabilities, the server's identity, and the server's +// instructions, allowing clients to negotiate without performing the legacy +// initialize handshake. +func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams]) (*DiscoverResult, error) { + versions := req.Session.supportedVersions + if versions == nil { + versions = slices.Clone(supportedProtocolVersions) + } + req.Session.updateState(func(state *ServerSessionState) { + state.InitializeParams = &InitializeParams{ + ProtocolVersion: req.ProtocolVersion(), + Capabilities: req.ClientCapabilities(), + ClientInfo: req.ClientInfo(), + } + }) + return &DiscoverResult{ + SupportedVersions: versions, + Capabilities: s.capabilities(), + ServerInfo: s.impl, + Instructions: s.opts.Instructions, + }, nil +} + +// filterSupportedVersions returns the subset of [supportedProtocolVersions] +// that the Transport can serve. If t does not implement [ProtocolVersionSupporter], every +// SDK-supported version is included. +func filterSupportedVersions(t Transport) []string { + pvs, ok := t.(ProtocolVersionSupporter) + if !ok { + return slices.Clone(supportedProtocolVersions) + } + out := make([]string, 0, len(supportedProtocolVersions)) + for _, v := range supportedProtocolVersions { + if pvs.SupportsProtocolVersion(v) { + out = append(out, v) + } + } + return out } func (s *Server) listTools(_ context.Context, req *ListToolsRequest) (*ListToolsResult, error) { @@ -1093,6 +1129,11 @@ func (s *Server) Connect(ctx context.Context, t Transport, opts *ServerSessionOp return nil, err } + // Compute the protocol versions this session can serve, filtered by the + // transport's capabilities (if it implements [ProtocolVersionSupporter]). + // The list is consumed by the SEP-2575 server/discover handler. + ss.supportedVersions = filterSupportedVersions(t) + // Start keepalive before returning the session to avoid race conditions with Close. // This is safe because the spec allows sending pings before initialization (see ServerSession.handle for details). if s.opts.KeepAlive > 0 { @@ -1177,6 +1218,12 @@ type ServerSession struct { mcpConn Connection keepaliveCancel context.CancelFunc + // supportedVersions is the subset of [supportedProtocolVersions] that the + // transport can actually serve, computed once at connection time from + // [ProtocolVersionSupporter] (if implemented by the transport) and used by + // the SEP-2575 server/discover handler. + supportedVersions []string + mu sync.Mutex state ServerSessionState } @@ -1511,6 +1558,23 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, if perRequestErr != nil { return nil, perRequestErr } + // server/discover is exempt from the version-validation gate so that a + // client probing with a version the server doesn't implement can still + // learn the server's supported versions from the response (whether the + // handler returns a DiscoverResult or an UnsupportedProtocolVersionError + // with a Data.Supported payload). + if validatedMeta.usesNewProtocol && + !slices.Contains(supportedProtocolVersions, validatedMeta.initializeParams.ProtocolVersion) { + data, _ := json.Marshal(UnsupportedProtocolVersionData{ + Supported: supportedProtocolVersions, + Requested: validatedMeta.initializeParams.ProtocolVersion, + }) + return nil, &jsonrpc.Error{ + Code: CodeUnsupportedProtocolVersion, + Message: "unsupported protocol version", + Data: data, + } + } switch req.Method { case methodInitialize, methodPing, notificationInitialized, methodSetLevel: diff --git a/mcp/server_test.go b/mcp/server_test.go index 7cc780a..d2814a0 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -1100,6 +1100,10 @@ func TestServerCapabilitiesOverWire(t *testing.T) { // that opts into the new protocol via `_meta.protocolVersion` must be // rejected with `Method not found` (-32601). func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) { + orig := supportedProtocolVersions + supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) + t.Cleanup(func() { supportedProtocolVersions = orig }) + tests := []struct { name string params any @@ -1213,6 +1217,10 @@ func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) { // `ping`) all return Method not found when the request opts into the new // protocol via `_meta.protocolVersion`. func TestServerSessionHandle_RejectsRemovedMethodsOnNewProtocol(t *testing.T) { + orig := supportedProtocolVersions + supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) + t.Cleanup(func() { supportedProtocolVersions = orig }) + newProtoMeta := map[string]any{ "_meta": map[string]any{ MetaKeyProtocolVersion: protocolVersion20260630, diff --git a/mcp/shared.go b/mcp/shared.go index 1aa4290..778f57c 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -116,7 +116,6 @@ func defaultSendingMethodHandler(ctx context.Context, method string, req Request // capabilities, so any panic here is a bug. params = initParams.toV2() } - // Notifications don't have results. if strings.HasPrefix(method, "notifications/") { return nil, req.GetSession().getConn().Notify(ctx, method, params) @@ -356,6 +355,9 @@ func clientSessionMethod[P Params, R Result](f func(*ClientSession, context.Cont // MCP-specific error codes. const ( + // CodeMissingRequiredClientCapabilities is the JSON-RPC error code defined by + // SEP-2575 for MissingRequiredClientCapabilitiesError. + CodeMissingRequiredClientCapabilities = -32003 // CodeUnsupportedProtocolVersion is the JSON-RPC error code defined by // SEP-2575 for UnsupportedProtocolVersionError. CodeUnsupportedProtocolVersion = -32004 @@ -516,7 +518,7 @@ func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) { return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil } protocolVersion, ok := meta[MetaKeyProtocolVersion].(string) - if !ok { + if !ok || protocolVersion < protocolVersion20260630 { return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil } // Notifications do not carry full client identity. diff --git a/mcp/streamable.go b/mcp/streamable.go index a46c984..5bc3177 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -266,6 +266,23 @@ var enableoriginverification = mcpgodebug.Value("enableoriginverification") // The option will be removed in the 1.9.0 version of the SDK. var allowsessionsinstateless = mcpgodebug.Value("allowsessionsinstateless") +// noprotocolerrorbody is a compatibility parameter that restores the previous +// behavior of [streamableClientConn.checkResponse]. When unset (the default), +// the client always attempts to surface the underlying JSON-RPC error. +var noprotocolerrorbody = mcpgodebug.Value("noprotocolerrorbody") + +// writeJSONRPCError writes a JSON-RPC error response with the given HTTP +// status code, request ID (may be a zero ID for errors that occur before the +// request body has been parsed), and JSON-RPC error. +func writeJSONRPCError(w http.ResponseWriter, status int, id jsonrpc.ID, jerr *jsonrpc.Error) { + resp := &jsonrpc.Response{ID: id, Error: jerr} + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if data, err := jsonrpc2.EncodeMessage(resp); err == nil { + w.Write(data) + } +} + func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { // DNS rebinding protection: auto-enabled for localhost servers. // See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise @@ -291,7 +308,7 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque // // [§2.7]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#protocol-version-header protocolVersion := req.Header.Get(protocolVersionHeader) - if protocolVersion != "" && !slices.Contains(supportedProtocolVersions, protocolVersion) { + if protocolVersion != "" && !slices.Contains(supportedProtocolVersions, protocolVersion) && protocolVersion < protocolVersion20260630 { http.Error(w, fmt.Sprintf("Bad Request: Unsupported protocol version (supported versions: %s)", strings.Join(supportedProtocolVersions, ",")), http.StatusBadRequest) return } @@ -784,6 +801,16 @@ func (t *StreamableServerTransport) Connect(ctx context.Context) (Connection, er return t.connection, nil } +// The streamable HTTP transport supports every legacy SDK protocol version, +// but the SEP-2575 >= 2026-06-30 protocol is only supported when the +// transport is configured as stateless. +func (t *StreamableServerTransport) SupportsProtocolVersion(version string) bool { + if version >= protocolVersion20260630 { + return t.Stateless && slices.Contains(supportedProtocolVersions, version) + } + return slices.Contains(supportedProtocolVersions, version) +} + type streamableServerConn struct { sessionID string stateless bool @@ -916,6 +943,35 @@ func (s *stream) release() { s.done = nil // may already be nil, if the stream is done or closed } +// extractErrorStatus reports the HTTP status to send when the given +// outgoing message is a JSON-RPC error response under the SEP-2575 protocol +// (>= 2026-06-30). +// +// Per SEP-2575: +// - MethodNotFound (-32601) MUST return HTTP 404. +// - InvalidParams (-32602) and UnsupportedProtocolVersion (-32004) MUST +// return HTTP 400. +func extractErrorStatus(ctx context.Context, msg jsonrpc.Message) int { + if protocolVersionFromContext(ctx) < protocolVersion20260630 { + return 0 + } + resp, ok := msg.(*jsonrpc.Response) + if !ok || resp.Error == nil { + return 0 + } + var jerr *jsonrpc.Error + if !errors.As(resp.Error, &jerr) { + return 0 + } + switch jerr.Code { + case jsonrpc.CodeMethodNotFound: + return http.StatusNotFound + case jsonrpc.CodeInvalidParams, CodeUnsupportedProtocolVersion, CodeMissingRequiredClientCapabilities: + return http.StatusBadRequest + } + return 0 +} + // deliverLocked writes data to the stream (for SSE) or stores it in // pendingJSONMessages (for JSON mode). The eventID is used for SSE event ID; // pass "" to omit. @@ -923,11 +979,16 @@ func (s *stream) release() { // If responseTo is valid, it is removed from the requests map. When all // requests have been responded to, the done channel is closed and set to nil. // +// If overrideStatus is non-zero, data is treated as a SEP-2575 protocol-level +// error response (>= 2026-06-30): it is written as a single raw JSON-RPC +// response body with Content-Type: application/json and HTTP status +// overrideStatus. +// // Returns true if the stream is now done (all requests have been responded to). // The done value is always accurate, even if an error is returned. // // s.mu must be held when calling this method. -func (s *stream) deliverLocked(data []byte, eventID string, responseTo jsonrpc.ID) (done bool, err error) { +func (s *stream) deliverLocked(data []byte, eventID string, responseTo jsonrpc.ID, overrideStatus int) (done bool, err error) { // First, record the response. We must do this *before* returning an error // below, as even if the stream is disconnected we want to update our // accounting. @@ -942,6 +1003,17 @@ func (s *stream) deliverLocked(data []byte, eventID string, responseTo jsonrpc.I if done { defer func() { close(s.done); s.done = nil }() } + // SEP-2575 protocol-level error override: write the error as a raw + // JSON-RPC response with the spec-mandated HTTP status, bypassing any + // SSE framing. + if overrideStatus != 0 { + s.w.Header().Set("Content-Type", "application/json") + s.w.WriteHeader(overrideStatus) + if _, err := s.w.Write(data); err != nil { + return done, err + } + return done, nil + } // Try to write to the response. // // If we get here, the request is still hanging (because s.done != nil @@ -1301,6 +1373,13 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // the HTTP request. If we didn't do this, a request with a bad method or // missing ID could be silently swallowed. if _, err := checkRequest(jreq, serverMethodInfos); err != nil { + if headerVersion >= protocolVersion20260630 && errors.Is(err, jsonrpc2.ErrNotHandled) && jreq.IsCall() { + writeJSONRPCError(w, http.StatusNotFound, jreq.ID, &jsonrpc.Error{ + Code: jsonrpc.CodeMethodNotFound, + Message: err.Error(), + }) + return + } http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -1324,7 +1403,10 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques metaVersion, _ = meta[MetaKeyProtocolVersion].(string) } if protocolVersion >= protocolVersion20260630 || metaVersion != "" { - if !c.stateless { + // server/discover is exempt from the stateful + // rejection as it should learn about the supported protocols from the + // DiscoverResult response. + if !c.stateless && jreq.Method != methodDiscover { http.Error(w, fmt.Sprintf( "Bad Request: protocol version %q is only supported on stateless HTTP servers (set StreamableHTTPOptions.Stateless = true)", protocolVersion), @@ -1332,18 +1414,31 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques return } if headerVersion == "" { - http.Error(w, fmt.Sprintf( - "Bad Request: %s header is required for requests carrying %q", - protocolVersionHeader, MetaKeyProtocolVersion), - http.StatusBadRequest) + writeJSONRPCError(w, http.StatusBadRequest, jreq.ID, &jsonrpc.Error{ + Code: CodeHeaderMismatch, + Message: fmt.Sprintf( + "%s header is required for requests carrying %q", + protocolVersionHeader, MetaKeyProtocolVersion), + }) + return + } + if metaVersion == "" { + writeJSONRPCError(w, http.StatusBadRequest, jreq.ID, &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidParams, + Message: fmt.Sprintf( + "missing or invalid _meta field %q", + MetaKeyProtocolVersion), + }) return } if headerVersion != metaVersion { - http.Error(w, fmt.Sprintf( - "Bad Request: %s header %q does not match request %s %q", - protocolVersionHeader, headerVersion, - MetaKeyProtocolVersion, metaVersion), - http.StatusBadRequest) + writeJSONRPCError(w, http.StatusBadRequest, jreq.ID, &jsonrpc.Error{ + Code: CodeHeaderMismatch, + Message: fmt.Sprintf( + "%s header %q does not match request %s %q", + protocolVersionHeader, headerVersion, + MetaKeyProtocolVersion, metaVersion), + }) return } } @@ -1457,7 +1552,10 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques stream.pendingJSONMessages = []json.RawMessage{} } else { // SSE mode: write a priming event if supported. - if c.eventStore != nil && effectiveVersion >= protocolVersion20251125 { + // + // SEP-2575 removes Last-Event-ID-based resumable streams for protocol + // version >= 2026-06-30. + if c.eventStore != nil && effectiveVersion >= protocolVersion20251125 && effectiveVersion < protocolVersion20260630 { // Write a priming event, as defined by [§2.1.6] of the spec. // // [§2.1.6]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server @@ -1646,7 +1744,12 @@ func (c *streamableServerConn) Write(ctx context.Context, msg jsonrpc.Message) e eventID = formatEventID(s.id, s.lastIdx+1) } - done, err := s.deliverLocked(data, eventID, responseTo) + // SEP-2575: map protocol-level JSON-RPC error codes to HTTP status codes + // on the new protocol (>= 2026-06-30). When non-zero, deliverLocked will + // write the body as raw application/json with the override status. + overrideStatus := extractErrorStatus(ctx, msg) + + done, err := s.deliverLocked(data, eventID, responseTo, overrideStatus) if err != nil { errs = append(errs, err) } else { @@ -1846,7 +1949,7 @@ func (c *streamableClientConn) sessionUpdated(state clientSessionState) { c.initializedResult = state.InitializeResult c.mu.Unlock() - // When the protocol version is >= 2026-06-30, the standalone HTTP GET + // Under SEP-2575 (protocol version >= 2026-06-30) the standalone HTTP GET // SSE stream is removed. if state.InitializeResult == nil || state.InitializeResult.ProtocolVersion >= protocolVersion20260630 { @@ -2271,18 +2374,24 @@ func (c *streamableClientConn) checkResponse(ctx context.Context, requestSummary return fmt.Errorf("%w: %s: %v", jsonrpc2.ErrRejected, requestSummary, http.StatusText(resp.StatusCode)) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - // Read the body and if we can detect vPre servers that - // reject "server/discover" as unsupported method with a plain HTTP 400, - // then return jsonrpc2.ErrMethodNotFound or jsonrpc2.ErrUnsupportedProtocolVersion to trigger the fallback. - protocolVersion := protocolVersionFromContext(ctx) - if protocolVersion != "" && protocolVersion >= protocolVersion20260630 { - body, _ := io.ReadAll(resp.Body) - if strings.Contains(string(body), fmt.Sprintf("%s: %q unsupported", jsonrpc2.ErrNotHandled, methodDiscover)) { - return fmt.Errorf("%s: %w: %v", requestSummary, jsonrpc2.ErrMethodNotFound, http.StatusText(resp.StatusCode)) - } - if strings.Contains(string(body), "Unsupported protocol version") { - return fmt.Errorf("%s: %w: %v", requestSummary, jsonrpc2.ErrUnsupportedProtocolVersion, http.StatusText(resp.StatusCode)) - } + // By default, always try to decode the body and surface the underlying + // JSON-RPC error (or detect vPre servers that reject "server/discover" + // as an unsupported method with a plain HTTP 400) regardless of the + // negotiated protocol version. Setting MCPGODEBUG=noprotocolerrorbody=1 + // restores the previous behavior. + if noprotocolerrorbody == "1" { + return fmt.Errorf("%s: %v", requestSummary, http.StatusText(resp.StatusCode)) + } + body, _ := io.ReadAll(resp.Body) + msg, _ := jsonrpc.DecodeMessage(body) + if response, ok := msg.(*jsonrpc.Response); ok && response.Error != nil { + return fmt.Errorf("%s: %w: %v", requestSummary, response.Error, http.StatusText(resp.StatusCode)) + } + if strings.Contains(string(body), fmt.Sprintf("%s: %q unsupported", jsonrpc2.ErrNotHandled, methodDiscover)) { + return fmt.Errorf("%s: %w: %v", requestSummary, jsonrpc2.ErrMethodNotFound, http.StatusText(resp.StatusCode)) + } + if strings.Contains(string(body), "Unsupported protocol version") { + return fmt.Errorf("%s: %w: %v", requestSummary, jsonrpc2.ErrUnsupportedProtocolVersion, http.StatusText(resp.StatusCode)) } return fmt.Errorf("%s: %v", requestSummary, http.StatusText(resp.StatusCode)) } diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index a27696a..2efee20 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -2069,15 +2069,7 @@ func TestStreamableMcpHeaderValidationErrorFormat(t *testing.T) { }) defer handler.closeAll() - // TODO(SEP-2575): drop discoverInterceptor and hit `handler` directly - // once Server.discover returns a real DiscoverResult instead of - // MethodNotFound. See comment on discoverInterceptor for details. - wrapped := discoverInterceptor(t, handler, - []string{minVersionForStandardHeaders}, - &ServerCapabilities{Tools: &ToolCapabilities{}}, - &Implementation{Name: "testServer", Version: "v1.0.0"}, - ) - httpServer := httptest.NewServer(mustNotPanic(t, wrapped)) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() // Use the MCP client with a custom RoundTripper to inject a bad header. @@ -2239,15 +2231,7 @@ func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) { Stateless: true, }) defer handler.closeAll() - // TODO(SEP-2575): drop discoverInterceptor and hit `handler` directly - // once Server.discover returns a real DiscoverResult instead of - // MethodNotFound. See comment on discoverInterceptor for details. - wrapped := discoverInterceptor(t, handler, - []string{minVersionForStandardHeaders}, - &ServerCapabilities{Tools: &ToolCapabilities{ListChanged: true}}, - &Implementation{Name: "testServer", Version: "v1.0.0"}, - ) - httpServer := httptest.NewServer(mustNotPanic(t, wrapped)) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() var capturedHeaders http.Header @@ -2361,15 +2345,7 @@ func TestStreamableFilterValidToolsIntegration(t *testing.T) { Stateless: true, }) defer handler.closeAll() - // TODO(SEP-2575): drop discoverInterceptor and hit `handler` directly - // once Server.discover returns a real DiscoverResult instead of - // MethodNotFound. See comment on discoverInterceptor for details. - wrapped := discoverInterceptor(t, handler, - []string{minVersionForStandardHeaders}, - &ServerCapabilities{Tools: &ToolCapabilities{ListChanged: true}}, - &Implementation{Name: "testServer", Version: "v1.0.0"}, - ) - httpServer := httptest.NewServer(mustNotPanic(t, wrapped)) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) defer httpServer.Close() client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil) @@ -2382,7 +2358,9 @@ func TestStreamableFilterValidToolsIntegration(t *testing.T) { } defer session.Close() - result, err := session.ListTools(ctx, nil) + // Pass non-nil params so the SEP-2575 per-request _meta triple is + // injected; injectMeta is a no-op when params is nil. + result, err := session.ListTools(ctx, &ListToolsParams{}) if err != nil { t.Fatal(err) } @@ -2690,51 +2668,6 @@ func TestStreamableSessionTimeout(t *testing.T) { handler.mu.Unlock() } -// discoverInterceptor wraps an HTTP handler so that POST requests carrying a -// server/discover JSON-RPC request are answered with a canned DiscoverResult -// advertising the given supportedVersions. All other requests are forwarded -// to next unchanged. -// -// TODO(SEP-2575): this is a workaround for tests that need an end-to-end -// SEP-2575 session (e.g. to exercise the Mcp-Method / Mcp-Param-* request -// headers gated on protocol >= 2026-06-30) while the server-side -// Server.discover implementation still returns MethodNotFound. Once -// server-side discover is implemented, this helper can be removed and the -// tests can hit the real handler directly. -func discoverInterceptor(t *testing.T, next http.Handler, supportedVersions []string, capabilities *ServerCapabilities, serverInfo *Implementation) http.Handler { - t.Helper() - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - next.ServeHTTP(w, req) - return - } - body, err := io.ReadAll(req.Body) - req.Body.Close() - if err != nil { - http.Error(w, "failed to read body", http.StatusBadRequest) - return - } - req.Body = io.NopCloser(bytes.NewReader(body)) - msg, err := jsonrpc.DecodeMessage(body) - if err != nil { - next.ServeHTTP(w, req) - return - } - r, ok := msg.(*jsonrpc.Request) - if !ok || r.Method != methodDiscover { - next.ServeHTTP(w, req) - return - } - result := &DiscoverResult{ - SupportedVersions: supportedVersions, - Capabilities: capabilities, - ServerInfo: serverInfo, - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(result)}))) - }) -} - // mustNotPanic is a helper to enforce that test handlers do not panic (see // issue #556). func mustNotPanic(t *testing.T, h http.Handler) http.Handler { @@ -3630,3 +3563,156 @@ func TestStreamableClientUnsupportedVersionFallback(t *testing.T) { t.Errorf("Ping after fallback initialize: %v", err) } } + +// TestStreamableStateful_AcceptsDiscover verifies that a stateful HTTP server +// accepts a server/discover probe carrying MCP-Protocol-Version: 2026-06-30 +// (and the matching _meta.protocolVersion), instead of rejecting it with the +// "stateless required" 400. The SEP-2575 client flow has the client probing +// the server with the new protocol version to learn which versions are +// supported. +func TestStreamableStateful_AcceptsDiscover(t *testing.T) { + orig := supportedProtocolVersions + supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + server := NewServer(testImpl, nil) + handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + defer httpServer.Close() + + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": methodDiscover, + "params": map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260630, + MetaKeyClientInfo: map[string]any{"name": "new-proto-client", "version": "9.9"}, + MetaKeyClientCapabilities: map[string]any{"sampling": map[string]any{}}, + }, + }, + }) + 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, protocolVersion20260630) + req.Header.Set(methodHeader, methodDiscover) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody) + } + + // Parse the JSON-RPC response. The body may arrive as a bare JSON object + // or as a single SSE event depending on the Accept negotiation; both + // shapes are valid here. + jsonPayload := respBody + if i := bytes.Index(respBody, []byte("data: ")); i >= 0 { + jsonPayload = respBody[i+len("data: "):] + if j := bytes.IndexByte(jsonPayload, '\n'); j >= 0 { + jsonPayload = jsonPayload[:j] + } + } + var rpcResp struct { + Result *DiscoverResult `json:"result"` + Error *jsonrpc.Error `json:"error"` + } + if err := json.Unmarshal(jsonPayload, &rpcResp); err != nil { + t.Fatalf("unmarshal response %q: %v", respBody, err) + } + if rpcResp.Error != nil { + t.Fatalf("discover returned error: %+v (body = %s)", rpcResp.Error, respBody) + } + if rpcResp.Result == nil { + t.Fatalf("discover returned no result; body = %s", respBody) + } + if slices.Contains(rpcResp.Result.SupportedVersions, protocolVersion20260630) { + t.Errorf("DiscoverResult.SupportedVersions = %v, must not include %q on a stateful transport", + rpcResp.Result.SupportedVersions, protocolVersion20260630) + } + if len(rpcResp.Result.SupportedVersions) == 0 { + t.Errorf("DiscoverResult.SupportedVersions is empty; want at least one legacy version") + } +} + +// TestStreamableHTTP_E2E_DiscoverSuccess is a full end-to-end smoke test for +// SEP-2575 over the streamable HTTP transport. +func TestStreamableHTTP_E2E_DiscoverSuccess(t *testing.T) { + ctx := context.Background() + orig := supportedProtocolVersions + supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) + t.Cleanup(func() { supportedProtocolVersions = orig }) + + server := NewServer(&Implementation{Name: "e2e-server", Version: "v1"}, nil) + // Register a simple tool so we can prove the session is usable end-to-end. + AddTool(server, &Tool{Name: "echo", Description: "echoes its input"}, + func(_ context.Context, _ *CallToolRequest, args struct { + Msg string `json:"msg"` + }) (*CallToolResult, struct{}, error) { + return &CallToolResult{ + Content: []Content{&TextContent{Text: args.Msg}}, + }, struct{}{}, nil + }, + ) + + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return server }, + &StreamableHTTPOptions{Stateless: true}, + ) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + client := NewClient(&Implementation{Name: "e2e-client", Version: "v1"}, nil) + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + cs, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer cs.Close() + + ir := cs.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil after Connect; discover should have populated it") + } + if ir.ProtocolVersion != protocolVersion20260630 { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (negotiated via discover)", + ir.ProtocolVersion, protocolVersion20260630) + } + if ir.ServerInfo == nil || ir.ServerInfo.Name != "e2e-server" { + t.Errorf("InitializeResult.ServerInfo = %+v, want name=e2e-server", ir.ServerInfo) + } + + // Prove the session is fully usable: list tools and call one. + tools, err := cs.ListTools(ctx, nil) + if err != nil { + t.Fatalf("ListTools: %v", err) + } + if len(tools.Tools) != 1 || tools.Tools[0].Name != "echo" { + t.Errorf("ListTools = %+v, want one tool named 'echo'", tools.Tools) + } + res, err := cs.CallTool(ctx, &CallToolParams{ + Name: "echo", + Arguments: map[string]any{"msg": "hello"}, + }) + if err != nil { + t.Fatalf("CallTool: %v", err) + } + if len(res.Content) != 1 { + t.Fatalf("CallTool result content = %+v, want 1 entry", res.Content) + } + tc, ok := res.Content[0].(*TextContent) + if !ok || tc.Text != "hello" { + t.Errorf("CallTool result[0] = %+v, want TextContent{Text:\"hello\"}", res.Content[0]) + } +} diff --git a/mcp/transport.go b/mcp/transport.go index ea44747..f67c43d 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -48,6 +48,19 @@ type Transport interface { Connect(ctx context.Context) (Connection, error) } +// ProtocolVersionSupporter is an optional capability that a [Transport] may +// implement to declare which MCP protocol versions it can serve. +// +// [Server.Connect] consults this interface to filter the +// list of versions advertised in server/discover responses. Transports that +// do not implement this interface are assumed to support every protocol +// version known to the SDK. +type ProtocolVersionSupporter interface { + // SupportsProtocolVersion reports whether the transport can serve + // requests using the given protocol version. + SupportsProtocolVersion(version string) bool +} + // A Connection is a logical bidirectional JSON-RPC connection. type Connection interface { // Read reads the next message to process off the connection. From 88c58c3006589fc66845da2e74487e44b7833ea4 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Wed, 10 Jun 2026 16:13:27 +0200 Subject: [PATCH 298/418] mcp: remove outdated comment (#1003) --- mcp/server.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index adcf698..912dea9 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1558,11 +1558,7 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, if perRequestErr != nil { return nil, perRequestErr } - // server/discover is exempt from the version-validation gate so that a - // client probing with a version the server doesn't implement can still - // learn the server's supported versions from the response (whether the - // handler returns a DiscoverResult or an UnsupportedProtocolVersionError - // with a Data.Supported payload). + if validatedMeta.usesNewProtocol && !slices.Contains(supportedProtocolVersions, validatedMeta.initializeParams.ProtocolVersion) { data, _ := json.Marshal(UnsupportedProtocolVersionData{ From 4dc99b48e1da72ceae65ab72893a704c709609e3 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Mon, 15 Jun 2026 09:47:42 +0200 Subject: [PATCH 299/418] mcp: Remove resumability and introduce MissingRequiredClientCapability error data (#1005) Fixes #966 --- mcp/protocol.go | 15 +++++++++++++++ mcp/shared.go | 2 ++ mcp/streamable.go | 19 ++++++++++++++----- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/mcp/protocol.go b/mcp/protocol.go index 4ba8600..acc7ec4 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -2116,3 +2116,18 @@ type UnsupportedProtocolVersionData struct { // Requested is the protocol version the client asked for. Requested string `json:"requested"` } + +// MissingRequiredClientCapabilityData is the SEP-2575 payload carried in the +// `data` field of a JSON-RPC error response with code +// [CodeMissingRequiredClientCapabilities]. The server uses it to indicate +// which client capabilities are required to process the request but were not +// declared by the client in its per-request `_meta` field. +// +// Handlers that require a specific client capability should inspect the +// per-request [ServerRequest.ClientCapabilities] and return a JSON-RPC error +// populated with this structure when the required capability is missing. +type MissingRequiredClientCapabilityData struct { + // RequiredCapabilities is the set of capabilities the server requires + // from the client to process the request. + RequiredCapabilities *ClientCapabilities `json:"requiredCapabilities"` +} diff --git a/mcp/shared.go b/mcp/shared.go index 778f57c..ae1ee3c 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -585,6 +585,8 @@ type RequestExtra struct { // to configure the reconnection delay. // // [SEP-1699]: https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699 + // This mechanism is deprecated in protocol version 2026-06-30 as the resumability + // feature is removed. CloseSSEStream func(CloseSSEStreamArgs) } diff --git a/mcp/streamable.go b/mcp/streamable.go index 5bc3177..8ff9cd1 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1059,7 +1059,7 @@ func (s *stream) doneLocked() bool { } func (c *streamableServerConn) newStream(ctx context.Context, requests map[jsonrpc.ID]struct{}, id string) (*stream, error) { - if c.eventStore != nil { + if c.eventStore != nil && protocolVersionFromContext(ctx) < protocolVersion20260630 { if err := c.eventStore.Open(ctx, c.sessionID, id); err != nil { return nil, err } @@ -1366,14 +1366,13 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques tokenInfo := auth.TokenInfoFromContext(req.Context()) isInitialize := false var initializeProtocolVersion string - headerVersion := protocolVersionFromContext(req.Context()) for _, msg := range incoming { if jreq, ok := msg.(*jsonrpc.Request); ok { // Preemptively check that this is a valid request, so that we can fail // the HTTP request. If we didn't do this, a request with a bad method or // missing ID could be silently swallowed. if _, err := checkRequest(jreq, serverMethodInfos); err != nil { - if headerVersion >= protocolVersion20260630 && errors.Is(err, jsonrpc2.ErrNotHandled) && jreq.IsCall() { + if protocolVersion >= protocolVersion20260630 && errors.Is(err, jsonrpc2.ErrNotHandled) && jreq.IsCall() { writeJSONRPCError(w, http.StatusNotFound, jreq.ID, &jsonrpc.Error{ Code: jsonrpc.CodeMethodNotFound, Message: err.Error(), @@ -1403,6 +1402,9 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques metaVersion, _ = meta[MetaKeyProtocolVersion].(string) } if protocolVersion >= protocolVersion20260630 || metaVersion != "" { + // Extract again the protcol version from the context to see what the client + // is advertising in the Mcp-Protocol-Version HTTP header. + headerVersion := protocolVersionFromContext(req.Context()) // server/discover is exempt from the stateful // rejection as it should learn about the supported protocols from the // DiscoverResult response. @@ -1452,6 +1454,12 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // See the doc for CloseSSEStream: allow the request handler to // explicitly close the ongoing stream. jreq.Extra.(*RequestExtra).CloseSSEStream = func(args CloseSSEStreamArgs) { + // This mechanism was designed to trigger client reconnection with + // Last-Event-ID for server-initiated disconnect scenarios. It is + // deprecated in protocol version 2026-06-30. + if protocolVersion >= protocolVersion20260630 { + return + } c.mu.Lock() streamID, ok := c.requestStreams[jreq.ID] var stream *stream @@ -1729,7 +1737,8 @@ func (c *streamableServerConn) Write(ctx context.Context, msg jsonrpc.Message) e // pushing down into the delivery layer. delivered := false var errs []error - if c.eventStore != nil { + protocolVersion := protocolVersionFromContext(ctx) + if c.eventStore != nil && protocolVersion < protocolVersion20260630 { if err := c.eventStore.Append(ctx, c.sessionID, s.id, data); err != nil { errs = append(errs, err) } else { @@ -1740,7 +1749,7 @@ func (c *streamableServerConn) Write(ctx context.Context, msg jsonrpc.Message) e // Compute eventID for SSE streams with event store. // Use s.lastIdx + 1 because deliverLocked increments before writing. var eventID string - if c.eventStore != nil { + if c.eventStore != nil && protocolVersion < protocolVersion20260630 { eventID = formatEventID(s.id, s.lastIdx+1) } From bcbfe0ce3687dbec629c33756e95ce555b6d1b7b Mon Sep 17 00:00:00 2001 From: Piyush Bag Date: Tue, 16 Jun 2026 00:57:18 -0700 Subject: [PATCH 300/418] fix(jsonrpc2): decode requests when method key is present (#1000) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes #976. `DecodeMessage` used `msg.Method != ""` to classify messages. A wire payload with `"method":""` was decoded as a **response**, so stdio servers dropped it without a correlated JSON-RPC error. ## Approach Decode into a `wireDecode` struct whose `method` field is `json.RawMessage`. Treat the message as a **request** when the `method` key is **present on the wire** (including `""`), which matches JSON-RPC semantics. This revision: - Uses a **single JSON parse** per message (dedicated decode struct, no secondary fields-map unmarshal) - Documents why `json.RawMessage` is used for `method` - Adds regression coverage so normal responses and id-only payloads without a `method` key stay on the response path ## Test plan - [x] `go test ./internal/jsonrpc2/... -v` - [x] `go test ./mcp/... -run TestIOConnRead_EmptyMethod -v` - [x] `go test ./... -count=1` - [x] `go test -race ./internal/jsonrpc2/... -count=1` - [x] Server conformance (`npx @modelcontextprotocol/conformance server --url http://127.0.0.1:3000/mcp`) — 40 passed, 0 failed - [x] Client conformance (`./scripts/client-conformance.sh`) — 217 passed, 0 failed ## Scope note This PR addresses the primary repro in #976 (empty `method` with valid `id`). Other cases listed in the issue (`id: null`, structurally invalid envelopes) are unchanged. Co-authored-by: Guglielmo Colombo --- internal/jsonrpc2/messages.go | 26 ++++++++++++++++---- internal/jsonrpc2/wire_test.go | 45 ++++++++++++++++++++++++++++++++++ mcp/transport_test.go | 23 +++++++++++++++++ 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/internal/jsonrpc2/messages.go b/internal/jsonrpc2/messages.go index 77c9531..8b96770 100644 --- a/internal/jsonrpc2/messages.go +++ b/internal/jsonrpc2/messages.go @@ -171,8 +171,20 @@ func EncodeIndent(msg Message, prefix, indent string) ([]byte, error) { return bytes.TrimRight(buf.Bytes(), "\n"), nil } +// wireDecode is the decode form of [wireCombined]. Method is a [json.RawMessage] +// so we can tell whether the "method" key was present on the wire, including +// when its value is the empty string (see go-sdk#976). +type wireDecode struct { + VersionTag string `json:"jsonrpc"` + ID any `json:"id,omitempty"` + Method json.RawMessage `json:"method"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *WireError `json:"error,omitempty"` +} + func DecodeMessage(data []byte) (Message, error) { - msg := wireCombined{} + msg := wireDecode{} if err := internaljson.Unmarshal(data, &msg); err != nil { return nil, fmt.Errorf("unmarshaling jsonrpc message: %w", err) } @@ -183,15 +195,19 @@ func DecodeMessage(data []byte) (Message, error) { if err != nil { return nil, err } - if msg.Method != "" { - // has a method, must be a call + if len(msg.Method) > 0 { + // The "method" key was present. Decode its value (including ""). + var method string + if err := internaljson.Unmarshal(msg.Method, &method); err != nil { + return nil, fmt.Errorf("unmarshaling jsonrpc message: %w", err) + } return &Request{ - Method: msg.Method, + Method: method, ID: id, Params: msg.Params, }, nil } - // no method, should be a response + // no method key, should be a response if !id.IsValid() { return nil, ErrInvalidRequest } diff --git a/internal/jsonrpc2/wire_test.go b/internal/jsonrpc2/wire_test.go index 6f576f0..cf7e2b8 100644 --- a/internal/jsonrpc2/wire_test.go +++ b/internal/jsonrpc2/wire_test.go @@ -102,6 +102,51 @@ func newResponse(id any, result any, rerr error) jsonrpc2.Message { return msg } +// go-sdk#976: empty method must decode as a request (encode omits empty method). +func TestDecodeEmptyMethodRequest(t *testing.T) { + encoded := []byte(`{"jsonrpc":"2.0","id":5,"method":"","params":{}}`) + msg, err := jsonrpc2.DecodeMessage(encoded) + if err != nil { + t.Fatal(err) + } + req, ok := msg.(*jsonrpc2.Request) + if !ok { + t.Fatalf("message type = %T, want *jsonrpc2.Request", msg) + } + if req.Method != "" { + t.Errorf("Method = %q, want empty string", req.Method) + } + if req.ID != jsonrpc2.Int64ID(5) { + t.Errorf("ID = %v, want 5", req.ID.Raw()) + } + if !req.IsCall() { + t.Error("empty method with id=5 should be a call") + } +} + +func TestDecodeResponseUnchanged(t *testing.T) { + encoded := []byte(`{"jsonrpc":"2.0","id":2,"result":{}}`) + msg, err := jsonrpc2.DecodeMessage(encoded) + if err != nil { + t.Fatal(err) + } + if _, ok := msg.(*jsonrpc2.Response); !ok { + t.Fatalf("message type = %T, want *jsonrpc2.Response", msg) + } +} + +// Messages with an id but no "method" key are responses, not malformed requests. +func TestDecodeIDOnlyMessageIsResponse(t *testing.T) { + encoded := []byte(`{"jsonrpc":"2.0","id":5}`) + msg, err := jsonrpc2.DecodeMessage(encoded) + if err != nil { + t.Fatal(err) + } + if _, ok := msg.(*jsonrpc2.Response); !ok { + t.Fatalf("message type = %T, want *jsonrpc2.Response", msg) + } +} + func checkJSON(t *testing.T, got, want []byte) { // compare the compact form, to allow for formatting differences g := &bytes.Buffer{} diff --git a/mcp/transport_test.go b/mcp/transport_test.go index 515b8c1..730400e 100644 --- a/mcp/transport_test.go +++ b/mcp/transport_test.go @@ -124,3 +124,26 @@ func TestIOConnRead(t *testing.T) { }) } } + +// go-sdk#976: stdio must surface empty-method calls as requests, not responses. +func TestIOConnRead_EmptyMethod(t *testing.T) { + tr := newIOConn(rwc{ + rc: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":5,"method":"","params":{}}`)), + }) + t.Cleanup(func() { tr.Close() }) + + msg, err := tr.Read(context.Background()) + if err != nil { + t.Fatalf("ioConn.Read() error = %v", err) + } + req, ok := msg.(*jsonrpc.Request) + if !ok { + t.Fatalf("message type = %T, want *jsonrpc.Request", msg) + } + if req.Method != "" { + t.Errorf("Method = %q, want empty string", req.Method) + } + if req.ID != jsonrpc2.Int64ID(5) { + t.Errorf("ID = %v, want 5", req.ID.Raw()) + } +} From 91c010f664f619ab77de6f0cbb9de4d32eb28d97 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Tue, 16 Jun 2026 11:20:53 +0200 Subject: [PATCH 301/418] mcp: fix http-standardization (SEP-2243) (#1010) Fixes #905 --- mcp/streamable_headers.go | 292 +++++++++++++++++-------- mcp/streamable_headers_test.go | 382 ++++++++++++++++++++++++++++++--- 2 files changed, 557 insertions(+), 117 deletions(-) diff --git a/mcp/streamable_headers.go b/mcp/streamable_headers.go index fba1b4d..af9ff39 100644 --- a/mcp/streamable_headers.go +++ b/mcp/streamable_headers.go @@ -11,7 +11,9 @@ import ( "errors" "fmt" "log/slog" + "math" "net/http" + "strconv" "strings" internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" @@ -70,58 +72,127 @@ func unmarshalSchemaProperties(schema any) map[string]headerSchemaProperty { return s.Properties } -// extractParamHeaderAnnotations returns a map of parameter name to header name -// for all properties in the tool's InputSchema that have an x-mcp-header -// annotation. -func extractParamHeaderAnnotations(tool *Tool) map[string]string { +// paramHeaderBinding maps a (possibly nested) input-schema property to the +// HTTP header it carries. +type paramHeaderBinding struct { + Path []string + Header string +} + +// extractParamHeaderAnnotations returns the bindings for every property in +// the tool's InputSchema that has an x-mcp-header annotation +func extractParamHeaderAnnotations(tool *Tool) []paramHeaderBinding { props := unmarshalSchemaProperties(tool.InputSchema) if len(props) == 0 { return nil } - result := make(map[string]string) - for propName, prop := range props { - var headerName string - if err := json.Unmarshal(prop.XMCPHeader, &headerName); err != nil || headerName == "" { - continue - } - result[propName] = headerName - } + var result []paramHeaderBinding + result = collectParamHeaderAnnotations(props, nil, result) if len(result) == 0 { return nil } return result } -// primitiveToString conversion. -// Returns false in the second return value if the argument is not a primitive value. -func primitiveToString(value any) (string, bool) { - switch v := value.(type) { - case string: - return v, true - case float64: - return fmt.Sprintf("%g", v), true - case bool: - return fmt.Sprintf("%t", v), true - default: - return "", false +// collectParamHeaderAnnotations walks the schema properties and records every +// x-mcp-header annotation it finds, keyed by the property-name path. +func collectParamHeaderAnnotations(props map[string]headerSchemaProperty, prefix []string, out []paramHeaderBinding) []paramHeaderBinding { + for propName, prop := range props { + path := make([]string, len(prefix)+1) + copy(path, prefix) + path[len(prefix)] = propName + + var headerName string + if err := json.Unmarshal(prop.XMCPHeader, &headerName); err == nil && headerName != "" { + out = append(out, paramHeaderBinding{Path: path, Header: headerName}) + } + if len(prop.Properties) > 0 { + out = collectParamHeaderAnnotations(prop.Properties, path, out) + } } + return out } -// unmarshalPrimitive unmarshals a JSON value into a Go primitive -// (string, float64, or bool). Returns nil for non-primitive types. +// lookupArgument navigates the arguments object using the given property-name +// path and returns the raw JSON value at that location. It reports whether +// the value was found. +func lookupArgument(args map[string]json.RawMessage, path []string) (json.RawMessage, bool) { + if len(path) == 0 { + return nil, false + } + cur, ok := args[path[0]] + if !ok { + return nil, false + } + for _, part := range path[1:] { + var obj map[string]json.RawMessage + if err := internaljson.Unmarshal(cur, &obj); err != nil { + return nil, false + } + cur, ok = obj[part] + if !ok { + return nil, false + } + } + return cur, true +} + +// maxSafeInteger and minSafeInteger bound the integer values that can be +// faithfully represented as IEEE-754 double-precision floats. +const ( + maxSafeInteger = 1<<53 - 1 // 2^53 - 1 = 9007199254740991 + minSafeInteger = -(1<<53 - 1) // -(2^53 - 1) = -9007199254740991 +) + +// unmarshalPrimitive unmarshals a JSON value into the Go representation used +// for x-mcp-header processing per SEP-2243: +// +// - JSON string -> string +// - JSON boolean -> bool +// - JSON integer (within the JavaScript safe-integer range) -> int64 +// +// JSON numbers that are non-integers (have a fractional part, NaN, or ±Inf) +// or integers outside the safe range are rejected because the `number` type +// is not permitted for x-mcp-header parameters; only integer, string, boolean +// are allowed. func unmarshalPrimitive(raw json.RawMessage) any { var val any if err := internaljson.Unmarshal(raw, &val); err != nil { return nil } - switch val.(type) { - case string, float64, bool: - return val + switch v := val.(type) { + case string, bool: + return v + case float64: + if math.IsNaN(v) || math.IsInf(v, 0) || v != math.Trunc(v) { + return nil + } + if v < minSafeInteger || v > maxSafeInteger { + return nil + } + return int64(v) default: return nil } } +// primitiveToString formats an x-mcp-header value (as produced by +// [unmarshalPrimitive]) to its canonical header string representation per +// SEP-2243. Returns false if value is not one of the permitted primitive +// types (string, bool, int64). +func primitiveToString(value any) (string, bool) { + switch v := value.(type) { + case string: + return v, true + case bool: + return fmt.Sprintf("%t", v), true + case int64: + return strconv.FormatInt(v, 10), true + default: + return "", false + } +} + // setStandardHeaders populates standard MCP headers. // It requires the protocol version header to be set. func setStandardHeaders(ctx context.Context, header http.Header, msg jsonrpc.Message) { @@ -164,8 +235,8 @@ func generateParamHeaders(tool *Tool, params json.RawMessage) map[string]string } res := make(map[string]string) - for paramName, headerName := range paramHeaders { - argRaw, ok := raw.Arguments[paramName] + for _, b := range paramHeaders { + argRaw, ok := lookupArgument(raw.Arguments, b.Path) if !ok { continue } @@ -180,7 +251,7 @@ func generateParamHeaders(tool *Tool, params json.RawMessage) map[string]string if !ok { continue } - res[paramHeaderPrefix+headerName] = encoded + res[paramHeaderPrefix+b.Header] = encoded } return res } @@ -201,64 +272,84 @@ func filterValidTools(logger *slog.Logger, tools []*Tool) []*Tool { } // validateParamHeaderAnnotations checks that a tool's x-mcp-header annotations -// are valid. +// are valid. Annotations may appear on properties at any nesting +// depth within the inputSchema and must be unique across all of them. func validateParamHeaderAnnotations(tool *Tool) error { props := unmarshalSchemaProperties(tool.InputSchema) if len(props) == 0 { return nil } - seen := make(map[string]bool) + return validateParamHeadersIn(props, "", seen) +} + +func validateParamHeadersIn(props map[string]headerSchemaProperty, prefix string, seen map[string]bool) error { for propName, prop := range props { - if err := checkForNestedHeaders(prop, propName); err != nil { - return err + path := propName + if prefix != "" { + path = prefix + "." + propName } - if prop.XMCPHeader == nil { - continue + if prop.XMCPHeader != nil { + if prop.Type != "string" && prop.Type != "integer" && prop.Type != "boolean" { + return fmt.Errorf("property %q: x-mcp-header can only be applied to primitive types (integer, string, boolean), got %q", path, prop.Type) + } + var headerName string + if err := json.Unmarshal(prop.XMCPHeader, &headerName); err != nil || headerName == "" { + return fmt.Errorf("property %q: x-mcp-header must be a non-empty string", path) + } + if err := validateHeaderName(headerName); err != nil { + return fmt.Errorf("property %q: %w", path, err) + } + lower := strings.ToLower(headerName) + if seen[lower] { + return fmt.Errorf("property %q: duplicate x-mcp-header value %q (case-insensitive)", path, headerName) + } + seen[lower] = true } - var headerName string - if err := json.Unmarshal(prop.XMCPHeader, &headerName); err != nil || headerName == "" { - return fmt.Errorf("property %q: x-mcp-header must be a non-empty string", propName) - } - if err := validateHeaderName(headerName); err != nil { - return fmt.Errorf("property %q: %w", propName, err) - } - lower := strings.ToLower(headerName) - if seen[lower] { - return fmt.Errorf("property %q: duplicate x-mcp-header value %q (case-insensitive)", propName, headerName) - } - seen[lower] = true - - if prop.Type != "string" && prop.Type != "number" && prop.Type != "integer" && prop.Type != "boolean" { - return fmt.Errorf("property %q: x-mcp-header can only be applied to primitive types, got %v", propName, prop.Type) + if len(prop.Properties) > 0 { + if err := validateParamHeadersIn(prop.Properties, path, seen); err != nil { + return err + } } } return nil } -func checkForNestedHeaders(prop headerSchemaProperty, path string) error { - for propName, nested := range prop.Properties { - if nested.XMCPHeader != nil { - return fmt.Errorf("property %q: x-mcp-header cannot be applied to nested properties", path+"."+propName) - } - if err := checkForNestedHeaders(nested, path+"."+propName); err != nil { - return err - } - } - return nil -} - -// validateHeaderName checks that a header name contains only valid -// ASCII characters (excluding space and ':'). +// validateHeaderName checks that a header name matches the HTTP field-name +// token syntax (1*tchar). func validateHeaderName(name string) error { + if name == "" { + return fmt.Errorf("x-mcp-header value must be a non-empty string") + } for _, c := range name { - if c <= 0x20 || c > 0x7E || c == ':' { + if !isTChar(c) { return fmt.Errorf("x-mcp-header value %q contains invalid character %q", name, c) } } return nil } +// isTChar reports whether c is a valid HTTP token character (tchar) +// +// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / +// "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA +func isTChar(c rune) bool { + switch { + case c >= '0' && c <= '9': + return true + case c >= 'A' && c <= 'Z': + return true + case c >= 'a' && c <= 'z': + return true + } + switch c { + case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', + '^', '_', '`', '|', '~': + return true + } + return false +} + func validateMcpHeaders(header http.Header, msg jsonrpc.Message, toolLookup func(string) (*serverTool, bool)) error { protocolVersion := header.Get(protocolVersionHeader) if protocolVersion == "" || protocolVersion < minVersionForStandardHeaders { @@ -315,20 +406,20 @@ func validateParamHeaders(header http.Header, msg *jsonrpc.Request, tool *Tool) return nil } - for paramName, headerName := range paramHeaders { - fullHeader := paramHeaderPrefix + headerName + for _, b := range paramHeaders { + fullHeader := paramHeaderPrefix + b.Header headerVal := header.Get(fullHeader) - argRaw, argExists := raw.Arguments[paramName] + argRaw, argExists := lookupArgument(raw.Arguments, b.Path) if !argExists || string(argRaw) == "null" { if headerVal != "" { - return fmt.Errorf("header mismatch: unexpected %s header for absent or null parameter %q", fullHeader, paramName) + return fmt.Errorf("header mismatch: unexpected %s header for absent or null parameter %q", fullHeader, strings.Join(b.Path, ".")) } continue } if headerVal == "" { - return fmt.Errorf("header mismatch: missing %s header for parameter %q", fullHeader, paramName) + return fmt.Errorf("header mismatch: missing %s header for parameter %q", fullHeader, strings.Join(b.Path, ".")) } decoded, ok := decodeHeaderValue(headerVal) @@ -338,26 +429,44 @@ func validateParamHeaders(header http.Header, msg *jsonrpc.Request, tool *Tool) bodyVal := unmarshalPrimitive(argRaw) if bodyVal == nil { - return fmt.Errorf("header mismatch: %s header present but body parameter %q is not a primitive type", fullHeader, paramName) - } - expected, ok := primitiveToString(bodyVal) - if !ok { - return fmt.Errorf("header mismatch: %s header present but body parameter %q is not a primitive type", fullHeader, paramName) + return fmt.Errorf("header mismatch: %s header present but body parameter %q is not a primitive type", fullHeader, strings.Join(b.Path, ".")) } - // TODO: String comparison may not work ideally for numbers - if decoded != expected { + if !primitiveEqual(decoded, bodyVal) { return fmt.Errorf("header mismatch: %s header value '%s' does not match body value", fullHeader, headerVal) } } return nil } +// primitiveEqual reports whether the (decoded) header string equals the +// JSON-derived body value. +func primitiveEqual(headerStr string, bodyVal any) bool { + if bodyInt, ok := bodyVal.(int64); ok { + headerNum, err := strconv.ParseFloat(headerStr, 64) + if err != nil { + return false + } + if math.IsNaN(headerNum) || math.IsInf(headerNum, 0) || headerNum != math.Trunc(headerNum) { + return false + } + if headerNum < minSafeInteger || headerNum > maxSafeInteger { + return false + } + return int64(headerNum) == bodyInt + } + expected, ok := primitiveToString(bodyVal) + if !ok { + return false + } + return headerStr == expected +} + // encodeHeaderValue converts a parameter value to an HTTP header-safe string // per the SEP-2243 encoding rules: // - string: used as-is if safe ASCII, otherwise Base64 encoded -// - number (float64): decimal string representation -// - bool: lowercase "true" or "false" +// - int64: decimal string representation +// - bool: lowercase "true" or "false" // // Values that contain non-ASCII characters, control characters, or // leading/trailing whitespace are Base64-encoded with the =?base64?...?= wrapper. @@ -383,14 +492,14 @@ func decodeHeaderValue(headerValue string) (string, bool) { return headerValue, true } - if strings.HasPrefix(strings.ToLower(headerValue), base64Prefix) && - strings.HasSuffix(headerValue, base64Suffix) { - encoded := headerValue[len(base64Prefix) : len(headerValue)-len(base64Suffix)] - decoded, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - return "", false + if encoded, ok := strings.CutPrefix(headerValue, base64Prefix); ok { + if encoded, ok = strings.CutSuffix(encoded, base64Suffix); ok { + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", false + } + return string(decoded), true } - return string(decoded), true } return headerValue, true } @@ -407,6 +516,11 @@ func requiresBase64Encoding(s string) bool { return true } } + // Per SEP-2243, plain-ASCII values that match the base64 sentinel pattern + // must also be base64-encoded to avoid ambiguity with already-encoded values. + if strings.HasPrefix(s, base64Prefix) && strings.HasSuffix(s, base64Suffix) { + return true + } return false } diff --git a/mcp/streamable_headers_test.go b/mcp/streamable_headers_test.go index 88e9f46..52d5244 100644 --- a/mcp/streamable_headers_test.go +++ b/mcp/streamable_headers_test.go @@ -519,6 +519,55 @@ func TestValidateToolParamHeaders(t *testing.T) { wantErr: true, wantErrSub: "invalid character", }, + { + name: "x-mcp-header with separator char (parens) is invalid per RFC 9110", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "X-(Region)", + }, + }, + }, + }, + wantErr: true, + wantErrSub: "invalid character", + }, + { + name: "x-mcp-header with equals sign is invalid per RFC 9110", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "Region=1", + }, + }, + }, + }, + wantErr: true, + wantErrSub: "invalid character", + }, + { + name: "x-mcp-header with all tchar specials is valid", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "!#$%&'*+-.^_`|~aZ0", + }, + }, + }, + }, + }, { name: "duplicate header names same case", tool: &Tool{ @@ -584,7 +633,7 @@ func TestValidateToolParamHeaders(t *testing.T) { wantErrSub: "primitive types", }, { - name: "x-mcp-header on number type is valid", + name: "x-mcp-header on number type is invalid", tool: &Tool{ Name: "test", InputSchema: map[string]any{ @@ -597,6 +646,8 @@ func TestValidateToolParamHeaders(t *testing.T) { }, }, }, + wantErr: true, + wantErrSub: "primitive types", }, { name: "x-mcp-header on integer type is valid", @@ -629,7 +680,7 @@ func TestValidateToolParamHeaders(t *testing.T) { }, }, { - name: "x-mcp-header on nested property inside object", + name: "x-mcp-header on nested property inside object is valid", tool: &Tool{ Name: "test", InputSchema: map[string]any{ @@ -647,11 +698,9 @@ func TestValidateToolParamHeaders(t *testing.T) { }, }, }, - wantErr: true, - wantErrSub: "nested", }, { - name: "x-mcp-header on deeply nested property", + name: "x-mcp-header on deeply nested property is valid", tool: &Tool{ Name: "test", InputSchema: map[string]any{ @@ -674,8 +723,26 @@ func TestValidateToolParamHeaders(t *testing.T) { }, }, }, + }, + { + name: "duplicate header names across nesting levels", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + "config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + }, + }, + }, + }, + }, wantErr: true, - wantErrSub: "nested", + wantErrSub: "duplicate", }, { name: "object property without nested x-mcp-header is valid", @@ -733,7 +800,7 @@ func TestValidateToolParamHeaders(t *testing.T) { wantErrSub: "primitive types", }, { - name: "jsonschema.Schema nested x-mcp-header", + name: "jsonschema.Schema nested x-mcp-header is valid", tool: &Tool{ Name: "test", InputSchema: &jsonschema.Schema{ @@ -751,8 +818,6 @@ func TestValidateToolParamHeaders(t *testing.T) { }, }, }, - wantErr: true, - wantErrSub: "nested", }, { name: "json.RawMessage valid x-mcp-header", @@ -770,6 +835,50 @@ func TestValidateToolParamHeaders(t *testing.T) { wantErr: true, wantErrSub: "primitive types", }, + { + name: "nested invalid header name is rejected", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "x-mcp-header": "Bad Header", // contains a space + }, + }, + }, + }, + }, + }, + wantErr: true, + wantErrSub: "invalid character", + }, + { + name: "nested x-mcp-header on number type is rejected", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "count": map[string]any{ + "type": "number", + "x-mcp-header": "Count", + }, + }, + }, + }, + }, + }, + wantErr: true, + wantErrSub: "primitive types", + }, } for _, tt := range tests { @@ -812,15 +921,15 @@ func TestFilterValidTools(t *testing.T) { Name: "plain", InputSchema: map[string]any{"type": "object", "properties": map[string]any{"q": map[string]any{"type": "string"}}}, } - nestedInvalid := &Tool{ - Name: "nested-invalid", + nestedValid := &Tool{ + Name: "nested-valid", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "config": map[string]any{ "type": "object", "properties": map[string]any{ - "region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + "tenant": map[string]any{"type": "string", "x-mcp-header": "TenantId"}, }, }, }, @@ -852,12 +961,13 @@ func TestFilterValidTools(t *testing.T) { }, } - result := filterValidTools(nil, []*Tool{valid, invalid, noAnnotation, nestedInvalid, validJsonSchema, invalidJsonSchema}) - if len(result) != 3 { - t.Fatalf("filterValidTools returned %d tools, want 3", len(result)) + result := filterValidTools(nil, []*Tool{valid, invalid, noAnnotation, nestedValid, validJsonSchema, invalidJsonSchema}) + if len(result) != 4 { + t.Fatalf("filterValidTools returned %d tools, want 4", len(result)) } - if result[0].Name != "valid" || result[1].Name != "plain" || result[2].Name != "valid-jsonschema" { - t.Errorf("filterValidTools returned [%s, %s, %s], want [valid, plain, valid-jsonschema]", result[0].Name, result[1].Name, result[2].Name) + if result[0].Name != "valid" || result[1].Name != "plain" || result[2].Name != "nested-valid" || result[3].Name != "valid-jsonschema" { + t.Errorf("filterValidTools returned [%s, %s, %s, %s], want [valid, plain, nested-valid, valid-jsonschema]", + result[0].Name, result[1].Name, result[2].Name, result[3].Name) } } @@ -947,13 +1057,13 @@ func TestSetStandardHeadersWithParamHeaders(t *testing.T) { }, }, { - name: "handles number argument", + name: "handles integer argument", tool: &Tool{ Name: "test", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ - "count": map[string]any{"type": "number", "x-mcp-header": "Count"}, + "count": map[string]any{"type": "integer", "x-mcp-header": "Count"}, }, }, }, @@ -965,6 +1075,23 @@ func TestSetStandardHeadersWithParamHeaders(t *testing.T) { "Mcp-Param-Count": "42", }, }, + { + name: "out-of-range integer argument produces no header", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "count": map[string]any{"type": "integer", "x-mcp-header": "Count"}, + }, + }, + }, + params: &CallToolParams{ + Name: "test", + Arguments: map[string]any{"count": float64(maxSafeInteger) + 2}, + }, + wantHeaders: nil, + }, { name: "no tool in extra does not add param headers", tool: nil, @@ -974,6 +1101,37 @@ func TestSetStandardHeadersWithParamHeaders(t *testing.T) { }, wantHeaders: nil, }, + { + name: "nested arguments resolve via dotted path", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + "tenant": map[string]any{"type": "string", "x-mcp-header": "TenantId"}, + }, + }, + }, + }, + }, + params: &CallToolParams{ + Name: "test", + Arguments: map[string]any{ + "config": map[string]any{ + "region": "us-west1", + "tenant": "acme", + }, + }, + }, + wantHeaders: map[string]string{ + "Mcp-Param-Region": "us-west1", + "Mcp-Param-TenantId": "acme", + }, + }, } for _, tt := range tests { @@ -1090,6 +1248,48 @@ func TestExtractToolParamHeaders(t *testing.T) { }, want: nil, }, + { + name: "nested x-mcp-header annotations produce path-slice bindings", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + "config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "tenant": map[string]any{"type": "string", "x-mcp-header": "TenantId"}, + "deep": map[string]any{ + "type": "object", + "properties": map[string]any{ + "flag": map[string]any{"type": "boolean", "x-mcp-header": "DeepFlag"}, + }, + }, + }, + }, + }, + }, + }, + want: map[string]string{ + "region": "Region", + "config.tenant": "TenantId", + "config.deep.flag": "DeepFlag", + }, + }, + { + name: "property name containing a dot is preserved in path", + tool: &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "user.id": map[string]any{"type": "string", "x-mcp-header": "UserId"}, + }, + }, + }, + want: map[string]string{"user.id": "UserId"}, + }, } for _, tt := range tests { @@ -1104,9 +1304,15 @@ func TestExtractToolParamHeaders(t *testing.T) { if len(got) != len(tt.want) { t.Fatalf("extractToolParamHeaders() returned %d entries, want %d", len(got), len(tt.want)) } + // Index returned bindings by joined-path for comparison; the + // expected map uses dotted-path keys for readability. + gotMap := make(map[string]string, len(got)) + for _, b := range got { + gotMap[strings.Join(b.Path, ".")] = b.Header + } for k, v := range tt.want { - if got[k] != v { - t.Errorf("extractToolParamHeaders()[%q] = %q, want %q", k, got[k], v) + if gotMap[k] != v { + t.Errorf("extractToolParamHeaders()[%q] = %q, want %q", k, gotMap[k], v) } } }) @@ -1120,10 +1326,24 @@ func TestUnmarshalPrimitive(t *testing.T) { want any }{ {"string", `"hello"`, "hello"}, - {"number", `42`, float64(42)}, - {"float", `3.14`, float64(3.14)}, {"true", `true`, true}, {"false", `false`, false}, + + // Integer JSON numbers are promoted to int64. + {"integer", `42`, int64(42)}, + {"integer zero", `0`, int64(0)}, + {"integer negative", `-7`, int64(-7)}, + {"integer max safe", `9007199254740991`, int64(maxSafeInteger)}, + {"integer min safe", `-9007199254740991`, int64(minSafeInteger)}, + // JSON serialization of an integer-valued float (e.g. "42.0") is + // still a valid integer at the value level and must be accepted. + {"integer-valued float", `42.0`, int64(42)}, + + // Non-integer numbers, out-of-range integers, and disallowed JSON + // kinds are rejected (return nil). + {"float with fraction", `3.14`, nil}, + {"integer above max safe", `9007199254740993`, nil}, + {"integer below min safe", `-9007199254740993`, nil}, {"null", `null`, nil}, {"array", `[1,2]`, nil}, {"object", `{"a":1}`, nil}, @@ -1158,15 +1378,24 @@ func TestEncodeHeaderValue(t *testing.T) { {"string with carriage return", "line1\r\nline2", "=?base64?bGluZTENCmxpbmUy?=", true}, {"string with leading tab", "\tindented", "=?base64?CWluZGVudGVk?=", true}, - // Numbers - {"integer", float64(42), "42", true}, - {"float", float64(3.14159), "3.14159", true}, + // Sentinel pattern collisions: plain-ASCII values that match the base64 + // sentinel pattern must also be base64-encoded to avoid ambiguity. + {"sentinel collision literal", "=?base64?literal?=", "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?=", true}, + {"sentinel collision empty", "=?base64??=", "=?base64?PT9iYXNlNjQ/Pz0=?=", true}, + // Uppercase sentinel does NOT collide (case-sensitive markers). + {"uppercase pseudo-sentinel passes through", "=?BASE64?abc?=", "=?BASE64?abc?=", true}, + + {"integer", int64(42), "42", true}, + {"integer zero", int64(0), "0", true}, + {"integer negative", int64(-7), "-7", true}, + {"integer max safe", int64(maxSafeInteger), "9007199254740991", true}, + {"integer min safe", int64(minSafeInteger), "-9007199254740991", true}, // Booleans {"true", true, "true", true}, {"false", false, "false", true}, - // Unsupported types + {"raw float64 rejected", float64(42), "", false}, {"nil", nil, "", false}, {"slice", []string{"a"}, "", false}, } @@ -1196,7 +1425,10 @@ func TestDecodeHeaderValue(t *testing.T) { {"valid base64", "=?base64?SGVsbG8=?=", "Hello", true}, {"non-ASCII decoded", "=?base64?5pel5pys6Kqe?=", "日本語", true}, {"leading space decoded", "=?base64?IHVzLXdlc3Qx?=", " us-west1", true}, - {"case-insensitive prefix", "=?BASE64?SGVsbG8=?=", "Hello", true}, + // Per SEP-2243, the base64 sentinel markers are case-sensitive: an + // uppercase prefix is treated as a literal value, not a base64 marker. + {"uppercase prefix is literal", "=?BASE64?SGVsbG8=?=", "=?BASE64?SGVsbG8=?=", true}, + {"mixed case prefix is literal", "=?Base64?SGVsbG8=?=", "=?Base64?SGVsbG8=?=", true}, {"invalid base64 chars", "=?base64?SGVs!!!bG8=?=", "", false}, // Missing prefix or suffix: treated as literal values, not base64 {"missing prefix", "SGVsbG8=", "SGVsbG8=", true}, @@ -1225,6 +1457,7 @@ func TestEncodeDecodeRoundTrip(t *testing.T) { "Hello, 世界", "line1\nline2", "\ttab", + "=?base64?literal?=", // sentinel-pattern collision (SEP-2243) } for _, v := range values { encoded, ok := encodeHeaderValue(v) @@ -1240,3 +1473,96 @@ func TestEncodeDecodeRoundTrip(t *testing.T) { } } } + +// TestValidateParamHeaders_IntegerComparison verifies server-side validation +// of integer x-mcp-header parameters per SEP-2243. +func TestValidateParamHeaders_IntegerComparison(t *testing.T) { + tool := &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "count": map[string]any{"type": "integer", "x-mcp-header": "Count"}, + }, + }, + } + tests := []struct { + name string + headerVal string + bodyArg any + wantErr bool + }{ + // Canonical decimal form matches. + {"integer matches integer", "42", float64(42), false}, + {"integer header matches integer-valued float body", "42", float64(42.0), false}, + {"negative integer matches", "-7", float64(-7), false}, + {"large safe integer matches", "1000000000000", float64(1e12), false}, + + {"non-canonical '42.0' header matches integer body", "42.0", float64(42), false}, + {"scientific notation header matches integer body", "1e2", float64(100), false}, + {"negative non-canonical header matches", "-7.0", float64(-7), false}, + + // Genuine mismatches and invalid forms are still rejected. + {"different integers do not match", "42", float64(43), true}, + {"fractional header against integer body", "3.14", float64(3), true}, + {"non-numeric header fails", "not-a-number", float64(42), true}, + {"header outside safe range against integer body", "9007199254740993", float64(42), true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + header := http.Header{} + header.Set(paramHeaderPrefix+"Count", tt.headerVal) + args := map[string]any{"count": tt.bodyArg} + msg := &jsonrpc.Request{ + Method: "tools/call", + Params: mustMarshal(&CallToolParams{Name: "test", Arguments: args}), + } + err := validateParamHeaders(header, msg, tool) + if tt.wantErr && err == nil { + t.Errorf("validateParamHeaders() = nil, want error") + } + if !tt.wantErr && err != nil { + t.Errorf("validateParamHeaders() = %v, want nil", err) + } + }) + } +} + +// TestValidateParamHeaders_NestedArguments verifies that the server-side +// validation can look up nested arguments via the dotted path produced by +// extractParamHeaderAnnotations. +func TestValidateParamHeaders_NestedArguments(t *testing.T) { + tool := &Tool{ + Name: "test", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "config": map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{"type": "string", "x-mcp-header": "Region"}, + }, + }, + }, + }, + } + header := http.Header{} + header.Set(paramHeaderPrefix+"Region", "us-west1") + args := map[string]any{ + "config": map[string]any{"region": "us-west1"}, + } + msg := &jsonrpc.Request{ + Method: "tools/call", + Params: mustMarshal(&CallToolParams{Name: "test", Arguments: args}), + } + if err := validateParamHeaders(header, msg, tool); err != nil { + t.Errorf("validateParamHeaders() = %v, want nil", err) + } + + // Mismatched nested value should fail. + args["config"].(map[string]any)["region"] = "eu-west1" + msg.Params = mustMarshal(&CallToolParams{Name: "test", Arguments: args}) + if err := validateParamHeaders(header, msg, tool); err == nil { + t.Error("validateParamHeaders() = nil, want error for mismatched nested value") + } +} From 79e1a13dcfa08a024c7f39f814c6634da366d491 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Tue, 16 Jun 2026 13:55:47 +0200 Subject: [PATCH 302/418] mcp: `tool.InputSchema` and `tool.OutputSchema` validation (SEP-2106) (#1009) Fixes #990 --- docs/server.md | 10 +- internal/docs/server.src.md | 10 +- mcp/content.go | 4 +- mcp/protocol.go | 4 +- mcp/server.go | 32 +++--- mcp/server_test.go | 198 +++++++++++++++++++++++++++++++++++- mcp/tool.go | 86 +++++++++++++--- mcp/tool_test.go | 79 +++++++++++++- 8 files changed, 380 insertions(+), 43 deletions(-) diff --git a/docs/server.md b/docs/server.md index c5a7bf9..8fe6bd3 100644 --- a/docs/server.md +++ b/docs/server.md @@ -312,9 +312,13 @@ mcp.AddTool(server, &mcp.Tool{Name: "my_tool"}, handler) This does the following automatically: -- If `Tool.InputSchema` or `Tool.OutputSchema` are unset, the input and output - schemas are inferred from the `In` type, which must be a struct or map. - Optional `jsonschema` struct tags provide argument descriptions. +- If `Tool.InputSchema` is unset, the input schema is inferred from the `In` + type, which must be a struct or map. +- If `Tool.OutputSchema` is unset and the `Out` type is not `any`, the output + schema is inferred from the `Out` type. Per SEP-2106, `Out` may be any Go + type whose inferred schema is a valid JSON Schema (struct, map, slice, + primitive, etc.). +- Optional `jsonschema` struct tags provide argument and output descriptions. - Tool arguments are validated against the input schema. - Tool arguments are marshaled into the `In` value. - Tool output (the `Out` value) is marshaled into the result's diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index 2d822c7..26dba5a 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -165,9 +165,13 @@ mcp.AddTool(server, &mcp.Tool{Name: "my_tool"}, handler) This does the following automatically: -- If `Tool.InputSchema` or `Tool.OutputSchema` are unset, the input and output - schemas are inferred from the `In` type, which must be a struct or map. - Optional `jsonschema` struct tags provide argument descriptions. +- If `Tool.InputSchema` is unset, the input schema is inferred from the `In` + type, which must be a struct or map. +- If `Tool.OutputSchema` is unset and the `Out` type is not `any`, the output + schema is inferred from the `Out` type. Per SEP-2106, `Out` may be any Go + type whose inferred schema is a valid JSON Schema (struct, map, slice, + primitive, etc.). +- Optional `jsonschema` struct tags provide argument and output descriptions. - Tool arguments are validated against the input schema. - Tool arguments are marshaled into the `In` value. - Tool output (the `Out` value) is marshaled into the result's diff --git a/mcp/content.go b/mcp/content.go index 95ea40d..39c25ff 100644 --- a/mcp/content.go +++ b/mcp/content.go @@ -234,7 +234,9 @@ type ToolResultContent struct { ToolUseID string // Content holds the unstructured result of the tool call. Content []Content - // StructuredContent holds an optional structured result as a JSON object. + // StructuredContent holds an optional structured result. Per SEP-2106, it + // may be any valid JSON value (object, array, or primitive) conforming to + // the tool's output schema. StructuredContent any // IsError indicates whether the tool call ended in an error. IsError bool diff --git a/mcp/protocol.go b/mcp/protocol.go index acc7ec4..6e87f19 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -270,7 +270,9 @@ type CallToolResult struct { Content []Content `json:"content"` // StructuredContent is an optional value that represents the structured - // result of the tool call. It must marshal to a JSON object. + // result of the tool call. Per SEP-2106, it may marshal to any valid JSON + // value (object, array, or primitive) conforming to the tool's + // [Tool.OutputSchema]. // // When using a [ToolHandlerFor] with structured output, you should not // populate this field. It will be automatically populated with the typed Out diff --git a/mcp/server.go b/mcp/server.go index 912dea9..6cd084e 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -232,7 +232,8 @@ func (s *Server) RemovePrompts(names ...string) { // that takes no input, or one where any input is valid, set [Tool.InputSchema] to // `{"type": "object"}`, using your preferred library or `json.RawMessage`. // -// If present, [Tool.OutputSchema] must also have type "object". +// If present, [Tool.OutputSchema] may be any valid JSON Schema (object, array, +// primitive, or composition). // // When the handler is invoked as part of a CallTool request, req.Params.Arguments // will be a json.RawMessage. @@ -279,16 +280,10 @@ func (s *Server) AddTool(t *Tool, h ToolHandler) { if s == nil { panic(fmt.Errorf("AddTool %q: output schema is nil", t.Name)) } - if s.Type != "object" { - panic(fmt.Errorf(`AddTool %q: output schema must have type "object"`, t.Name)) - } } else { - var m map[string]any + var m any if err := remarshal(t.OutputSchema, &m); err != nil { - panic(fmt.Errorf("AddTool %q: can't marshal output schema to a JSON object: %v", t.Name, err)) - } - if typ := m["type"]; typ != "object" { - panic(fmt.Errorf(`AddTool %q: output schema must have type "object" (got %v)`, t.Name, typ)) + panic(fmt.Errorf("AddTool %q: can't marshal output schema to JSON: %v", t.Name, err)) } } } @@ -340,7 +335,7 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out], cache *SchemaCa } // Validate input and apply defaults. var err error - input, err = applySchema(input, inputResolved) + input, err = applySchema(input, inputResolved, false) if err != nil { var errRes CallToolResult errRes.SetError(fmt.Errorf("validating \"arguments\": %v", err)) @@ -402,7 +397,7 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out], cache *SchemaCa // // We validate against the JSON, rather than the output value, as // some types may have custom JSON marshalling (issue #447). - outJSON, err = applySchema(outJSON, outputResolved) + outJSON, err = applySchema(outJSON, outputResolved, true) if err != nil { return nil, fmt.Errorf("validating tool output: %w", err) } @@ -411,10 +406,18 @@ func toolForErr[In, Out any](t *Tool, h ToolHandlerFor[In, Out], cache *SchemaCa // If the Content field isn't being used, return the serialized JSON in a // TextContent block, as the spec suggests: // https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content. + // + // Ensure a serialized-JSON TextContent fallback is present in case of servers using array + // or primitive structuredContent, so that pre-SEP-2106 clients can recover the structured + // payload from unstructured content. if res.Content == nil { res.Content = []Content{&TextContent{ Text: string(outJSON), }} + } else if !isObjectJSON(outJSON) { + res.Content = append(res.Content, &TextContent{ + Text: string(outJSON), + }) } } return res, nil @@ -525,9 +528,10 @@ func setSchema[T any](sfield *any, rfield **jsonschema.Resolved, cache *SchemaCa // empty object schema value. // // If the tool's output schema is nil, and the Out type is not 'any', the -// output schema is set to the schema inferred from the Out type argument, -// which must also be a map or struct. If the Out type is 'any', the output -// schema is omitted. +// output schema is set to the schema inferred from the Out type argument. +// Per SEP-2106, the Out type may be any Go type whose inferred schema is a +// valid JSON Schema (struct, map, slice, primitive, etc.). If the Out type is +// 'any', the output schema is omitted. // // Unlike [Server.AddTool], AddTool does a lot automatically, and forces // tools to conform to the MCP spec. See [ToolHandlerFor] for a detailed diff --git a/mcp/server_test.go b/mcp/server_test.go index d2814a0..4c8b76e 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -521,7 +521,8 @@ func panics(f func()) (b bool) { } func TestAddTool(t *testing.T) { - // AddTool should panic if In or Out are not JSON objects. + // AddTool should panic if In is not a JSON object. + // Out may be of any type whose inferred JSON Schema is valid. s := NewServer(testImpl, nil) if !panics(func() { AddTool(s, &Tool{Name: "T1"}, func(context.Context, *CallToolRequest, string) (*CallToolResult, any, error) { return nil, nil, nil }) @@ -535,12 +536,12 @@ func TestAddTool(t *testing.T) { }) { t.Error("good In: expected no panic") } - if !panics(func() { - AddTool(s, &Tool{Name: "T2"}, func(context.Context, *CallToolRequest, map[string]any) (*CallToolResult, int, error) { + if panics(func() { + AddTool(s, &Tool{Name: "T3"}, func(context.Context, *CallToolRequest, map[string]any) (*CallToolResult, int, error) { return nil, 0, nil }) }) { - t.Error("bad Out: expected panic") + t.Error("primitive Out: expected no panic") } } @@ -653,6 +654,195 @@ func TestAddToolNilSchema(t *testing.T) { } } +// TestAddToolNonObjectOutputSchema verifies the low-level +// Server.AddTool accepts an output schema whose root type is not "object" +// (array or primitive) and structured content of the matching shape +// round-trips end-to-end. +func TestAddToolNonObjectOutputSchema(t *testing.T) { + ctx := context.Background() + + for _, tc := range []struct { + name string + outputSchema any + content any + want any + }{ + { + name: "array of objects", + outputSchema: &jsonschema.Schema{Type: "array", Items: &jsonschema.Schema{Type: "object"}}, + content: []any{map[string]any{"id": "u1"}, map[string]any{"id": "u2"}}, + want: []any{map[string]any{"id": "u1"}, map[string]any{"id": "u2"}}, + }, + { + name: "primitive number (map-based schema)", + outputSchema: map[string]any{"type": "number"}, + content: 42.0, + want: 42.0, + }, + { + name: "primitive string (RawMessage schema)", + outputSchema: json.RawMessage(`{"type":"string"}`), + content: "hello", + want: "hello", + }, + } { + t.Run(tc.name, func(t *testing.T) { + server := NewServer(testImpl, nil) + handler := func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { + return &CallToolResult{StructuredContent: tc.content}, nil + } + tool := &Tool{ + Name: "t", + InputSchema: &jsonschema.Schema{Type: "object"}, + OutputSchema: tc.outputSchema, + } + // Must not panic. + server.AddTool(tool, handler) + + ct, st := NewInMemoryTransports() + if _, err := server.Connect(ctx, st, nil); err != nil { + t.Fatal(err) + } + client := NewClient(testImpl, nil) + cs, err := client.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + res, err := cs.CallTool(ctx, &CallToolParams{Name: "t"}) + if err != nil { + t.Fatal(err) + } + if res.IsError { + t.Fatalf("unexpected tool error: %v", res.Content) + } + if diff := cmp.Diff(tc.want, res.StructuredContent); diff != "" { + t.Errorf("structured content mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// TestAddToolGenericNonObjectOutput verifies SEP-2106 for the generic +// AddTool[In, Out] helper: Out may be a slice or primitive whose inferred +// JSON Schema has a non-object root. +func TestAddToolGenericNonObjectOutput(t *testing.T) { + ctx := context.Background() + + type item struct { + ID string `json:"id"` + } + + t.Run("slice output", func(t *testing.T) { + server := NewServer(testImpl, nil) + AddTool(server, &Tool{Name: "list"}, + func(ctx context.Context, req *CallToolRequest, _ struct{}) (*CallToolResult, []item, error) { + return nil, []item{{ID: "u1"}, {ID: "u2"}}, nil + }) + + ct, st := NewInMemoryTransports() + if _, err := server.Connect(ctx, st, nil); err != nil { + t.Fatal(err) + } + client := NewClient(testImpl, nil) + cs, err := client.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + // Verify tools/list reports an array-rooted output schema. + // jsonschema-go infers ["null","array"] for slices since nil slices + // are valid; accept either form. + lt, err := cs.ListTools(ctx, nil) + if err != nil { + t.Fatal(err) + } + if len(lt.Tools) != 1 { + t.Fatalf("got %d tools, want 1", len(lt.Tools)) + } + gotSchema, _ := lt.Tools[0].OutputSchema.(map[string]any) + typeOK := false + switch typ := gotSchema["type"].(type) { + case string: + typeOK = typ == "array" + case []any: + for _, e := range typ { + if e == "array" { + typeOK = true + } + } + } + if !typeOK { + t.Errorf("output schema type = %v, want to include %q", gotSchema["type"], "array") + } + + res, err := cs.CallTool(ctx, &CallToolParams{Name: "list"}) + if err != nil { + t.Fatal(err) + } + if res.IsError { + t.Fatalf("unexpected tool error: %v", res.Content) + } + want := []any{map[string]any{"id": "u1"}, map[string]any{"id": "u2"}} + if diff := cmp.Diff(want, res.StructuredContent); diff != "" { + t.Errorf("structured content mismatch (-want +got):\n%s", diff) + } + }) + + t.Run("primitive output", func(t *testing.T) { + server := NewServer(testImpl, nil) + AddTool(server, &Tool{Name: "count"}, + func(ctx context.Context, req *CallToolRequest, _ struct{}) (*CallToolResult, int, error) { + return nil, 42, nil + }) + + ct, st := NewInMemoryTransports() + if _, err := server.Connect(ctx, st, nil); err != nil { + t.Fatal(err) + } + client := NewClient(testImpl, nil) + cs, err := client.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + res, err := cs.CallTool(ctx, &CallToolParams{Name: "count"}) + if err != nil { + t.Fatal(err) + } + if res.IsError { + t.Fatalf("unexpected tool error: %v", res.Content) + } + if diff := cmp.Diff(float64(42), res.StructuredContent); diff != "" { + t.Errorf("structured content mismatch (-want +got):\n%s", diff) + } + }) +} + +// TestAddToolInputSchemaComposition verifies SEP-2106 (input side): composition +// keywords such as oneOf are allowed on the input schema alongside +// type:"object". +func TestAddToolInputSchemaComposition(t *testing.T) { + server := NewServer(testImpl, nil) + tool := &Tool{ + Name: "lookup", + InputSchema: &jsonschema.Schema{ + Type: "object", + OneOf: []*jsonschema.Schema{ + {Required: []string{"id"}, Properties: map[string]*jsonschema.Schema{"id": {Type: "string"}}}, + {Required: []string{"name"}, Properties: map[string]*jsonschema.Schema{"name": {Type: "string"}}}, + }, + }, + } + // Must not panic. + server.AddTool(tool, func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { + return &CallToolResult{}, nil + }) +} + type schema = jsonschema.Schema func testToolForSchema[In, Out any](t *testing.T, tool *Tool, in string, out Out, wantIn, wantOut any, wantErrContaining string) { diff --git a/mcp/tool.go b/mcp/tool.go index 3ecb59d..04cf551 100644 --- a/mcp/tool.go +++ b/mcp/tool.go @@ -65,8 +65,14 @@ type serverTool struct { // applySchema validates whether data is valid JSON according to the provided // schema, after applying schema defaults. // -// Returns the JSON value augmented with defaults. -func applySchema(data json.RawMessage, resolved *jsonschema.Resolved) (json.RawMessage, error) { +// If forOutput is false, the data is treated as tool input: the schema's root +// type must be "object" and the value is unmarshaled into a map. +// +// If forOutput is true, the data is treated as tool output: the schema's root +// may be of any type (object, array, primitive, composition). +// +// Returns the JSON value, augmented with defaults where applicable. +func applySchema(data json.RawMessage, resolved *jsonschema.Resolved, forOutput bool) (json.RawMessage, error) { // TODO: use reflection to create the struct type to unmarshal into. // Separate validation from assignment. @@ -75,33 +81,81 @@ func applySchema(data json.RawMessage, resolved *jsonschema.Resolved) (json.RawM // This avoids inconsistent representation due to custom marshallers, such as // time.Time (issue #449). // - // Additionally, unmarshalling into a map ensures that the resulting JSON is + // For input, unmarshalling into a map ensures that the resulting JSON is // at least {}, even if data is empty. For example, arguments is technically // an optional property of callToolParams, and we still want to apply the // defaults in this case. // // TODO(rfindley): in which cases can resolved be nil? - if resolved != nil { + if resolved == nil { + return data, nil + } + + var unmarshaled any + if !forOutput { v := make(map[string]any) if len(data) > 0 { if err := internaljson.Unmarshal(data, &v); err != nil { return nil, fmt.Errorf("unmarshaling arguments: %w", err) } } - if err := resolved.ApplyDefaults(&v); err != nil { - return nil, fmt.Errorf("applying schema defaults:\n%w", err) - } - if err := resolved.Validate(&v); err != nil { - return nil, err - } - // We must re-marshal with the default values applied. - var err error - data, err = json.Marshal(v) - if err != nil { - return nil, fmt.Errorf("marshalling with defaults: %v", err) + unmarshaled = v + } else { + if len(data) > 0 { + if err := internaljson.Unmarshal(data, &unmarshaled); err != nil { + return nil, fmt.Errorf("unmarshaling output: %w", err) + } } } - return data, nil + + // Apply defaults only when the value is a map: jsonschema.Resolved.ApplyDefaults + // only operates on object properties. For object-rooted output schemas, + // coerce a nil result (from "null" or empty data) into {} so handlers that + // return a typed-nil map still validate. + appliedDefaults := false + if _, ok := unmarshaled.(map[string]any); ok { + if err := resolved.ApplyDefaults(&unmarshaled); err != nil { + return nil, fmt.Errorf("applying schema defaults:\n%w", err) + } + appliedDefaults = true + } else if forOutput && unmarshaled == nil && resolved.Schema().Type == "object" { + unmarshaled = make(map[string]any) + if err := resolved.ApplyDefaults(&unmarshaled); err != nil { + return nil, fmt.Errorf("applying schema defaults:\n%w", err) + } + appliedDefaults = true + } + + if err := resolved.Validate(&unmarshaled); err != nil { + return nil, err + } + + // Re-marshal only when defaults may have changed the value. + if !appliedDefaults { + return data, nil + } + out, err := json.Marshal(unmarshaled) + if err != nil { + return nil, fmt.Errorf("marshalling with defaults: %v", err) + } + return out, nil +} + +// isObjectJSON reports whether data is a JSON object (i.e., starts with '{' +// after any leading whitespace). Returns false for arrays, primitives, null, +// or empty input. +func isObjectJSON(data json.RawMessage) bool { + for _, b := range data { + switch b { + case ' ', '\t', '\n', '\r': + continue + case '{': + return true + default: + return false + } + } + return false } // validateToolName checks whether name is a valid tool name, reporting a diff --git a/mcp/tool_test.go b/mcp/tool_test.go index dfd859b..7a82c96 100644 --- a/mcp/tool_test.go +++ b/mcp/tool_test.go @@ -46,7 +46,7 @@ func TestApplySchema(t *testing.T) { {`{"x": 0}`, new(map[string]any), &map[string]any{"x": 0.0}}, } { raw := json.RawMessage(tt.data) - raw, err = applySchema(raw, resolved) + raw, err = applySchema(raw, resolved, false) if err != nil { t.Fatal(err) } @@ -59,6 +59,83 @@ func TestApplySchema(t *testing.T) { } } +func TestApplySchemaOutput(t *testing.T) { + // SEP-2106: when forOutput is true, the schema may have a non-object root. + for _, tc := range []struct { + name string + schema *jsonschema.Schema + data string + want string + wantErr string + }{ + { + name: "array root accepts array", + schema: &jsonschema.Schema{Type: "array", Items: &jsonschema.Schema{Type: "integer"}}, + data: `[1,2,3]`, + want: `[1,2,3]`, + }, + { + name: "array root rejects wrong element type", + schema: &jsonschema.Schema{Type: "array", Items: &jsonschema.Schema{Type: "integer"}}, + data: `[1,"two",3]`, + wantErr: `"integer"`, + }, + { + name: "primitive number root", + schema: &jsonschema.Schema{Type: "number"}, + data: `42`, + want: `42`, + }, + { + name: "primitive string root", + schema: &jsonschema.Schema{Type: "string"}, + data: `"hello"`, + want: `"hello"`, + }, + { + name: "object root with defaults", + schema: &jsonschema.Schema{Type: "object", Properties: map[string]*jsonschema.Schema{"x": {Type: "integer", Default: json.RawMessage("3")}}}, + data: `{}`, + want: `{"x":3}`, + }, + { + name: "empty object root with defaults", + schema: &jsonschema.Schema{Type: "object", Properties: map[string]*jsonschema.Schema{"x": {Type: "integer", Default: json.RawMessage("3")}}}, + data: ``, + want: `{"x":3}`, + }, + { + name: "object root accepts null as empty object", + schema: &jsonschema.Schema{Type: "object"}, + data: `null`, + want: `{}`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + resolved, err := tc.schema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true}) + if err != nil { + t.Fatal(err) + } + got, err := applySchema(json.RawMessage(tc.data), resolved, true) + if tc.wantErr != "" { + if err == nil { + t.Fatalf("got %s, want error containing %q", got, tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("got error %q, want containing %q", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatal(err) + } + if string(got) != tc.want { + t.Errorf("got %s, want %s", got, tc.want) + } + }) + } +} + func TestToolErrorHandling(t *testing.T) { // Construct server and add both tools at the top level server := NewServer(testImpl, nil) From 6a6ade920bf3c49fd89478028ddb69dfa4036434 Mon Sep 17 00:00:00 2001 From: BorisTyshkevich <125603338+BorisTyshkevich@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:20:09 +0200 Subject: [PATCH 303/418] oauthex: add MatchesResource helper (RFC 9728/8707 audience comparison) (#970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add an exported \`oauthex.MatchesResource(claims []string, resource string) bool\` helper that compares an \`aud\` claim slice against an expected resource URL with the canonical-form (trailing slash) tolerance required by RFC 9728 and RFC 8707. - Tests cover the common shape combinations (canonical/trimmed in either direction, surrounding whitespace, multiple claims) plus negative cases ensuring scheme + path mismatches still fail. ## Motivation RFC 9728 §3.3 canonicalises the protected-resource identifier with a trailing slash, but RFC 8707 resource indicators frequently omit it. Upstream IdPs vary in which form they emit in the \`aud\` claim (Google trims, Auth0 retains, some clients round-trip whatever the IdP sent). Strict byte equality therefore fails routinely on legitimate setups. Resource servers writing this comparison by hand all converge on the same trim-trailing-slash + whitespace logic; exporting a canonical helper saves every consumer from re-deriving it. ## Test plan - [x] \`go test ./oauthex/... -run MatchesResource\` passes. - [x] Positive cases: exact match, trailing slash on either side, surrounding whitespace, multi-claim slice with one match. - [x] Negative cases: empty slice, no match, path mismatch, scheme mismatch (HTTPS vs HTTP must still fail). --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Guglielmo Colombo --- oauthex/audience.go | 44 +++++++++++++++++ oauthex/audience_test.go | 101 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 oauthex/audience.go create mode 100644 oauthex/audience_test.go diff --git a/oauthex/audience.go b/oauthex/audience.go new file mode 100644 index 0000000..cad1303 --- /dev/null +++ b/oauthex/audience.go @@ -0,0 +1,44 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package oauthex + +import "strings" + +// MatchesResource reports whether any of claims matches resource under +// RFC 3986 §6.2.3 scheme-based normalization, narrowed to the empty-path +// case: a URI with an empty path is treated as equivalent to one with a +// path of "/". All other URI components (scheme, host case, port, query, +// fragment) must match exactly — this is intentionally narrower than the +// full §6.2.3 rung, which would also fold scheme/host case and default +// ports. +// +// RFC 9728 §3.3 normatively requires "simple string comparison" per +// RFC 3986 §6.2.1 (byte-equal). Callers that need strict byte-equal +// semantics should compare claims to resource directly: +// +// for _, c := range claims { if c == resource { return true } } +// +// Background: RFC 9728 §3.3 canonicalises the protected-resource +// identifier with a trailing slash, but RFC 8707 resource indicators +// sometimes omit it, and upstream IdPs vary in which form they emit in +// `aud` claims (Google trims, Auth0 retains, claude.ai round-trips +// whichever it received). Strict byte equality therefore fails routinely +// on legitimate setups; this helper is the most common pragmatic relaxation +// while keeping path/scheme/host strict so token confusion across distinct +// resources still fails closed. +// +// Returns false when claims is empty. +func MatchesResource(claims []string, resource string) bool { + expected := strings.TrimSuffix(resource, "/") + for _, c := range claims { + if c == resource { + return true + } + if strings.TrimSuffix(c, "/") == expected { + return true + } + } + return false +} diff --git a/oauthex/audience_test.go b/oauthex/audience_test.go new file mode 100644 index 0000000..7d5acfb --- /dev/null +++ b/oauthex/audience_test.go @@ -0,0 +1,101 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package oauthex + +import "testing" + +func TestMatchesResource(t *testing.T) { + tests := []struct { + name string + claims []string + resource string + want bool + }{ + { + name: "exact match", + claims: []string{"https://mcp.example.com/"}, + resource: "https://mcp.example.com/", + want: true, + }, + { + name: "claim has trailing slash, resource does not", + claims: []string{"https://mcp.example.com/"}, + resource: "https://mcp.example.com", + want: true, + }, + { + name: "claim missing trailing slash, resource has one", + claims: []string{"https://mcp.example.com"}, + resource: "https://mcp.example.com/", + want: true, + }, + { + name: "multiple claims, one matches", + claims: []string{"https://other.example.com/", "https://mcp.example.com"}, + resource: "https://mcp.example.com/", + want: true, + }, + { + name: "no claims", + claims: nil, + resource: "https://mcp.example.com/", + want: false, + }, + { + name: "claim does not match resource", + claims: []string{"https://attacker.example.com/"}, + resource: "https://mcp.example.com/", + want: false, + }, + { + name: "path mismatch is not tolerated", + claims: []string{"https://mcp.example.com/v2"}, + resource: "https://mcp.example.com", + want: false, + }, + { + name: "scheme mismatch is not tolerated", + claims: []string{"http://mcp.example.com/"}, + resource: "https://mcp.example.com/", + want: false, + }, + // The following document the intentional boundaries: §6.2.3 also + // permits scheme/host case folding and default-port elision, but + // MatchesResource deliberately does NOT, so two distinct + // registered resources cannot collide via these normalizations. + { + name: "host case difference is not tolerated", + claims: []string{"https://MCP.example.com/"}, + resource: "https://mcp.example.com/", + want: false, + }, + { + name: "default-port elision is not tolerated", + claims: []string{"https://mcp.example.com:443/"}, + resource: "https://mcp.example.com/", + want: false, + }, + { + name: "query string difference is not tolerated", + claims: []string{"https://mcp.example.com/?x=y"}, + resource: "https://mcp.example.com/", + want: false, + }, + { + name: "surrounding whitespace is not tolerated (malformed claim)", + claims: []string{" https://mcp.example.com/ "}, + resource: "https://mcp.example.com/", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MatchesResource(tt.claims, tt.resource) + if got != tt.want { + t.Errorf("MatchesResource(%v, %q) = %v, want %v", tt.claims, tt.resource, got, tt.want) + } + }) + } +} From 96074c99187dd0f5ff037247aa6ae922269cf9ea Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Tue, 16 Jun 2026 16:13:52 +0200 Subject: [PATCH 304/418] fix: prevent duplicate in-flight request IDs (#1011) Fixes #1004 --- mcp/streamable.go | 38 ++++++++++++++++--------- mcp/streamable_test.go | 64 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 14 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 8ff9cd1..470bc7f 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1553,6 +1553,30 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques done := make(chan struct{}) stream.done = done stream.protocolVersion = effectiveVersion + + // Reject any call whose ID is already in flight on this session, + // atomically and without partial registration. + c.mu.Lock() + for reqID := range calls { + if _, ok := c.requestStreams[reqID]; ok { + c.mu.Unlock() + writeJSONRPCError(w, http.StatusBadRequest, reqID, &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidRequest, + Message: fmt.Sprintf("duplicate in-flight request ID %v", reqID.Raw()), + }) + return + } + } + c.streams[stream.id] = stream + for reqID := range calls { + c.requestStreams[reqID] = stream.id + } + c.mu.Unlock() + + // TODO(rfindley): if we have no event store, we should really cancel all + // remaining requests here, since the client will never get the results. + defer stream.release() + if c.jsonResponse { // JSON mode: collect messages in pendingJSONMessages until done. // Set pendingJSONMessages to a non-nil value to signal that this is an @@ -1581,20 +1605,6 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques } } - // TODO(rfindley): if we have no event store, we should really cancel all - // remaining requests here, since the client will never get the results. - defer stream.release() - - // The stream is now set up to deliver messages. - // - // Register it before publishing incoming messages. - c.mu.Lock() - c.streams[stream.id] = stream - for reqID := range calls { - c.requestStreams[reqID] = stream.id - } - c.mu.Unlock() - // Publish incoming messages. for _, msg := range incoming { select { diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 2efee20..f3bc98d 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -3716,3 +3716,67 @@ func TestStreamableHTTP_E2E_DiscoverSuccess(t *testing.T) { t.Errorf("CallTool result[0] = %+v, want TextContent{Text:\"hello\"}", res.Content[0]) } } + +// A POST whose call ID is already in flight on the same session must be rejected +// with a JSON-RPC error, must not overwrite the existing request-to-stream +// mapping, and must not publish the duplicate message to the session's incoming +// channel. +func TestStreamableServerRejectsDuplicateInFlightRequestID(t *testing.T) { + id := jsonrpc2.Int64ID(1) + conn := &streamableServerConn{ + logger: ensureLogger(nil), + incoming: make(chan jsonrpc.Message, 1), + done: make(chan struct{}), + streams: map[string]*stream{ + "existing": { + id: "existing", + logger: ensureLogger(nil), + requests: map[jsonrpc.ID]struct{}{id: {}}, + }, + }, + requestStreams: map[jsonrpc.ID]string{id: "existing"}, + } + + data, err := jsonrpc2.EncodeMessage(req(1, methodPing, &PingParams{})) + if err != nil { + t.Fatalf("EncodeMessage() error = %v", err) + } + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer cancel() + httpReq := httptest.NewRequestWithContext(ctx, http.MethodPost, "/", bytes.NewReader(data)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json, text/event-stream") + + rec := httptest.NewRecorder() + conn.servePOST(rec, httpReq) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + msg, err := jsonrpc2.DecodeMessage(rec.Body.Bytes()) + if err != nil { + t.Fatalf("DecodeMessage() error = %v; body = %s", err, rec.Body.String()) + } + resp, ok := msg.(*jsonrpc.Response) + if !ok { + t.Fatalf("response type = %T, want *jsonrpc.Response", msg) + } + if got := resp.ID.Raw(); got != int64(1) { + t.Fatalf("response ID = %v, want 1", got) + } + var jerr *jsonrpc.Error + if !errors.As(resp.Error, &jerr) { + t.Fatalf("response error = %v, want *jsonrpc.Error", resp.Error) + } + if jerr.Code != jsonrpc.CodeInvalidRequest { + t.Fatalf("error code = %d, want %d", jerr.Code, jsonrpc.CodeInvalidRequest) + } + if got := conn.requestStreams[id]; got != "existing" { + t.Fatalf("requestStreams[%v] = %q, want %q", id, got, "existing") + } + select { + case msg := <-conn.incoming: + t.Fatalf("duplicate request was published to incoming: %v", msg) + default: + } +} From d9728a88f3cdd50a5809730b5d346b74cddb6ad0 Mon Sep 17 00:00:00 2001 From: Thomas Bechtold Date: Tue, 16 Jun 2026 16:42:53 +0200 Subject: [PATCH 305/418] auth: bind refreshing token source to a background context, not the request ctx (#988) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `AuthorizationCodeHandler.exchangeAuthorizationCode` builds the long-lived token source from the **request context** passed to `Authorize()`: ```go clientCtx := context.WithValue(ctx, oauth2.HTTPClient, h.config.Client) ... h.tokenSource = cfg.TokenSource(clientCtx, token) ``` `golang.org/x/oauth2` captures that context and reuses it for **every** future refresh: - `oauth2.go`: `Config.TokenSource(ctx, …)` stores `ctx` in `tokenRefresher{ctx}` - `tokenRefresher.Token()` (no ctx arg) calls `retrieveToken(tf.ctx, …)` - `internal/token.go`: `ContextClient(ctx).Do(req.WithContext(ctx))` `Authorize()` is normally invoked from a request- or connect-scoped context — e.g. the streamable client transport calls it during `setMCPHeaders`/`initialize`, and that context is cancelled once the operation completes. The cached access token works until expiry; the **first refresh after expiry** then fails instantly with `context canceled`, before any HTTP request is sent. The connection can no longer refresh. `setMCPHeaders` only re-invokes `Authorize()` for an `invalid_grant` `oauth2.RetrieveError` (#917). A `context canceled` is not a `RetrieveError`, so it is never recovered — the connection is wedged until the client reconnects. ### Reproduce 1. Connect a `StreamableClientTransport` with an `AuthorizationCodeHandler` to a server whose `initialize` returns 401 (so `Authorize()` runs during connect, under the connect context). 2. Let the connect context be cancelled after connect returns (the common pattern: a `context.WithTimeout(parent, …)` + `defer cancel()` bounding the interactive flow). 3. Wait for the access token to expire and issue a request. 4. Every request fails with `Post "": context canceled` and `dur≈0` — the refresh never hits the network. ## Fix A token source outlives the request that created it, so bind its refreshes to `context.Background()` (still carrying the configured HTTP client via `oauth2.HTTPClient`). The one-shot token exchange keeps using the request context. ```go refreshCtx := context.WithValue(context.Background(), oauth2.HTTPClient, h.config.Client) h.tokenSource = cfg.TokenSource(refreshCtx, token) ``` `auth` package tests pass. Behaviour is otherwise unchanged: the same HTTP client value is carried; only the cancellation parent differs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Guglielmo Colombo --- auth/authorization_code.go | 12 ++- auth/authorization_code_test.go | 95 +++++++++++++++++++ .../oauthtest/fake_authorization_server.go | 48 +++++++++- 3 files changed, 153 insertions(+), 2 deletions(-) diff --git a/auth/authorization_code.go b/auth/authorization_code.go index a3daeec..963884d 100644 --- a/auth/authorization_code.go +++ b/auth/authorization_code.go @@ -605,7 +605,17 @@ func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context if err != nil { return fmt.Errorf("token exchange failed: %w", err) } - h.tokenSource = cfg.TokenSource(clientCtx, token) + // The token source outlives this authorization request: it is stored on the + // handler and used by the transport for the lifetime of the connection. The + // oauth2 library captures the context passed to TokenSource and reuses it for + // every subsequent token refresh (see golang.org/x/oauth2: tokenRefresher + // retains the context and passes it to each refresh round-trip). Binding it to + // the per-request ctx makes all later refreshes fail with "context canceled" + // once that request (or the connect operation that triggered authorization) + // completes. Use a background context that still carries the configured HTTP + // client so refreshes keep working for the life of the token source. + refreshCtx := context.WithValue(context.Background(), oauth2.HTTPClient, h.config.Client) + h.tokenSource = cfg.TokenSource(refreshCtx, token) return nil } diff --git a/auth/authorization_code_test.go b/auth/authorization_code_test.go index c84b003..15ca69f 100644 --- a/auth/authorization_code_test.go +++ b/auth/authorization_code_test.go @@ -115,6 +115,101 @@ func TestAuthorize(t *testing.T) { } } +// TestAuthorize_RefreshAfterContextCancel verifies that the token source built +// by Authorize keeps refreshing after the context passed to Authorize is +// cancelled. The token source is stored on the handler and used by the +// transport for the whole connection lifetime, whereas Authorize is typically +// called from a request- or connect-scoped context. golang.org/x/oauth2 +// captures the context handed to Config.TokenSource and reuses it for every +// refresh, so binding it to the request context made every refresh after the +// access token expired fail with "context canceled". Regression test for that. +func TestAuthorize_RefreshAfterContextCancel(t *testing.T) { + authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + // expires_in below oauth2's 10s expiry delta, so the reuse token source + // treats the access token as expired immediately and must refresh. + AccessTokenTTL: 1, + IssueRefreshToken: true, + RegistrationConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "test_client_id": { + Secret: "test_client_secret", + RedirectURIs: []string{"http://localhost:12345/callback"}, + }, + }, + }, + }) + authServer.Start(t) + + resourceMux := http.NewServeMux() + resourceServer := httptest.NewServer(resourceMux) + t.Cleanup(resourceServer.Close) + resourceURL := resourceServer.URL + "/resource" + resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{ + Resource: resourceURL, + AuthorizationServers: []string{authServer.URL()}, + })) + + handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{ + RedirectURL: "http://localhost:12345/callback", + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "test_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: "test_client_secret"}, + }, + AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := client.Get(args.URL) + if err != nil { + return nil, fmt.Errorf("failed to visit auth URL: %v", err) + } + defer resp.Body.Close() + location, err := resp.Location() + if err != nil { + return nil, fmt.Errorf("failed to get location header: %v", err) + } + return &AuthorizationResult{ + Code: location.Query().Get("code"), + State: location.Query().Get("state"), + Iss: location.Query().Get("iss"), + }, nil + }, + }) + if err != nil { + t.Fatalf("NewAuthorizationCodeHandler failed: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, resourceURL, nil) + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: http.NoBody, + Request: req, + } + resp.Header.Set("WWW-Authenticate", "Bearer resource_metadata="+resourceServer.URL+"/.well-known/oauth-protected-resource/resource") + + // Authorize under a context that we cancel immediately afterwards, mimicking + // the request/connect context the transport passes and that is already done + // by the time a later token refresh runs. + ctx, cancel := context.WithCancel(context.Background()) + if err := handler.Authorize(ctx, req, resp); err != nil { + t.Fatalf("Authorize failed: %v", err) + } + cancel() + + tokenSource, err := handler.TokenSource(context.Background()) + if err != nil { + t.Fatalf("Failed to get token source: %v", err) + } + // The access token is already expired, so this forces a refresh round-trip. + // It must not fail with "context canceled" from the cancelled Authorize ctx. + token, err := tokenSource.Token() + if err != nil { + t.Fatalf("token refresh after Authorize context cancellation failed: %v", err) + } + if token.AccessToken != "test_access_token_refreshed" { + t.Errorf("expected refreshed access token %q, got %q", "test_access_token_refreshed", token.AccessToken) + } +} + func TestAuthorize_ScopeAccumulation(t *testing.T) { authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ RegistrationConfig: &oauthtest.RegistrationConfig{ diff --git a/internal/oauthtest/fake_authorization_server.go b/internal/oauthtest/fake_authorization_server.go index c180f86..e5134fb 100644 --- a/internal/oauthtest/fake_authorization_server.go +++ b/internal/oauthtest/fake_authorization_server.go @@ -89,6 +89,26 @@ type Config struct { // TokenScopeFunc, if set, is called with the scope from the authorization // request and returns the scope string to include in the token response. TokenScopeFunc func(requestedScope string) string + // AccessTokenTTL, if non-zero, is the expires_in (in seconds) returned by the + // /token endpoint for both the authorization_code and refresh_token grants. + // When zero a default of 3600 is used. Set it small to force a client's reuse + // token source to treat the access token as expired and refresh it. + AccessTokenTTL int + // IssueRefreshToken, if true, includes a refresh_token in token responses and + // enables grant_type=refresh_token at the /token endpoint. + IssueRefreshToken bool +} + +// testRefreshToken is the refresh token issued and accepted by the fake server +// when Config.IssueRefreshToken is set. +const testRefreshToken = "test_refresh_token" + +// accessTokenExpiresIn returns the expires_in value to use in token responses. +func (s *FakeAuthorizationServer) accessTokenExpiresIn() int { + if s.config.AccessTokenTTL != 0 { + return s.config.AccessTokenTTL + } + return 3600 } // FakeAuthorizationServer is a fake OAuth 2.0 Authorization Server for testing. @@ -298,6 +318,8 @@ func (s *FakeAuthorizationServer) handleToken(w http.ResponseWriter, r *http.Req s.handleJWTBearerGrant(w, r) case "client_credentials": s.handleClientCredentialsGrant(w, r) + case "refresh_token": + s.handleRefreshTokenGrant(w, r) default: http.Error(w, fmt.Sprintf("unsupported grant_type: %s", grantType), http.StatusBadRequest) } @@ -329,7 +351,10 @@ func (s *FakeAuthorizationServer) handleAuthorizationCodeGrant(w http.ResponseWr resp := map[string]any{ "access_token": "test_access_token", "token_type": "Bearer", - "expires_in": 3600, + "expires_in": s.accessTokenExpiresIn(), + } + if s.config.IssueRefreshToken { + resp["refresh_token"] = testRefreshToken } if s.config.TokenScopeFunc != nil { if scope := s.config.TokenScopeFunc(codeInfo.Scope); scope != "" { @@ -340,6 +365,27 @@ func (s *FakeAuthorizationServer) handleAuthorizationCodeGrant(w http.ResponseWr json.NewEncoder(w).Encode(resp) } +// handleRefreshTokenGrant implements grant_type=refresh_token (RFC 6749 Section +// 6) when Config.IssueRefreshToken is set, returning a distinct access token so +// callers can observe that a refresh occurred. +func (s *FakeAuthorizationServer) handleRefreshTokenGrant(w http.ResponseWriter, r *http.Request) { + if !s.config.IssueRefreshToken { + http.Error(w, "refresh_token grant not supported", http.StatusBadRequest) + return + } + if r.Form.Get("refresh_token") != testRefreshToken { + http.Error(w, "invalid refresh_token", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "access_token": "test_access_token_refreshed", + "token_type": "Bearer", + "expires_in": s.accessTokenExpiresIn(), + "refresh_token": testRefreshToken, + }) +} + func (s *FakeAuthorizationServer) handleJWTBearerGrant(w http.ResponseWriter, r *http.Request) { if s.config.JWTBearerConfig == nil { http.Error(w, "JWT bearer grant not supported", http.StatusBadRequest) From 71ffcb412a087d4fd2bee5d9389b9f7885a08468 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Wed, 17 Jun 2026 11:41:35 +0200 Subject: [PATCH 306/418] mcp: add MCPGPDEBUG for opt-in Content-Type check (#1012) Copy of existing [PR](https://github.com/modelcontextprotocol/go-sdk/pull/972) patching 1.6.0 version --- docs/mcpgodebug.md | 9 +++++++++ internal/docs/mcpgodebug.src.md | 9 +++++++++ mcp/sse.go | 2 +- mcp/streamable.go | 10 ++++++++-- 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index 40958c2..7082518 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -50,6 +50,15 @@ Options listed below were added and will be removed in the 1.9.0 version of the restoring the previous behavior. The default behavior was changed so that the client always attempts to surface the underlying JSON-RPC error. +### 1.6.1 + +Options listed below were added and will be removed in the 1.8.0 version of the SDK. + +- `disablecontenttypecheck` added. If set to `1`, Content-Type validation on + HTTP POST requests will be disabled, allowing requests with non-JSON or missing + Content-Type headers. The default behavior is to validate that HTTP POST + requests have Content-Type: application/json. + ### 1.6.0 Options listed below were added and will be removed in the 1.8.0 version of the SDK. diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index a8e1a92..1f15072 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -49,6 +49,15 @@ Options listed below were added and will be removed in the 1.9.0 version of the restoring the previous behavior. The default behavior was changed so that the client always attempts to surface the underlying JSON-RPC error. +### 1.6.1 + +Options listed below were added and will be removed in the 1.8.0 version of the SDK. + +- `disablecontenttypecheck` added. If set to `1`, Content-Type validation on + HTTP POST requests will be disabled, allowing requests with non-JSON or missing + Content-Type headers. The default behavior is to validate that HTTP POST + requests have Content-Type: application/json. + ### 1.6.0 Options listed below were added and will be removed in the 1.8.0 version of the SDK. diff --git a/mcp/sse.go b/mcp/sse.go index 0e1ad79..7efaa95 100644 --- a/mcp/sse.go +++ b/mcp/sse.go @@ -202,7 +202,7 @@ func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { } // Validate 'Content-Type' header. - if req.Method == http.MethodPost { + if disablecontenttypecheck != "1" && req.Method == http.MethodPost { mediaType, _, err := mime.ParseMediaType(req.Header.Get("Content-Type")) if err != nil || mediaType != "application/json" { http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) diff --git a/mcp/streamable.go b/mcp/streamable.go index 470bc7f..2f60fb8 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -271,6 +271,12 @@ var allowsessionsinstateless = mcpgodebug.Value("allowsessionsinstateless") // the client always attempts to surface the underlying JSON-RPC error. var noprotocolerrorbody = mcpgodebug.Value("noprotocolerrorbody") +// disablecontenttypecheck is a compatibility parameter that allows to disable +// Content-Type validation on POST requests. +// See the documentation for the mcpgodebug package for instructions how to enable it. +// The option will be removed in the 1.8.0 version of the SDK. +var disablecontenttypecheck = mcpgodebug.Value("disablecontenttypecheck") + // writeJSONRPCError writes a JSON-RPC error response with the given HTTP // status code, request ID (may be a zero ID for errors that occur before the // request body has been parsed), and JSON-RPC error. @@ -342,7 +348,7 @@ func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http. return } - if baseMediaType(req.Header.Get("Content-Type")) != "application/json" { + if disablecontenttypecheck != "1" && baseMediaType(req.Header.Get("Content-Type")) != "application/json" { http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) return } @@ -560,7 +566,7 @@ func (h *StreamableHTTPHandler) serveStatefulDELETE(w http.ResponseWriter, req * // ID, a new session is created (this is the normal path for the first // initialize request). func (h *StreamableHTTPHandler) serveStatefulPOST(w http.ResponseWriter, req *http.Request) { - if baseMediaType(req.Header.Get("Content-Type")) != "application/json" { + if disablecontenttypecheck != "1" && baseMediaType(req.Header.Get("Content-Type")) != "application/json" { http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) return } From 6333aa7c635ec864d1147e8106d96a546b8e1da3 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Thu, 18 Jun 2026 15:20:21 +0200 Subject: [PATCH 307/418] mcp: add ttl for list results (SEP-2549) (#1008) Fixes #994 --- mcp/cache.go | 99 ++++++++++++++ mcp/client.go | 123 ++++++++++++++---- mcp/client_test.go | 87 +++++++------ mcp/protocol.go | 48 ++++++- mcp/server.go | 29 ++++- mcp/streamable_test.go | 14 ++ .../conformance/server/lifecycle.txtar | 4 + mcp/testdata/conformance/server/prompts.txtar | 4 + .../conformance/server/resources.txtar | 6 + .../spec-sep-973-additional-metadata.txtar | 6 + mcp/testdata/conformance/server/tools.txtar | 6 + 11 files changed, 356 insertions(+), 70 deletions(-) create mode 100644 mcp/cache.go diff --git a/mcp/cache.go b/mcp/cache.go new file mode 100644 index 0000000..6aa3f61 --- /dev/null +++ b/mcp/cache.go @@ -0,0 +1,99 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package mcp + +import ( + "sync" + "time" +) + +// methodCache is a per-method TTL cache for list and read results, as +// described in SEP-2549. Each entry is keyed by cursor (for paginated list +// methods) or URI (for resources/read). +type methodCache[R CacheableResult] struct { + mu sync.Mutex + cachedValues map[string]*cacheEntry[R] +} + +type cacheEntry[R CacheableResult] struct { + result R + receivedAt time.Time +} + +func (e *cacheEntry[R]) isValid() bool { + return time.Since(e.receivedAt) < time.Duration(e.result.GetTTLMs())*time.Millisecond +} + +func (mc *methodCache[R]) get(key string) (R, bool) { + mc.mu.Lock() + defer mc.mu.Unlock() + entry, ok := mc.cachedValues[key] + if !ok { + var zero R + return zero, false + } + if entry.result.GetTTLMs() <= 0 || !entry.isValid() { + delete(mc.cachedValues, key) + var zero R + return zero, false + } + return entry.result, true +} + +func (mc *methodCache[R]) put(key string, result R) { + mc.mu.Lock() + defer mc.mu.Unlock() + if mc.cachedValues == nil { + mc.cachedValues = make(map[string]*cacheEntry[R]) + } + mc.cachedValues[key] = &cacheEntry[R]{ + result: result, + receivedAt: time.Now(), + } +} + +func (mc *methodCache[R]) forEachValid(f func(R)) { + mc.mu.Lock() + defer mc.mu.Unlock() + for key, entry := range mc.cachedValues { + if !entry.isValid() { + delete(mc.cachedValues, key) + continue + } + f(entry.result) + } +} + +func (mc *methodCache[R]) invalidate() { + mc.mu.Lock() + defer mc.mu.Unlock() + clear(mc.cachedValues) +} + +func (mc *methodCache[R]) invalidateKey(key string) { + mc.mu.Lock() + defer mc.mu.Unlock() + delete(mc.cachedValues, key) +} + +// cursorParams is the constraint for list-method params that carry a pagination +// cursor and can be checked for nil. Both methods are already implemented by +// every concrete list-params type. +type cursorParams interface { + Params + cursorPtr() *string +} + +// cachedListResult returns a cached list result keyed by the request cursor +// (SEP-2549). It returns the zero value and false on miss or when params is nil. +func cachedListResult[P cursorParams, R CacheableResult](cache *methodCache[R], params P) (R, bool) { + key := "" + if !params.isNil() { + if cp := params.cursorPtr(); cp != nil { + key = *cp + } + } + return cache.get(key) +} diff --git a/mcp/client.go b/mcp/client.go index 1e0e18a..5d4beb2 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -422,16 +422,16 @@ type ClientSession struct { // only set synchronously during Client.Connect. state clientSessionState + // Per-method TTL caches for list results (SEP-2549). + toolsCache methodCache[*ListToolsResult] + promptsCache methodCache[*ListPromptsResult] + resourcesCache methodCache[*ListResourcesResult] + resourceTemplatesCache methodCache[*ListResourceTemplatesResult] + readResourceCache methodCache[*ReadResourceResult] + // Pending URL elicitations waiting for completion notifications. pendingElicitationsMu sync.Mutex pendingElicitations map[string]chan struct{} - - // toolCacheMu guards toolCache. - toolCacheMu sync.RWMutex - // toolCache stores tool definitions keyed by name. - // It is used to look up x-mcp-header annotations when - // constructing Mcp-Param-* headers for tools/call requests. - toolCache map[string]*Tool } type clientSessionState struct { @@ -513,19 +513,25 @@ func (cs *ClientSession) Wait() error { return cs.conn.Wait() } -func (cs *ClientSession) cacheTools(tools []*Tool) { - cs.toolCacheMu.Lock() - defer cs.toolCacheMu.Unlock() - cs.toolCache = make(map[string]*Tool, len(tools)) - for _, tool := range tools { - cs.toolCache[tool.Name] = tool - } -} - -func (cs *ClientSession) getCachedTool(name string) *Tool { - cs.toolCacheMu.RLock() - defer cs.toolCacheMu.RUnlock() - return cs.toolCache[name] +// lookupTool returns the most recently seen definition of the tool with the +// given name across all cached ListTools results, or nil if no such tool has +// been seen. It is used by CallTool to inject the tool definition into the +// outgoing request context for transport-layer features (e.g. x-mcp-header +// param annotations). +func (cs *ClientSession) lookupTool(name string) *Tool { + var found *Tool + cs.toolsCache.forEachValid(func(r *ListToolsResult) { + if found != nil { + return + } + for _, t := range r.Tools { + if t.Name == name { + found = t + return + } + } + }) + return found } // registerElicitationWaiter registers a waiter for an elicitation complete @@ -1135,11 +1141,24 @@ func (cs *ClientSession) Ping(ctx context.Context, params *PingParams) error { } // ListPrompts lists prompts that are currently available on the server. +// +// Results may be served from a client-side TTL cache populated by previous +// calls; see SEP-2549. func (cs *ClientSession) ListPrompts(ctx context.Context, params *ListPromptsParams) (*ListPromptsResult, error) { if cs.usesNewProtocol() { + if result, ok := cachedListResult(&cs.promptsCache, params); ok { + return result, nil + } params = injectRequestMeta(cs, params) } - return handleSend[*ListPromptsResult](ctx, methodListPrompts, newClientRequest(cs, orZero[Params](params))) + result, err := handleSend[*ListPromptsResult](ctx, methodListPrompts, newClientRequest(cs, orZero[Params](params))) + if err != nil { + return nil, err + } + if cs.usesNewProtocol() { + cs.promptsCache.put(params.Cursor, result) + } + return result, nil } // GetPrompt gets a prompt from the server. @@ -1153,6 +1172,9 @@ func (cs *ClientSession) GetPrompt(ctx context.Context, params *GetPromptParams) // ListTools lists tools that are currently available on the server. func (cs *ClientSession) ListTools(ctx context.Context, params *ListToolsParams) (*ListToolsResult, error) { if cs.usesNewProtocol() { + if result, ok := cachedListResult(&cs.toolsCache, params); ok { + return result, nil + } params = injectRequestMeta(cs, params) } result, err := handleSend[*ListToolsResult](ctx, methodListTools, newClientRequest(cs, orZero[Params](params))) @@ -1160,7 +1182,9 @@ func (cs *ClientSession) ListTools(ctx context.Context, params *ListToolsParams) return nil, err } result.Tools = filterValidTools(cs.client.opts.Logger, result.Tools) - cs.cacheTools(result.Tools) + if cs.usesNewProtocol() { + cs.toolsCache.put(params.Cursor, result) + } return result, nil } @@ -1175,7 +1199,7 @@ func (cs *ClientSession) CallTool(ctx context.Context, params *CallToolParams) ( // Avoid sending nil over the wire. params.Arguments = map[string]any{} } - if tool := cs.getCachedTool(params.Name); tool != nil { + if tool := cs.lookupTool(params.Name); tool != nil { ctx = context.WithValue(ctx, toolContextKey, tool) } if cs.usesNewProtocol() { @@ -1192,25 +1216,59 @@ func (cs *ClientSession) SetLoggingLevel(ctx context.Context, params *SetLogging // ListResources lists the resources that are currently available on the server. func (cs *ClientSession) ListResources(ctx context.Context, params *ListResourcesParams) (*ListResourcesResult, error) { if cs.usesNewProtocol() { + if result, ok := cachedListResult(&cs.resourcesCache, params); ok { + return result, nil + } params = injectRequestMeta(cs, params) } - return handleSend[*ListResourcesResult](ctx, methodListResources, newClientRequest(cs, orZero[Params](params))) + result, err := handleSend[*ListResourcesResult](ctx, methodListResources, newClientRequest(cs, orZero[Params](params))) + if err != nil { + return nil, err + } + if cs.usesNewProtocol() { + cs.resourcesCache.put(params.Cursor, result) + } + return result, nil } // ListResourceTemplates lists the resource templates that are currently available on the server. func (cs *ClientSession) ListResourceTemplates(ctx context.Context, params *ListResourceTemplatesParams) (*ListResourceTemplatesResult, error) { if cs.usesNewProtocol() { + if result, ok := cachedListResult(&cs.resourceTemplatesCache, params); ok { + return result, nil + } params = injectRequestMeta(cs, params) } - return handleSend[*ListResourceTemplatesResult](ctx, methodListResourceTemplates, newClientRequest(cs, orZero[Params](params))) + result, err := handleSend[*ListResourceTemplatesResult](ctx, methodListResourceTemplates, newClientRequest(cs, orZero[Params](params))) + if err != nil { + return nil, err + } + if cs.usesNewProtocol() { + cs.resourceTemplatesCache.put(params.Cursor, result) + } + return result, nil } // ReadResource asks the server to read a resource and return its contents. func (cs *ClientSession) ReadResource(ctx context.Context, params *ReadResourceParams) (*ReadResourceResult, error) { if cs.usesNewProtocol() { + var uri string + if params != nil { + uri = params.URI + } + if result, ok := cs.readResourceCache.get(uri); ok { + return result, nil + } params = injectRequestMeta(cs, params) } - return handleSend[*ReadResourceResult](ctx, methodReadResource, newClientRequest(cs, orZero[Params](params))) + result, err := handleSend[*ReadResourceResult](ctx, methodReadResource, newClientRequest(cs, orZero[Params](params))) + if err != nil { + return nil, err + } + if cs.usesNewProtocol() { + cs.readResourceCache.put(params.URI, result) + } + return result, nil } func (cs *ClientSession) Complete(ctx context.Context, params *CompleteParams) (*CompleteResult, error) { @@ -1235,6 +1293,9 @@ func (cs *ClientSession) Unsubscribe(ctx context.Context, params *UnsubscribePar } func (c *Client) callToolChangedHandler(ctx context.Context, req *ToolListChangedRequest) (Result, error) { + if cs, ok := req.GetSession().(*ClientSession); ok { + cs.toolsCache.invalidate() + } if h := c.opts.ToolListChangedHandler; h != nil { h(ctx, req) } @@ -1242,6 +1303,9 @@ func (c *Client) callToolChangedHandler(ctx context.Context, req *ToolListChange } func (c *Client) callPromptChangedHandler(ctx context.Context, req *PromptListChangedRequest) (Result, error) { + if cs, ok := req.GetSession().(*ClientSession); ok { + cs.promptsCache.invalidate() + } if h := c.opts.PromptListChangedHandler; h != nil { h(ctx, req) } @@ -1249,6 +1313,10 @@ func (c *Client) callPromptChangedHandler(ctx context.Context, req *PromptListCh } func (c *Client) callResourceChangedHandler(ctx context.Context, req *ResourceListChangedRequest) (Result, error) { + if cs, ok := req.GetSession().(*ClientSession); ok { + cs.resourcesCache.invalidate() + cs.resourceTemplatesCache.invalidate() + } if h := c.opts.ResourceListChangedHandler; h != nil { h(ctx, req) } @@ -1256,6 +1324,9 @@ func (c *Client) callResourceChangedHandler(ctx context.Context, req *ResourceLi } func (c *Client) callResourceUpdatedHandler(ctx context.Context, req *ResourceUpdatedNotificationRequest) (Result, error) { + if cs, ok := req.GetSession().(*ClientSession); ok && req.Params != nil { + cs.readResourceCache.invalidateKey(req.Params.URI) + } if h := c.opts.ResourceUpdatedHandler; h != nil { h(ctx, req) } diff --git a/mcp/client_test.go b/mcp/client_test.go index 16b6b76..23ee034 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -444,16 +444,22 @@ func TestClientCapabilities(t *testing.T) { } } -func TestToolCache(t *testing.T) { +func TestLookupTool(t *testing.T) { tool1 := &Tool{Name: "tool1", Description: "first"} tool2 := &Tool{Name: "tool2", Description: "second"} tool1Updated := &Tool{Name: "tool1", Description: "updated"} + // page represents a single cached ListToolsResult, keyed by cursor. + type page struct { + cursor string + tools []*Tool + } + testCases := []struct { - name string - cacheBatches [][]*Tool - lookup string - want *Tool + name string + pages []page + lookup string + want *Tool }{ { name: "empty cache", @@ -461,58 +467,61 @@ func TestToolCache(t *testing.T) { want: nil, }, { - name: "single tool found", - cacheBatches: [][]*Tool{{tool1}}, - lookup: "tool1", - want: tool1, + name: "single tool found", + pages: []page{{cursor: "", tools: []*Tool{tool1}}}, + lookup: "tool1", + want: tool1, }, { - name: "unknown tool", - cacheBatches: [][]*Tool{{tool1}}, - lookup: "nonexistent", - want: nil, + name: "unknown tool", + pages: []page{{cursor: "", tools: []*Tool{tool1}}}, + lookup: "nonexistent", + want: nil, }, { - name: "multiple tools single batch", - cacheBatches: [][]*Tool{{tool1, tool2}}, - lookup: "tool2", - want: tool2, + name: "multiple tools single page", + pages: []page{{cursor: "", tools: []*Tool{tool1, tool2}}}, + lookup: "tool2", + want: tool2, }, { - name: "replace clears old entries", - cacheBatches: [][]*Tool{{tool1}, {tool2}}, - lookup: "tool1", - want: nil, + name: "tool found across paginated pages", + pages: []page{ + {cursor: "", tools: []*Tool{tool1}}, + {cursor: "page2", tools: []*Tool{tool2}}, + }, + lookup: "tool2", + want: tool2, }, { - name: "replace keeps new entries", - cacheBatches: [][]*Tool{{tool1}, {tool2}}, - lookup: "tool2", - want: tool2, + name: "re-list same cursor overwrites entry", + pages: []page{ + {cursor: "", tools: []*Tool{tool1}}, + {cursor: "", tools: []*Tool{tool1Updated}}, + }, + lookup: "tool1", + want: tool1Updated, }, { - name: "overwrite existing entry", - cacheBatches: [][]*Tool{{tool1}, {tool1Updated}}, - lookup: "tool1", - want: tool1Updated, - }, - { - name: "empty batch no-op", - cacheBatches: [][]*Tool{{}}, - lookup: "tool1", - want: nil, + name: "empty page no-op", + pages: []page{{cursor: "", tools: []*Tool{}}}, + lookup: "tool1", + want: nil, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { cs := &ClientSession{} - for _, batch := range tc.cacheBatches { - cs.cacheTools(batch) + for _, p := range tc.pages { + cs.toolsCache.put(p.cursor, &ListToolsResult{ + Cacheable: Cacheable{TTLMs: 60_000}, + Tools: p.tools, + }) } - got := cs.getCachedTool(tc.lookup) + got := cs.lookupTool(tc.lookup) if diff := cmp.Diff(tc.want, got); diff != "" { - t.Errorf("getCachedTool(%q) mismatch (-want +got):\n%s", tc.lookup, diff) + t.Errorf("lookupTool(%q) mismatch (-want +got):\n%s", tc.lookup, diff) } }) } diff --git a/mcp/protocol.go b/mcp/protocol.go index 6e87f19..5447212 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -1102,11 +1102,53 @@ func (x *ListPromptsParams) GetProgressToken() any { return getProgressToken(x) func (x *ListPromptsParams) SetProgressToken(t any) { setProgressToken(x, t) } func (x *ListPromptsParams) cursorPtr() *string { return &x.Cursor } +// CacheableResult is a result that supports a time-to-live (TTL) hint for +// client-side caching. +type CacheableResult interface { + Result + GetTTLMs() int + GetCacheScope() string +} + +// Cacheable describes a result that supports a time-to-live (TTL) hint for +// client-side caching. +type Cacheable struct { + // A hint from the server indicating how long (in milliseconds) the + // client MAY cache this response before re-fetching. Semantics are + // analogous to HTTP Cache-Control max-age. + // + // If 0, the response SHOULD be considered immediately stale. + // If positive, the client SHOULD consider the result fresh for this + // many milliseconds after receiving the response. + TTLMs int `json:"ttlMs"` + + // Indicates the intended scope of the cached response, analogous to + // HTTP Cache-Control: public vs Cache-Control: private. + // + // "public": Any client or intermediary MAY cache and serve the response. + // "private": Only the requesting user's client MAY cache the response. + // + // Defaults to "public" if absent. + CacheScope string `json:"cacheScope"` +} + +// GetTTLMs returns the TTL hint in milliseconds. +func (c Cacheable) GetTTLMs() int { return c.TTLMs } + +// GetCacheScope returns the cache scope. +func (c Cacheable) GetCacheScope() string { return c.CacheScope } + +// setDefaultCacheableValues sets the default values for the cacheable fields. +func (c *Cacheable) setDefaultCacheableValues() { + c.CacheScope = "public" +} + // The server's response to a prompts/list request from the client. type ListPromptsResult struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` + Cacheable // An opaque token representing the pagination position after the last returned // result. If present, there may be more results available. NextCursor string `json:"nextCursor,omitempty"` @@ -1136,6 +1178,7 @@ type ListResourceTemplatesResult struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` + Cacheable // An opaque token representing the pagination position after the last returned // result. If present, there may be more results available. NextCursor string `json:"nextCursor,omitempty"` @@ -1165,6 +1208,7 @@ type ListResourcesResult struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` + Cacheable // An opaque token representing the pagination position after the last returned // result. If present, there may be more results available. NextCursor string `json:"nextCursor,omitempty"` @@ -1219,6 +1263,7 @@ type ListToolsResult struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` + Cacheable // An opaque token representing the pagination position after the last returned // result. If present, there may be more results available. NextCursor string `json:"nextCursor,omitempty"` @@ -1460,7 +1505,8 @@ func (x *ReadResourceParams) SetProgressToken(t any) { setProgressToken(x, t) } type ReadResourceResult struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. - Meta `json:"_meta,omitempty"` + Meta `json:"_meta,omitempty"` + Cacheable Contents []*ResourceContents `json:"contents"` // InputRequests is populated when ResultType is ResultTypeInputRequired. diff --git a/mcp/server.go b/mcp/server.go index 6cd084e..a53cb64 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -740,12 +740,17 @@ func (s *Server) listPrompts(_ context.Context, req *ListPromptsRequest) (*ListP if req.Params == nil { req.Params = &ListPromptsParams{} } - return paginateList(s.prompts, s.opts.PageSize, req.Params, &ListPromptsResult{}, func(res *ListPromptsResult, prompts []*serverPrompt) { + res, err := paginateList(s.prompts, s.opts.PageSize, req.Params, &ListPromptsResult{}, func(res *ListPromptsResult, prompts []*serverPrompt) { res.Prompts = []*Prompt{} // avoid JSON null for _, p := range prompts { res.Prompts = append(res.Prompts, p.prompt) } }) + if err != nil { + return nil, err + } + res.setDefaultCacheableValues() + return res, nil } func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetPromptResult, error) { @@ -817,12 +822,17 @@ func (s *Server) listTools(_ context.Context, req *ListToolsRequest) (*ListTools if req.Params == nil { req.Params = &ListToolsParams{} } - return paginateList(s.tools, s.opts.PageSize, req.Params, &ListToolsResult{}, func(res *ListToolsResult, tools []*serverTool) { + res, err := paginateList(s.tools, s.opts.PageSize, req.Params, &ListToolsResult{}, func(res *ListToolsResult, tools []*serverTool) { res.Tools = []*Tool{} // avoid JSON null for _, t := range tools { res.Tools = append(res.Tools, t.tool) } }) + if err != nil { + return nil, err + } + res.setDefaultCacheableValues() + return res, nil } // getServerTool looks up a server tool by name. @@ -860,12 +870,17 @@ func (s *Server) listResources(_ context.Context, req *ListResourcesRequest) (*L if req.Params == nil { req.Params = &ListResourcesParams{} } - return paginateList(s.resources, s.opts.PageSize, req.Params, &ListResourcesResult{}, func(res *ListResourcesResult, resources []*serverResource) { + res, err := paginateList(s.resources, s.opts.PageSize, req.Params, &ListResourcesResult{}, func(res *ListResourcesResult, resources []*serverResource) { res.Resources = []*Resource{} // avoid JSON null for _, r := range resources { res.Resources = append(res.Resources, r.resource) } }) + if err != nil { + return nil, err + } + res.setDefaultCacheableValues() + return res, nil } func (s *Server) listResourceTemplates(_ context.Context, req *ListResourceTemplatesRequest) (*ListResourceTemplatesResult, error) { @@ -874,13 +889,18 @@ func (s *Server) listResourceTemplates(_ context.Context, req *ListResourceTempl if req.Params == nil { req.Params = &ListResourceTemplatesParams{} } - return paginateList(s.resourceTemplates, s.opts.PageSize, req.Params, &ListResourceTemplatesResult{}, + res, err := paginateList(s.resourceTemplates, s.opts.PageSize, req.Params, &ListResourceTemplatesResult{}, func(res *ListResourceTemplatesResult, rts []*serverResourceTemplate) { res.ResourceTemplates = []*ResourceTemplate{} // avoid JSON null for _, rt := range rts { res.ResourceTemplates = append(res.ResourceTemplates, rt.resourceTemplate) } }) + if err != nil { + return nil, err + } + res.setDefaultCacheableValues() + return res, nil } func (s *Server) readResource(ctx context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) { @@ -900,6 +920,7 @@ func (s *Server) readResource(ctx context.Context, req *ReadResourceRequest) (*R if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil { return nil, err } + res.setDefaultCacheableValues() if res.resultType == resultTypeInputRequired { return res, nil } diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index f3bc98d..79934b4 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -2227,6 +2227,20 @@ func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) { return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil }) + // Install a receiving middleware on the server that opts the tools/list + // response caching by setting a positive ttlMs. + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + res, err := next(ctx, method, req) + if err == nil && method == methodListTools { + if r, ok := res.(*ListToolsResult); ok { + r.TTLMs = 60_000 + } + } + return res, err + } + }) + handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, &StreamableHTTPOptions{ Stateless: true, }) diff --git a/mcp/testdata/conformance/server/lifecycle.txtar b/mcp/testdata/conformance/server/lifecycle.txtar index 0a8cf34..652aa7f 100644 --- a/mcp/testdata/conformance/server/lifecycle.txtar +++ b/mcp/testdata/conformance/server/lifecycle.txtar @@ -53,6 +53,8 @@ See also modelcontextprotocol/go-sdk#225. "jsonrpc": "2.0", "id": 2, "result": { + "ttlMs": 0, + "cacheScope": "public", "tools": [] } } @@ -60,6 +62,8 @@ See also modelcontextprotocol/go-sdk#225. "jsonrpc": "2.0", "id": 3, "result": { + "ttlMs": 0, + "cacheScope": "public", "tools": [] } } diff --git a/mcp/testdata/conformance/server/prompts.txtar b/mcp/testdata/conformance/server/prompts.txtar index fdaf793..2d8e168 100644 --- a/mcp/testdata/conformance/server/prompts.txtar +++ b/mcp/testdata/conformance/server/prompts.txtar @@ -45,6 +45,8 @@ code_review "jsonrpc": "2.0", "id": 2, "result": { + "ttlMs": 0, + "cacheScope": "public", "tools": [] } } @@ -52,6 +54,8 @@ code_review "jsonrpc": "2.0", "id": 4, "result": { + "ttlMs": 0, + "cacheScope": "public", "prompts": [ { "arguments": [ diff --git a/mcp/testdata/conformance/server/resources.txtar b/mcp/testdata/conformance/server/resources.txtar index 314817b..1c3ed80 100644 --- a/mcp/testdata/conformance/server/resources.txtar +++ b/mcp/testdata/conformance/server/resources.txtar @@ -67,6 +67,8 @@ info.txt "jsonrpc": "2.0", "id": 2, "result": { + "ttlMs": 0, + "cacheScope": "public", "resources": [ { "mimeType": "text/plain", @@ -85,6 +87,8 @@ info.txt "jsonrpc": "2.0", "id": 3, "result": { + "ttlMs": 0, + "cacheScope": "public", "contents": [ { "uri": "embedded:info", @@ -103,6 +107,8 @@ info.txt "jsonrpc": "2.0", "id": 3, "result": { + "ttlMs": 0, + "cacheScope": "public", "contents": [ { "uri": "file:///info.txt", diff --git a/mcp/testdata/conformance/server/spec-sep-973-additional-metadata.txtar b/mcp/testdata/conformance/server/spec-sep-973-additional-metadata.txtar index 2add92e..356be18 100644 --- a/mcp/testdata/conformance/server/spec-sep-973-additional-metadata.txtar +++ b/mcp/testdata/conformance/server/spec-sep-973-additional-metadata.txtar @@ -87,6 +87,8 @@ infoWithIcon "jsonrpc": "2.0", "id": 2, "result": { + "ttlMs": 0, + "cacheScope": "public", "tools": [ { "description": "return resourceLink content with Icon", @@ -140,6 +142,8 @@ infoWithIcon "jsonrpc": "2.0", "id": 3, "result": { + "ttlMs": 0, + "cacheScope": "public", "resources": [ { "mimeType": "text/plain", @@ -164,6 +168,8 @@ infoWithIcon "jsonrpc": "2.0", "id": 4, "result": { + "ttlMs": 0, + "cacheScope": "public", "prompts": [ { "arguments": [ diff --git a/mcp/testdata/conformance/server/tools.txtar b/mcp/testdata/conformance/server/tools.txtar index aadad12..ba6fd33 100644 --- a/mcp/testdata/conformance/server/tools.txtar +++ b/mcp/testdata/conformance/server/tools.txtar @@ -64,6 +64,8 @@ inc "jsonrpc": "2.0", "id": 2, "result": { + "ttlMs": 0, + "cacheScope": "public", "tools": [ { "description": "say hi", @@ -169,6 +171,8 @@ inc "jsonrpc": "2.0", "id": 3, "result": { + "ttlMs": 0, + "cacheScope": "public", "resources": [] } } @@ -176,6 +180,8 @@ inc "jsonrpc": "2.0", "id": 4, "result": { + "ttlMs": 0, + "cacheScope": "public", "prompts": [] } } From b85574f6851a9d9d0d91f0e05d10ef286a43fee9 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Thu, 18 Jun 2026 15:52:11 +0200 Subject: [PATCH 308/418] mcp: Enable legacy initialize fallback on any error (#1014) To guarantee a better compatibility with legacy server, enable the fallback to the legacy initialize rpc in case of any error returned by the server, in case of `server/discover` request. --- internal/jsonrpc2/conn.go | 2 +- mcp/client.go | 17 ++--- mcp/client_test.go | 69 -------------------- mcp/streamable.go | 26 ++++---- mcp/streamable_client_test.go | 118 +++++++++++++++++++--------------- 5 files changed, 84 insertions(+), 148 deletions(-) diff --git a/internal/jsonrpc2/conn.go b/internal/jsonrpc2/conn.go index 3b4bc57..074ceab 100644 --- a/internal/jsonrpc2/conn.go +++ b/internal/jsonrpc2/conn.go @@ -735,7 +735,7 @@ func (c *Connection) write(ctx context.Context, msg Message) error { // For cancelled or rejected requests, we don't set the writeErr (which would // break the connection). They can just be returned to the caller. - if err != nil && ctx.Err() == nil && !errors.Is(err, ErrRejected) && !errors.Is(err, ErrUnsupportedProtocolVersion) && !errors.Is(err, ErrMethodNotFound) { + if err != nil && ctx.Err() == nil && !errors.Is(err, ErrRejected) { // The call to Write failed, and since ctx.Err() is nil we can't attribute // the failure (even indirectly) to Context cancellation. The writer appears // to be broken, and future writes are likely to also fail. diff --git a/mcp/client.go b/mcp/client.go index 5d4beb2..b89b962 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -303,13 +303,10 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp return cs, nil } - var werr *jsonrpc.Error - if !errors.As(err, &werr) { - return nil, err - } // Try to negotiate a mutually supported version if the server // reports an UnsupportedProtocolVersionError with a supported version. - if werr.Code == CodeUnsupportedProtocolVersion && werr.Data != nil { + var werr *jsonrpc.Error + if errors.As(err, &werr) && werr.Code == CodeUnsupportedProtocolVersion && werr.Data != nil { var data UnsupportedProtocolVersionData if err := json.Unmarshal(werr.Data, &data); err == nil { if negotiatedVersion := negotiateMutuallySupportedVersion(data.Supported); negotiatedVersion != "" && negotiatedVersion >= protocolVersion20260630 { @@ -318,13 +315,11 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp } } } - // MethodNotFound and UnsupportedProtocolVersion trigger a fallback to legacy initialize. - if werr.Code == jsonrpc.CodeMethodNotFound || werr.Code == CodeUnsupportedProtocolVersion { - break - } - return nil, err + // Per the spec, fall back to the legacy initialize handshake on any + // non-modern error from server/discover. + break } - // Fallback to the legacy initialize handshake with the legacy protocol version. + // Use the latest legacy protocol version for the fallback initialize. protocolVersion = protocolVersion20251125 } diff --git a/mcp/client_test.go b/mcp/client_test.go index 23ee034..d6f0232 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -657,7 +657,6 @@ func TestClientConnectDiscover(t *testing.T) { // Returning (nil, nil) means "let the default stub handle it" (which // returns ErrMethodNotFound). discoverHandler func() (Result, error) - wantConnectErr bool // wantInitialize is true if the legacy initialize handshake should // have run (i.e. discover signaled "not supported"). wantInitialize bool @@ -710,16 +709,6 @@ func TestClientConnectDiscover(t *testing.T) { wantInitialize: true, wantVersion: latestProtocolVersion, }, - { - name: "unexpected error propagates and aborts Connect", - discoverHandler: func() (Result, error) { - return nil, &jsonrpc.Error{ - Code: jsonrpc.CodeInternalError, - Message: "boom", - } - }, - wantConnectErr: true, - }, } for _, tc := range tests { @@ -754,19 +743,6 @@ func TestClientConnectDiscover(t *testing.T) { c := NewClient(testImpl, nil) cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) - if tc.wantConnectErr { - if err == nil { - _ = cs.Close() - t.Fatal("Connect succeeded, want error") - } - if !gotDiscover.Load() { - t.Error("server did not receive server/discover") - } - if gotInitialize.Load() { - t.Error("server received initialize but discover should have aborted Connect") - } - return - } if err != nil { t.Fatalf("Connect: %v", err) } @@ -1061,51 +1037,6 @@ func TestInMemory_E2E_DiscoverFallback_UnsupportedProtocolVersion(t *testing.T) } } -// TestInMemory_E2E_DiscoverPropagatesOtherErrors verifies that an unrelated -// error from the discover handler aborts Connect and does NOT silently -// fall back. -func TestInMemory_E2E_DiscoverPropagatesOtherErrors(t *testing.T) { - ctx := context.Background() - - orig := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) - t.Cleanup(func() { supportedProtocolVersions = orig }) - - var sawInitialize atomic.Bool - server := NewServer(&Implementation{Name: "broken-server", Version: "v1"}, nil) - server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { - return func(ctx context.Context, method string, req Request) (Result, error) { - switch method { - case methodDiscover: - return nil, &jsonrpc.Error{ - Code: jsonrpc.CodeInternalError, - Message: "boom", - } - case methodInitialize: - sawInitialize.Store(true) - } - return next(ctx, method, req) - } - }) - - ct, st := NewInMemoryTransports() - ss, err := server.Connect(ctx, st, nil) - if err != nil { - t.Fatalf("server.Connect: %v", err) - } - defer ss.Close() - - client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil) - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) - if err == nil { - _ = cs.Close() - t.Fatal("Connect succeeded; want propagated discover error") - } - if sawInitialize.Load() { - t.Error("server received initialize; Connect should have aborted on the discover error") - } -} - // TestClientConnectDiscover_UnsupportedVersionNegotiation verifies the // per SEP-2575 Version Negotiation Flow: when the client probes server/discover // with a protocol version the server doesn't implement, the server's diff --git a/mcp/streamable.go b/mcp/streamable.go index 2f60fb8..3d2de97 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -2103,12 +2103,14 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e } var requestSummary string + var requestMethod string var forCall *jsonrpc.Request switch msg := msg.(type) { case *jsonrpc.Request: requestSummary = fmt.Sprintf("sending %q", msg.Method) if msg.IsCall() { forCall = msg + requestMethod = msg.Method } case *jsonrpc.Response: requestSummary = fmt.Sprintf("sending jsonrpc response #%d", msg.ID) @@ -2184,10 +2186,14 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e } if err := c.checkResponse(ctx, requestSummary, resp); err != nil { - // Only fail the connection for non-transient errors. - // Transient errors (wrapped with ErrRejected) should not break the connection. - // ErrMethodNotFound and ErrUnsupportedProtocolVersion should not break the connection as they trigger the initialize fallback. - if !errors.Is(err, jsonrpc2.ErrRejected) && !errors.Is(err, jsonrpc2.ErrMethodNotFound) && !errors.Is(err, jsonrpc2.ErrUnsupportedProtocolVersion) { + if requestMethod == methodDiscover { + // Wrap the discover failure with ErrRejected so the jsonrpc2 layer + // doesn't set writeErr, which would prevent the legacy initialize + // fallback from succeeding on the same connection. + err = fmt.Errorf("%w: %w", err, jsonrpc2.ErrRejected) + } else if !errors.Is(err, jsonrpc2.ErrRejected) { + // Only fail the connection for non-transient errors. + // Transient errors (wrapped with ErrRejected) should not break the connection. c.fail(err) } return err @@ -2400,10 +2406,8 @@ func (c *streamableClientConn) checkResponse(ctx context.Context, requestSummary } if resp.StatusCode < 200 || resp.StatusCode >= 300 { // By default, always try to decode the body and surface the underlying - // JSON-RPC error (or detect vPre servers that reject "server/discover" - // as an unsupported method with a plain HTTP 400) regardless of the - // negotiated protocol version. Setting MCPGODEBUG=noprotocolerrorbody=1 - // restores the previous behavior. + // JSON-RPC error. + // Setting MCPGODEBUG=noprotocolerrorbody=1 restores the previous behavior. if noprotocolerrorbody == "1" { return fmt.Errorf("%s: %v", requestSummary, http.StatusText(resp.StatusCode)) } @@ -2412,12 +2416,6 @@ func (c *streamableClientConn) checkResponse(ctx context.Context, requestSummary if response, ok := msg.(*jsonrpc.Response); ok && response.Error != nil { return fmt.Errorf("%s: %w: %v", requestSummary, response.Error, http.StatusText(resp.StatusCode)) } - if strings.Contains(string(body), fmt.Sprintf("%s: %q unsupported", jsonrpc2.ErrNotHandled, methodDiscover)) { - return fmt.Errorf("%s: %w: %v", requestSummary, jsonrpc2.ErrMethodNotFound, http.StatusText(resp.StatusCode)) - } - if strings.Contains(string(body), "Unsupported protocol version") { - return fmt.Errorf("%s: %w: %v", requestSummary, jsonrpc2.ErrUnsupportedProtocolVersion, http.StatusText(resp.StatusCode)) - } return fmt.Errorf("%s: %v", requestSummary, http.StatusText(resp.StatusCode)) } return nil diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 785e894..2e372af 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -1470,59 +1470,6 @@ func TestStreamableClientConnect_DiscoverUnsupportedVersion(t *testing.T) { } } -// TestStreamableClientConnect_DiscoverPropagatesOtherErrors verifies that -// Client.Connect does NOT fall back to initialize when server/discover -// returns an unrelated JSON-RPC error (here, CodeInternalError). The Connect -// call should fail with the propagated error rather than masking it. -func TestStreamableClientConnect_DiscoverPropagatesOtherErrors(t *testing.T) { - ctx := context.Background() - - var sawInitialize atomic.Bool - fake := &fakeStreamableServer{ - t: t, - responses: fakeResponses{ - {"POST", "", methodDiscover, ""}: { - header: header{"Content-Type": "application/json"}, - wantProtocolVersion: protocolVersion20260630, - responseFunc: func(r *jsonrpc.Request) (string, int) { - return jsonBody(t, &jsonrpc.Response{ - ID: r.ID, - Error: &jsonrpc.Error{ - Code: jsonrpc.CodeInternalError, - Message: "boom", - }, - }), http.StatusOK - }, - }, - {"POST", "", methodInitialize, ""}: { - responseFunc: func(r *jsonrpc.Request) (string, int) { - sawInitialize.Store(true) - return jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(initResult)}), http.StatusOK - }, - header: header{ - "Content-Type": "application/json", - sessionIDHeader: "fallback", - }, - optional: true, - }, - }, - } - - httpServer := httptest.NewServer(fake) - defer httpServer.Close() - - transport := &StreamableClientTransport{Endpoint: httpServer.URL} - client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) - if err == nil { - _ = session.Close() - t.Fatal("Connect succeeded; want propagated error") - } - if sawInitialize.Load() { - t.Error("server received initialize; Connect should have aborted on the discover error") - } -} - // TestStreamableClientConnect_DiscoverMethodNotFoundVPre verifies that // Client.Connect falls back to the legacy initialize handshake when a // pre-SEP-2575 (vPre) server rejects server/discover. @@ -1635,3 +1582,68 @@ func TestStreamableClientConnect_DiscoverUnsupportedVersionVPre(t *testing.T) { t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (initialize fallback)", got, latestProtocolVersion) } } + +// TestStreamableClientConnect_DiscoverUnsupportedVersionNegotiation verifies that +// when Client.Connect over a streamable transport receives an +// UnsupportedProtocolVersion error containing Data.Supported, it negotiates a +// mutually supported version and retries server/discover. +func TestStreamableClientConnect_DiscoverUnsupportedVersionNegotiation(t *testing.T) { + ctx := context.Background() + + oldSupported := supportedProtocolVersions + supportedProtocolVersions = append([]string{protocolVersion20260630}, supportedProtocolVersions...) + t.Cleanup(func() { + supportedProtocolVersions = oldSupported + }) + + const unsupportedClientVersion = "2099-12-31" + + var discoverCalls atomic.Int32 + + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodDiscover, ""}: { + header: header{ + "Content-Type": "application/json", + }, + responseFunc: func(r *jsonrpc.Request) (string, int) { + n := discoverCalls.Add(1) + if n == 1 { + data, _ := json.Marshal(UnsupportedProtocolVersionData{ + Supported: []string{protocolVersion20260630}, + }) + respMsg := &jsonrpc.Response{ + ID: r.ID, + Error: &jsonrpc.Error{ + Code: CodeUnsupportedProtocolVersion, + Message: "unsupported protocol version", + Data: data, + }, + } + return jsonBody(t, respMsg), http.StatusOK + } + return jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(discoverResult)}), http.StatusOK + }, + }, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: unsupportedClientVersion}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer session.Close() + + if got, want := discoverCalls.Load(), int32(2); got != want { + t.Errorf("discover call count = %d, want %d", got, want) + } + if got := session.InitializeResult().ProtocolVersion; got != protocolVersion20260630 { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q", got, protocolVersion20260630) + } +} From 17029489fff03e9577d45424be90459dd3d25bee Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Mon, 22 Jun 2026 09:53:04 +0200 Subject: [PATCH 309/418] mcp: bump protocol version from 2026-06-30 to 2026-07-28 (#1015) The new official date for the protocol version has been moved to '2026-07-28'. Update all the references in the code of the old version. --- mcp/client.go | 12 ++++---- mcp/client_test.go | 48 +++++++++++++++---------------- mcp/mrtr.go | 2 +- mcp/mrtr_test.go | 16 +++++------ mcp/protocol.go | 2 +- mcp/server.go | 6 ++-- mcp/server_test.go | 14 ++++----- mcp/shared.go | 14 ++++----- mcp/shared_test.go | 22 +++++++------- mcp/streamable.go | 44 ++++++++++++++-------------- mcp/streamable_client_test.go | 38 ++++++++++++------------ mcp/streamable_headers.go | 2 +- mcp/streamable_test.go | 54 +++++++++++++++++------------------ 13 files changed, 137 insertions(+), 137 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index b89b962..785b186 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -165,7 +165,7 @@ type ClientOptions struct { // If non-zero, defines an interval for regular "ping" requests. // If the peer fails to respond to pings originating from the keepalive check, // the session is automatically closed. - // NOTE: The keepalive feature is only available for protocol versions < 2026-06-30 + // NOTE: The keepalive feature is only available for protocol versions < 2026-07-28 KeepAlive time.Duration // KeepAliveFailureThreshold is the number of consecutive keepalive ping // failures tolerated before the session is closed. A value of 0 or 1 @@ -285,7 +285,7 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp protocolVersion = opts.protocolVersion } - if protocolVersion >= protocolVersion20260630 { + if protocolVersion >= protocolVersion20260728 { // Per SEP-2575, try the stateless server/discover RPC first. If the server // signals it doesn't support it, fall back to the legacy initialize // handshake. @@ -309,7 +309,7 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp if errors.As(err, &werr) && werr.Code == CodeUnsupportedProtocolVersion && werr.Data != nil { var data UnsupportedProtocolVersionData if err := json.Unmarshal(werr.Data, &data); err == nil { - if negotiatedVersion := negotiateMutuallySupportedVersion(data.Supported); negotiatedVersion != "" && negotiatedVersion >= protocolVersion20260630 { + if negotiatedVersion := negotiateMutuallySupportedVersion(data.Supported); negotiatedVersion != "" && negotiatedVersion >= protocolVersion20260728 { discoverCtx = context.WithValue(ctx, protocolVersionContextKey{}, negotiatedVersion) continue } @@ -381,7 +381,7 @@ func (c *Client) discover(ctx context.Context, cs *ClientSession) (*InitializeRe } else { negotiated = negotiateMutuallySupportedVersion(res.SupportedVersions) } - if negotiated == "" || negotiated < protocolVersion20260630 { + if negotiated == "" || negotiated < protocolVersion20260728 { // If there is no overlap, fall back to initialize so version // negotiation can happen via the legacy path. return nil, jsonrpc2.ErrUnsupportedProtocolVersion @@ -436,11 +436,11 @@ type clientSessionState struct { func (cs *ClientSession) InitializeResult() *InitializeResult { return cs.state.InitializeResult } // usesNewProtocol reports whether this session has negotiated a protocol -// version >= 2026-06-30, which requires the SEP-2575 per-request `_meta` +// version >= 2026-07-28, which requires the SEP-2575 per-request `_meta` // triple on every outgoing request. func (cs *ClientSession) usesNewProtocol() bool { res := cs.state.InitializeResult - return res != nil && res.ProtocolVersion >= protocolVersion20260630 + return res != nil && res.ProtocolVersion >= protocolVersion20260728 } // injectRequestMeta populates the SEP-2575 per-request `_meta` triple diff --git a/mcp/client_test.go b/mcp/client_test.go index d6f0232..5e3a94a 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -642,9 +642,9 @@ func TestClientCapabilitiesOverWire(t *testing.T) { // don't overlap with the SDK. The test then asserts the resulting session // state and whether the legacy initialize handshake ran. func TestClientConnectDiscover(t *testing.T) { - // Temporarily enable 2026-06-30 support in the SDK for this test + // Temporarily enable 2026-07-28 support in the SDK for this test oldSupported := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260630}, supportedProtocolVersions...) + supportedProtocolVersions = append([]string{protocolVersion20260728}, supportedProtocolVersions...) t.Cleanup(func() { supportedProtocolVersions = oldSupported }) @@ -668,7 +668,7 @@ func TestClientConnectDiscover(t *testing.T) { name: "discover success skips initialize", discoverHandler: func() (Result, error) { return &DiscoverResult{ - SupportedVersions: []string{protocolVersion20260630}, + SupportedVersions: []string{protocolVersion20260728}, Capabilities: &ServerCapabilities{ Tools: &ToolCapabilities{ListChanged: true}, }, @@ -676,7 +676,7 @@ func TestClientConnectDiscover(t *testing.T) { }, nil }, wantInitialize: false, - wantVersion: protocolVersion20260630, + wantVersion: protocolVersion20260728, }, { name: "method not found falls back to initialize", @@ -742,7 +742,7 @@ func TestClientConnectDiscover(t *testing.T) { defer ss.Close() c := NewClient(testImpl, nil) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -770,7 +770,7 @@ func TestClientConnectDiscover(t *testing.T) { // protocolVersion, clientInfo, and clientCapabilities. func TestClientConnectDiscover_RequestContents(t *testing.T) { orig := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) + supportedProtocolVersions = append([]string{protocolVersion20260728}, slices.Clone(orig)...) t.Cleanup(func() { supportedProtocolVersions = orig }) ctx := context.Background() @@ -811,7 +811,7 @@ func TestClientConnectDiscover_RequestContents(t *testing.T) { return nil, nil }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -822,8 +822,8 @@ func TestClientConnectDiscover_RequestContents(t *testing.T) { } meta := got.params.GetMeta() - if v, _ := meta[MetaKeyProtocolVersion].(string); v != protocolVersion20260630 { - t.Errorf("_meta[%s] = %q, want %q", MetaKeyProtocolVersion, v, protocolVersion20260630) + if v, _ := meta[MetaKeyProtocolVersion].(string); v != protocolVersion20260728 { + t.Errorf("_meta[%s] = %q, want %q", MetaKeyProtocolVersion, v, protocolVersion20260728) } // _meta values round-trip through JSON, so on the server side they // arrive as map[string]any rather than the typed Go pointers we sent. @@ -849,7 +849,7 @@ func TestInMemory_E2E_DiscoverSuccess(t *testing.T) { ctx := context.Background() orig := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) + supportedProtocolVersions = append([]string{protocolVersion20260728}, slices.Clone(orig)...) t.Cleanup(func() { supportedProtocolVersions = orig }) server := NewServer(&Implementation{Name: "stdio-like-server", Version: "v1"}, nil) @@ -861,7 +861,7 @@ func TestInMemory_E2E_DiscoverSuccess(t *testing.T) { defer ss.Close() client := NewClient(&Implementation{Name: "stdio-like-client", Version: "v1"}, nil) - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client.Connect: %v", err) } @@ -871,9 +871,9 @@ func TestInMemory_E2E_DiscoverSuccess(t *testing.T) { if ir == nil { t.Fatal("InitializeResult is nil; discover should have populated it") } - if ir.ProtocolVersion != protocolVersion20260630 { + if ir.ProtocolVersion != protocolVersion20260728 { t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (negotiated via discover, no initialize)", - ir.ProtocolVersion, protocolVersion20260630) + ir.ProtocolVersion, protocolVersion20260728) } if ir.ServerInfo == nil || ir.ServerInfo.Name != "stdio-like-server" { t.Errorf("InitializeResult.ServerInfo = %+v, want name=stdio-like-server", ir.ServerInfo) @@ -887,13 +887,13 @@ func TestInMemory_E2E_DiscoverSuccess(t *testing.T) { // TestInMemory_E2E_DiscoverFallback_NoOverlap verifies the fallback path // over an InMemory (STDIO-equivalent) transport: the client probes with -// _meta.protocolVersion = 2026-06-30, but the server's supported list does +// _meta.protocolVersion = 2026-07-28, but the server's supported list does // NOT include that version (the default for an SDK server that hasn't // shimmed supportedProtocolVersions). func TestInMemory_E2E_DiscoverFallback_NoOverlap(t *testing.T) { ctx := context.Background() orig := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) + supportedProtocolVersions = append([]string{protocolVersion20260728}, slices.Clone(orig)...) t.Cleanup(func() { supportedProtocolVersions = orig }) server := NewServer(&Implementation{Name: "vpre-like-server", Version: "v1"}, nil) @@ -920,7 +920,7 @@ func TestInMemory_E2E_DiscoverFallback_NoOverlap(t *testing.T) { defer ss.Close() client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil) - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client.Connect: %v", err) } @@ -948,7 +948,7 @@ func TestInMemory_E2E_DiscoverFallback_MethodNotFound(t *testing.T) { ctx := context.Background() orig := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) + supportedProtocolVersions = append([]string{protocolVersion20260728}, slices.Clone(orig)...) t.Cleanup(func() { supportedProtocolVersions = orig }) server := NewServer(&Implementation{Name: "vpre-server", Version: "v1"}, nil) @@ -969,7 +969,7 @@ func TestInMemory_E2E_DiscoverFallback_MethodNotFound(t *testing.T) { defer ss.Close() client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil) - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client.Connect: %v", err) } @@ -997,7 +997,7 @@ func TestInMemory_E2E_DiscoverFallback_UnsupportedProtocolVersion(t *testing.T) ctx := context.Background() orig := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) + supportedProtocolVersions = append([]string{protocolVersion20260728}, slices.Clone(orig)...) t.Cleanup(func() { supportedProtocolVersions = orig }) server := NewServer(&Implementation{Name: "strict-server", Version: "v1"}, nil) @@ -1021,7 +1021,7 @@ func TestInMemory_E2E_DiscoverFallback_UnsupportedProtocolVersion(t *testing.T) defer ss.Close() client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil) - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client.Connect: %v", err) } @@ -1044,7 +1044,7 @@ func TestInMemory_E2E_DiscoverFallback_UnsupportedProtocolVersion(t *testing.T) // selects a mutually supported version from that list and retries. func TestClientConnectDiscover_UnsupportedVersionNegotiation(t *testing.T) { oldSupported := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(oldSupported)...) + supportedProtocolVersions = append([]string{protocolVersion20260728}, slices.Clone(oldSupported)...) t.Cleanup(func() { supportedProtocolVersions = oldSupported }) ctx := context.Background() @@ -1095,8 +1095,8 @@ func TestClientConnectDiscover_UnsupportedVersionNegotiation(t *testing.T) { if got, want := discoverCalls.Load(), int32(1); got != want { t.Errorf("server/discover handler call count = %d, want %d (first probe is rejected by the dispatcher; only the retry reaches the handler)", got, want) } - if got, _ := observedDiscoverVersion.Load().(string); got != protocolVersion20260630 { - t.Errorf("retry discover requested version = %q, want %q (highest mutually supported version)", got, protocolVersion20260630) + if got, _ := observedDiscoverVersion.Load().(string); got != protocolVersion20260728 { + t.Errorf("retry discover requested version = %q, want %q (highest mutually supported version)", got, protocolVersion20260728) } if gotInitialize.Load() { t.Error("legacy initialize handshake ran, but negotiated discover should have succeeded") @@ -1106,7 +1106,7 @@ func TestClientConnectDiscover_UnsupportedVersionNegotiation(t *testing.T) { if ir == nil { t.Fatal("InitializeResult is nil after Connect") } - if got, want := ir.ProtocolVersion, protocolVersion20260630; got != want { + if got, want := ir.ProtocolVersion, protocolVersion20260728; got != want { t.Errorf("InitializeResult.ProtocolVersion = %q, want %q", got, want) } } diff --git a/mcp/mrtr.go b/mcp/mrtr.go index 8ad5f1a..5f65f56 100644 --- a/mcp/mrtr.go +++ b/mcp/mrtr.go @@ -67,7 +67,7 @@ func clientSupportsMultiRoundTrip(ss *ServerSession) bool { if iparams := ss.InitializeParams(); iparams != nil { protocolVersion = iparams.ProtocolVersion } - return protocolVersion >= protocolVersion20260630 + return protocolVersion >= protocolVersion20260728 } func clientMultiRoundTripMiddleware() Middleware { diff --git a/mcp/mrtr_test.go b/mcp/mrtr_test.go index 230eb3b..c0335d8 100644 --- a/mcp/mrtr_test.go +++ b/mcp/mrtr_test.go @@ -22,7 +22,7 @@ func TestMultiRoundTrip_ManualRetry(t *testing.T) { } orig := supportedProtocolVersions - supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630) + supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260728) t.Cleanup(func() { supportedProtocolVersions = orig }) ctx := context.Background() @@ -94,7 +94,7 @@ func TestMultiRoundTrip_ManualRetry(t *testing.T) { func TestMultiRoundTrip_AutoRetry(t *testing.T) { orig := supportedProtocolVersions - supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630) + supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260728) t.Cleanup(func() { supportedProtocolVersions = orig }) tests := []struct { @@ -219,7 +219,7 @@ func TestMultiRoundTrip_MaxRetries(t *testing.T) { }, } orig := supportedProtocolVersions - supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630) + supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260728) t.Cleanup(func() { supportedProtocolVersions = orig }) for _, tc := range testCases { @@ -339,7 +339,7 @@ func TestMultiRoundTrip_ServerMiddleware(t *testing.T) { func TestMultiRoundTrip_GetPrompt_AutoRetry(t *testing.T) { orig := supportedProtocolVersions - supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630) + supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260728) t.Cleanup(func() { supportedProtocolVersions = orig }) ctx := context.Background() @@ -381,7 +381,7 @@ func TestMultiRoundTrip_GetPrompt_AutoRetry(t *testing.T) { func TestMultiRoundTrip_GetPrompt_ManualRetry(t *testing.T) { orig := supportedProtocolVersions - supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630) + supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260728) t.Cleanup(func() { supportedProtocolVersions = orig }) ctx := context.Background() @@ -433,7 +433,7 @@ func TestMultiRoundTrip_GetPrompt_ManualRetry(t *testing.T) { func TestMultiRoundTrip_ReadResource_AutoRetry(t *testing.T) { orig := supportedProtocolVersions - supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630) + supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260728) t.Cleanup(func() { supportedProtocolVersions = orig }) ctx := context.Background() @@ -474,7 +474,7 @@ func TestMultiRoundTrip_ReadResource_AutoRetry(t *testing.T) { func TestMultiRoundTrip_ReadResource_ManualRetry(t *testing.T) { orig := supportedProtocolVersions - supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630) + supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260728) t.Cleanup(func() { supportedProtocolVersions = orig }) ctx := context.Background() @@ -536,7 +536,7 @@ func mustConnect(t *testing.T, s *Server, clientOpts *ClientOptions) *ClientSess }) c := NewClient(testImpl, clientOpts) - cs, err := c.Connect(t.Context(), ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + cs, err := c.Connect(t.Context(), ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client.Connect() error = %v", err) } diff --git a/mcp/protocol.go b/mcp/protocol.go index 5447212..4b0872b 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -2138,7 +2138,7 @@ const ( methodUnsubscribe = "resources/unsubscribe" ) -// Per-request _meta field names for the >= 2026-06-30 protocol version. +// Per-request _meta field names for the >= 2026-07-28 protocol version. // // These keys appear inside a Params._meta map and carry information that // previously came from the initialization handshake (SEP-2575). diff --git a/mcp/server.go b/mcp/server.go index a53cb64..af59b40 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1441,7 +1441,7 @@ func (ss *ServerSession) Elicit(ctx context.Context, params *ElicitParams) (*Eli // logLevelContextKey carries the per-request log level from // [ServerSession.handle] to [ServerSession.Log] for new-protocol -// (>= 2026-06-30) requests. The level is scoped to a single in-flight request +// (>= 2026-07-28) requests. The level is scoped to a single in-flight request // — including handler goroutines that call [ServerSession.Log] concurrently — // rather than to the session, which avoids races between concurrent requests // and aligns with SEP-2575's per-request opt-in model. The value type is @@ -1450,7 +1450,7 @@ type logLevelContextKey struct{} // Log sends a log message to the client. // -// For new-protocol (>= 2026-06-30) requests, the level is taken from the +// For new-protocol (>= 2026-07-28) requests, the level is taken from the // originating request's `_meta` field (SEP-2575); an absent or empty value // suppresses the message per spec. For old-protocol requests, the level is // taken from the session state set via `logging/setLevel`. @@ -1609,7 +1609,7 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, case methodDiscover: // In case of methodDiscover call the state.initializeParams is populated // within the discover handle function to make sure the method is supported - // when the user is probing a pre-2026-06-30 server. + // when the user is probing a pre-2026-07-28 server. default: if !initialized && !validatedMeta.usesNewProtocol { ss.server.opts.Logger.Error("method invalid during initialization", "method", req.Method) diff --git a/mcp/server_test.go b/mcp/server_test.go index 4c8b76e..f6938d1 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -1291,7 +1291,7 @@ func TestServerCapabilitiesOverWire(t *testing.T) { // rejected with `Method not found` (-32601). func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) { orig := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) + supportedProtocolVersions = append([]string{protocolVersion20260728}, slices.Clone(orig)...) t.Cleanup(func() { supportedProtocolVersions = orig }) tests := []struct { @@ -1303,11 +1303,11 @@ func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) { name: "initialize with new-protocol _meta is rejected", params: map[string]any{ "_meta": map[string]any{ - MetaKeyProtocolVersion: protocolVersion20260630, + MetaKeyProtocolVersion: protocolVersion20260728, MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, MetaKeyClientCapabilities: map[string]any{}, }, - "protocolVersion": protocolVersion20260630, + "protocolVersion": protocolVersion20260728, }, wantReject: true, }, @@ -1372,11 +1372,11 @@ func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) { Method: methodInitialize, Params: mustMarshal(map[string]any{ "_meta": map[string]any{ - MetaKeyProtocolVersion: protocolVersion20260630, + MetaKeyProtocolVersion: protocolVersion20260728, MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, MetaKeyClientCapabilities: map[string]any{}, }, - "protocolVersion": protocolVersion20260630, + "protocolVersion": protocolVersion20260728, }), } _, handleErr := ss.handle(context.Background(), req) @@ -1408,12 +1408,12 @@ func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) { // protocol via `_meta.protocolVersion`. func TestServerSessionHandle_RejectsRemovedMethodsOnNewProtocol(t *testing.T) { orig := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) + supportedProtocolVersions = append([]string{protocolVersion20260728}, slices.Clone(orig)...) t.Cleanup(func() { supportedProtocolVersions = orig }) newProtoMeta := map[string]any{ "_meta": map[string]any{ - MetaKeyProtocolVersion: protocolVersion20260630, + MetaKeyProtocolVersion: protocolVersion20260728, MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, MetaKeyClientCapabilities: map[string]any{}, }, diff --git a/mcp/shared.go b/mcp/shared.go index ae1ee3c..128cbc2 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -36,7 +36,7 @@ const ( // It is the version that the client sends in the initialization request, and // the default version used by the server. latestProtocolVersion = protocolVersion20251125 - protocolVersion20260630 = "2026-06-30" + protocolVersion20260728 = "2026-07-28" protocolVersion20251125 = "2025-11-25" protocolVersion20250618 = "2025-06-18" protocolVersion20250326 = "2025-03-26" @@ -504,7 +504,7 @@ type validatedMeta struct { } // validateRequestMeta inspects a JSON-RPC request to detect whether it follows -// the >= 2026-06-30 protocol via the `_meta` field. +// the >= 2026-07-28 protocol via the `_meta` field. // If the request has no _meta, or no protocolVersion in _meta, it returns a non-nil // validatedMeta with usesNewProtocol set to false, and a nil error. // If the request has a protocolVersion in _meta: @@ -518,7 +518,7 @@ func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) { return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil } protocolVersion, ok := meta[MetaKeyProtocolVersion].(string) - if !ok || protocolVersion < protocolVersion20260630 { + if !ok || protocolVersion < protocolVersion20260728 { return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil } // Notifications do not carry full client identity. @@ -585,7 +585,7 @@ type RequestExtra struct { // to configure the reconnection delay. // // [SEP-1699]: https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699 - // This mechanism is deprecated in protocol version 2026-06-30 as the resumability + // This mechanism is deprecated in protocol version 2026-07-28 as the resumability // feature is removed. CloseSSEStream func(CloseSSEStreamArgs) } @@ -611,7 +611,7 @@ func (r *ServerRequest[P]) GetExtra() *RequestExtra { return r.Extra } // ProtocolVersion returns the protocol version negotiated for this request. // -// For requests following the >= 2026-06-30 protocol, the value is read from +// For requests following the >= 2026-07-28 protocol, the value is read from // the per-request `_meta` field. For older protocol requests, the value falls // back to the session-level [InitializeParams] established during the // initialize handshake. @@ -631,7 +631,7 @@ func (r *ServerRequest[P]) ProtocolVersion() string { // ClientInfo returns the [Implementation] identifying the calling client. // -// For requests following the >= 2026-06-30 protocol, the value is read from +// For requests following the >= 2026-07-28 protocol, the value is read from // the per-request `_meta` field. For older protocol requests, the value falls // back to the session-level [InitializeParams]. func (r *ServerRequest[P]) ClientInfo() *Implementation { @@ -650,7 +650,7 @@ func (r *ServerRequest[P]) ClientInfo() *Implementation { // ClientCapabilities returns the [ClientCapabilities] of the calling client. // -// For requests following the >= 2026-06-30 protocol, the value is read from +// For requests following the >= 2026-07-28 protocol, the value is read from // the per-request `_meta` field. For older protocol requests, the value falls // back to the session-level [InitializeParams]. func (r *ServerRequest[P]) ClientCapabilities() *ClientCapabilities { diff --git a/mcp/shared_test.go b/mcp/shared_test.go index 62f6583..08c202a 100644 --- a/mcp/shared_test.go +++ b/mcp/shared_test.go @@ -50,7 +50,7 @@ func TestValidateRequestMeta(t *testing.T) { method: methodCallTool, params: map[string]any{ "_meta": map[string]any{ - MetaKeyProtocolVersion: protocolVersion20260630, + MetaKeyProtocolVersion: protocolVersion20260728, MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, MetaKeyClientCapabilities: map[string]any{}, }, @@ -63,7 +63,7 @@ func TestValidateRequestMeta(t *testing.T) { method: methodCallTool, params: map[string]any{ "_meta": map[string]any{ - MetaKeyProtocolVersion: protocolVersion20260630, + MetaKeyProtocolVersion: protocolVersion20260728, MetaKeyClientCapabilities: map[string]any{}, }, "name": "x", @@ -76,7 +76,7 @@ func TestValidateRequestMeta(t *testing.T) { method: methodCallTool, params: map[string]any{ "_meta": map[string]any{ - MetaKeyProtocolVersion: protocolVersion20260630, + MetaKeyProtocolVersion: protocolVersion20260728, MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, }, "name": "x", @@ -90,7 +90,7 @@ func TestValidateRequestMeta(t *testing.T) { isNotification: true, params: map[string]any{ "_meta": map[string]any{ - MetaKeyProtocolVersion: protocolVersion20260630, + MetaKeyProtocolVersion: protocolVersion20260728, }, "requestId": "r1", }, @@ -107,7 +107,7 @@ func TestValidateRequestMeta(t *testing.T) { method: methodCallTool, params: map[string]any{ "_meta": map[string]any{ - MetaKeyProtocolVersion: protocolVersion20260630, + MetaKeyProtocolVersion: protocolVersion20260728, MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, MetaKeyClientCapabilities: map[string]any{}, MetaKeyLogLevel: "warning", @@ -122,7 +122,7 @@ func TestValidateRequestMeta(t *testing.T) { method: methodCallTool, params: map[string]any{ "_meta": map[string]any{ - MetaKeyProtocolVersion: protocolVersion20260630, + MetaKeyProtocolVersion: protocolVersion20260728, MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, MetaKeyClientCapabilities: map[string]any{}, }, @@ -190,15 +190,15 @@ func TestServerRequest_PerRequestAccessors(t *testing.T) { info := &Implementation{Name: "c", Version: "1"} params := &CallToolParamsRaw{ Meta: Meta{ - MetaKeyProtocolVersion: protocolVersion20260630, + MetaKeyProtocolVersion: protocolVersion20260728, 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.ProtocolVersion(); got != protocolVersion20260728 { + t.Errorf("ProtocolVersion = %q, want %q", got, protocolVersion20260728) } if got := req.ClientInfo(); got == nil || got.Name != "c" { t.Errorf("ClientInfo = %+v, want Name=c", got) @@ -213,7 +213,7 @@ func TestServerRequest_PerRequestAccessors_FromJSON(t *testing.T) { // re-decode them into typed Go values. raw := json.RawMessage(`{ "_meta": { - "io.modelcontextprotocol/protocolVersion": "2026-06-30", + "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": {"name": "wire-client", "version": "9"}, "io.modelcontextprotocol/clientCapabilities": {"sampling": {}} }, @@ -224,7 +224,7 @@ func TestServerRequest_PerRequestAccessors_FromJSON(t *testing.T) { t.Fatal(err) } req := &ServerRequest[*CallToolParamsRaw]{Params: ¶ms} - if got, want := req.ProtocolVersion(), protocolVersion20260630; got != want { + if got, want := req.ProtocolVersion(), protocolVersion20260728; got != want { t.Errorf("ProtocolVersion = %q, want %q", got, want) } gotInfo := req.ClientInfo() diff --git a/mcp/streamable.go b/mcp/streamable.go index 3d2de97..2d020c6 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -314,7 +314,7 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque // // [§2.7]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#protocol-version-header protocolVersion := req.Header.Get(protocolVersionHeader) - if protocolVersion != "" && !slices.Contains(supportedProtocolVersions, protocolVersion) && protocolVersion < protocolVersion20260630 { + if protocolVersion != "" && !slices.Contains(supportedProtocolVersions, protocolVersion) && protocolVersion < protocolVersion20260728 { http.Error(w, fmt.Sprintf("Bad Request: Unsupported protocol version (supported versions: %s)", strings.Join(supportedProtocolVersions, ",")), http.StatusBadRequest) return } @@ -414,7 +414,7 @@ func (h *StreamableHTTPHandler) serveStatelessLegacyDELETE(w http.ResponseWriter // ephemeralConnectOpts peeks at the request body to determine whether it // contains an initialize or initialized message or whether the protocol version -// header indicates a protocol version >= 2026-06-30 (SEP-2575). +// header indicates a protocol version >= 2026-07-28 (SEP-2575). // // For old-protocol requests, default session state is synthesized so that // the session's init gate doesn't reject the request. @@ -422,7 +422,7 @@ func (h *StreamableHTTPHandler) serveStatelessLegacyDELETE(w http.ResponseWriter // It is used for both stateless servers and stateful servers with no session ID. // // The returned usesNewProtocol bool reports whether the protocol version -// header indicates a protocol version >= 2026-06-30 (SEP-2575). +// header indicates a protocol version >= 2026-07-28 (SEP-2575). func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (opts *ServerSessionOptions, usesNewProtocol bool, err error) { protocolVersion := protocolVersionFromContext(req.Context()) if protocolVersion == "" { @@ -446,7 +446,7 @@ func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (opts *S case notificationInitialized: hasInitialized = true } - if protocolVersion >= protocolVersion20260630 { + if protocolVersion >= protocolVersion20260728 { usesNewProtocol = true } } @@ -808,10 +808,10 @@ func (t *StreamableServerTransport) Connect(ctx context.Context) (Connection, er } // The streamable HTTP transport supports every legacy SDK protocol version, -// but the SEP-2575 >= 2026-06-30 protocol is only supported when the +// but the SEP-2575 >= 2026-07-28 protocol is only supported when the // transport is configured as stateless. func (t *StreamableServerTransport) SupportsProtocolVersion(version string) bool { - if version >= protocolVersion20260630 { + if version >= protocolVersion20260728 { return t.Stateless && slices.Contains(supportedProtocolVersions, version) } return slices.Contains(supportedProtocolVersions, version) @@ -951,14 +951,14 @@ func (s *stream) release() { // extractErrorStatus reports the HTTP status to send when the given // outgoing message is a JSON-RPC error response under the SEP-2575 protocol -// (>= 2026-06-30). +// (>= 2026-07-28). // // Per SEP-2575: // - MethodNotFound (-32601) MUST return HTTP 404. // - InvalidParams (-32602) and UnsupportedProtocolVersion (-32004) MUST // return HTTP 400. func extractErrorStatus(ctx context.Context, msg jsonrpc.Message) int { - if protocolVersionFromContext(ctx) < protocolVersion20260630 { + if protocolVersionFromContext(ctx) < protocolVersion20260728 { return 0 } resp, ok := msg.(*jsonrpc.Response) @@ -986,7 +986,7 @@ func extractErrorStatus(ctx context.Context, msg jsonrpc.Message) int { // requests have been responded to, the done channel is closed and set to nil. // // If overrideStatus is non-zero, data is treated as a SEP-2575 protocol-level -// error response (>= 2026-06-30): it is written as a single raw JSON-RPC +// error response (>= 2026-07-28): it is written as a single raw JSON-RPC // response body with Content-Type: application/json and HTTP status // overrideStatus. // @@ -1065,7 +1065,7 @@ func (s *stream) doneLocked() bool { } func (c *streamableServerConn) newStream(ctx context.Context, requests map[jsonrpc.ID]struct{}, id string) (*stream, error) { - if c.eventStore != nil && protocolVersionFromContext(ctx) < protocolVersion20260630 { + if c.eventStore != nil && protocolVersionFromContext(ctx) < protocolVersion20260728 { if err := c.eventStore.Open(ctx, c.sessionID, id); err != nil { return nil, err } @@ -1378,7 +1378,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // the HTTP request. If we didn't do this, a request with a bad method or // missing ID could be silently swallowed. if _, err := checkRequest(jreq, serverMethodInfos); err != nil { - if protocolVersion >= protocolVersion20260630 && errors.Is(err, jsonrpc2.ErrNotHandled) && jreq.IsCall() { + if protocolVersion >= protocolVersion20260728 && errors.Is(err, jsonrpc2.ErrNotHandled) && jreq.IsCall() { writeJSONRPCError(w, http.StatusNotFound, jreq.ID, &jsonrpc.Error{ Code: jsonrpc.CodeMethodNotFound, Message: err.Error(), @@ -1399,7 +1399,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // SEP-2575: requests carrying `_meta.protocolVersion` require the // Mcp-Protocol-Version HTTP header to be present and to match the // per-request `_meta.protocolVersion` value. - // The new (>= 2026-06-30) 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. // // TODO: this validation can be moved within validateMcpHeaders. @@ -1407,7 +1407,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques if meta := extractRequestMeta(jreq.Params); meta != nil { metaVersion, _ = meta[MetaKeyProtocolVersion].(string) } - if protocolVersion >= protocolVersion20260630 || metaVersion != "" { + if protocolVersion >= protocolVersion20260728 || metaVersion != "" { // Extract again the protcol version from the context to see what the client // is advertising in the Mcp-Protocol-Version HTTP header. headerVersion := protocolVersionFromContext(req.Context()) @@ -1462,8 +1462,8 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques jreq.Extra.(*RequestExtra).CloseSSEStream = func(args CloseSSEStreamArgs) { // This mechanism was designed to trigger client reconnection with // Last-Event-ID for server-initiated disconnect scenarios. It is - // deprecated in protocol version 2026-06-30. - if protocolVersion >= protocolVersion20260630 { + // deprecated in protocol version 2026-07-28. + if protocolVersion >= protocolVersion20260728 { return } c.mu.Lock() @@ -1592,8 +1592,8 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // SSE mode: write a priming event if supported. // // SEP-2575 removes Last-Event-ID-based resumable streams for protocol - // version >= 2026-06-30. - if c.eventStore != nil && effectiveVersion >= protocolVersion20251125 && effectiveVersion < protocolVersion20260630 { + // version >= 2026-07-28. + if c.eventStore != nil && effectiveVersion >= protocolVersion20251125 && effectiveVersion < protocolVersion20260728 { // Write a priming event, as defined by [§2.1.6] of the spec. // // [§2.1.6]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server @@ -1754,7 +1754,7 @@ func (c *streamableServerConn) Write(ctx context.Context, msg jsonrpc.Message) e delivered := false var errs []error protocolVersion := protocolVersionFromContext(ctx) - if c.eventStore != nil && protocolVersion < protocolVersion20260630 { + if c.eventStore != nil && protocolVersion < protocolVersion20260728 { if err := c.eventStore.Append(ctx, c.sessionID, s.id, data); err != nil { errs = append(errs, err) } else { @@ -1765,12 +1765,12 @@ func (c *streamableServerConn) Write(ctx context.Context, msg jsonrpc.Message) e // Compute eventID for SSE streams with event store. // Use s.lastIdx + 1 because deliverLocked increments before writing. var eventID string - if c.eventStore != nil && protocolVersion < protocolVersion20260630 { + if c.eventStore != nil && protocolVersion < protocolVersion20260728 { eventID = formatEventID(s.id, s.lastIdx+1) } // SEP-2575: map protocol-level JSON-RPC error codes to HTTP status codes - // on the new protocol (>= 2026-06-30). When non-zero, deliverLocked will + // on the new protocol (>= 2026-07-28). When non-zero, deliverLocked will // write the body as raw application/json with the override status. overrideStatus := extractErrorStatus(ctx, msg) @@ -1974,10 +1974,10 @@ func (c *streamableClientConn) sessionUpdated(state clientSessionState) { c.initializedResult = state.InitializeResult c.mu.Unlock() - // Under SEP-2575 (protocol version >= 2026-06-30) the standalone HTTP GET + // Under SEP-2575 (protocol version >= 2026-07-28) the standalone HTTP GET // SSE stream is removed. if state.InitializeResult == nil || - state.InitializeResult.ProtocolVersion >= protocolVersion20260630 { + state.InitializeResult.ProtocolVersion >= protocolVersion20260728 { return } diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 2e372af..24179d1 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -1234,7 +1234,7 @@ func TestStreamableClientOAuth_RetrieveError(t *testing.T) { // discoverResult is the canned successful DiscoverResult returned by // fakeStreamableServer setups in the tests below. var discoverResult = &DiscoverResult{ - SupportedVersions: []string{protocolVersion20260630}, + SupportedVersions: []string{protocolVersion20260728}, Capabilities: &ServerCapabilities{ Tools: &ToolCapabilities{ListChanged: true}, }, @@ -1244,7 +1244,7 @@ var discoverResult = &DiscoverResult{ // TestStreamableClientConnect_DiscoverSuccess verifies that Client.Connect on // the streamable transport: -// - sends a POST server/discover with Mcp-Protocol-Version: 2026-06-30 and +// - sends a POST server/discover with Mcp-Protocol-Version: 2026-07-28 and // the SEP-2575 per-request _meta triple in the body, // - on a successful DiscoverResult, skips the legacy initialize handshake // entirely, and @@ -1253,9 +1253,9 @@ var discoverResult = &DiscoverResult{ func TestStreamableClientConnect_DiscoverSuccess(t *testing.T) { ctx := context.Background() - // Temporarily enable 2026-06-30 support in the SDK for this test + // Temporarily enable 2026-07-28 support in the SDK for this test oldSupported := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260630}, supportedProtocolVersions...) + supportedProtocolVersions = append([]string{protocolVersion20260728}, supportedProtocolVersions...) t.Cleanup(func() { supportedProtocolVersions = oldSupported }) @@ -1273,7 +1273,7 @@ func TestStreamableClientConnect_DiscoverSuccess(t *testing.T) { "Content-Type": "application/json", sessionIDHeader: "sess-1", }, - wantProtocolVersion: protocolVersion20260630, + wantProtocolVersion: protocolVersion20260728, responseFunc: func(r *jsonrpc.Request) (string, int) { gotDiscoverMu.Lock() gotDiscover = r @@ -1290,7 +1290,7 @@ func TestStreamableClientConnect_DiscoverSuccess(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -1313,8 +1313,8 @@ func TestStreamableClientConnect_DiscoverSuccess(t *testing.T) { if err := json.Unmarshal(gotDiscover.Params, &body); err != nil { t.Fatalf("decoding discover params: %v", err) } - if v, _ := body.Meta[MetaKeyProtocolVersion].(string); v != protocolVersion20260630 { - t.Errorf("_meta[%s] = %q, want %q", MetaKeyProtocolVersion, v, protocolVersion20260630) + if v, _ := body.Meta[MetaKeyProtocolVersion].(string); v != protocolVersion20260728 { + t.Errorf("_meta[%s] = %q, want %q", MetaKeyProtocolVersion, v, protocolVersion20260728) } if _, ok := body.Meta[MetaKeyClientInfo]; !ok { t.Errorf("_meta[%s] missing", MetaKeyClientInfo) @@ -1327,7 +1327,7 @@ func TestStreamableClientConnect_DiscoverSuccess(t *testing.T) { if ir == nil { t.Fatal("InitializeResult is nil after Connect") } - if got, want := ir.ProtocolVersion, protocolVersion20260630; got != want { + if got, want := ir.ProtocolVersion, protocolVersion20260728; got != want { t.Errorf("InitializeResult.ProtocolVersion = %q, want %q", got, want) } if ir.ServerInfo == nil || ir.ServerInfo.Name != "discoverServer" { @@ -1363,7 +1363,7 @@ func TestStreamableClientConnect_DiscoverMethodNotFound(t *testing.T) { responses: fakeResponses{ {"POST", "", methodDiscover, ""}: { header: header{"Content-Type": "application/json"}, - wantProtocolVersion: protocolVersion20260630, + wantProtocolVersion: protocolVersion20260728, responseFunc: echoErr(&jsonrpc.Error{ Code: jsonrpc.CodeMethodNotFound, Message: "method not found", @@ -1428,7 +1428,7 @@ func TestStreamableClientConnect_DiscoverUnsupportedVersion(t *testing.T) { responses: fakeResponses{ {"POST", "", methodDiscover, ""}: { header: header{"Content-Type": "application/json"}, - wantProtocolVersion: protocolVersion20260630, + wantProtocolVersion: protocolVersion20260728, responseFunc: echoErr(&jsonrpc.Error{ Code: CodeUnsupportedProtocolVersion, Message: "unsupported protocol version", @@ -1486,7 +1486,7 @@ func TestStreamableClientConnect_DiscoverMethodNotFoundVPre(t *testing.T) { t: t, responses: fakeResponses{ {"POST", "", methodDiscover, ""}: { - wantProtocolVersion: protocolVersion20260630, + wantProtocolVersion: protocolVersion20260728, // Reproduce the exact body a vPre server produces via // http.Error(w, err.Error(), 400) where err comes from // checkRequest. http.Error appends a trailing newline. @@ -1517,7 +1517,7 @@ func TestStreamableClientConnect_DiscoverMethodNotFoundVPre(t *testing.T) { DisableStandaloneSSE: true, } client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -1544,7 +1544,7 @@ func TestStreamableClientConnect_DiscoverUnsupportedVersionVPre(t *testing.T) { t: t, responses: fakeResponses{ {"POST", "", methodDiscover, ""}: { - wantProtocolVersion: protocolVersion20260630, + wantProtocolVersion: protocolVersion20260728, body: "Bad Request: Unsupported protocol version\n", status: http.StatusBadRequest, header: header{"Content-Type": "text/plain; charset=utf-8"}, @@ -1572,7 +1572,7 @@ func TestStreamableClientConnect_DiscoverUnsupportedVersionVPre(t *testing.T) { DisableStandaloneSSE: true, } client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -1591,7 +1591,7 @@ func TestStreamableClientConnect_DiscoverUnsupportedVersionNegotiation(t *testin ctx := context.Background() oldSupported := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260630}, supportedProtocolVersions...) + supportedProtocolVersions = append([]string{protocolVersion20260728}, supportedProtocolVersions...) t.Cleanup(func() { supportedProtocolVersions = oldSupported }) @@ -1611,7 +1611,7 @@ func TestStreamableClientConnect_DiscoverUnsupportedVersionNegotiation(t *testin n := discoverCalls.Add(1) if n == 1 { data, _ := json.Marshal(UnsupportedProtocolVersionData{ - Supported: []string{protocolVersion20260630}, + Supported: []string{protocolVersion20260728}, }) respMsg := &jsonrpc.Response{ ID: r.ID, @@ -1643,7 +1643,7 @@ func TestStreamableClientConnect_DiscoverUnsupportedVersionNegotiation(t *testin if got, want := discoverCalls.Load(), int32(2); got != want { t.Errorf("discover call count = %d, want %d", got, want) } - if got := session.InitializeResult().ProtocolVersion; got != protocolVersion20260630 { - t.Errorf("InitializeResult.ProtocolVersion = %q, want %q", got, protocolVersion20260630) + if got := session.InitializeResult().ProtocolVersion; got != protocolVersion20260728 { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q", got, protocolVersion20260728) } } diff --git a/mcp/streamable_headers.go b/mcp/streamable_headers.go index af9ff39..3989c01 100644 --- a/mcp/streamable_headers.go +++ b/mcp/streamable_headers.go @@ -27,7 +27,7 @@ const ( methodHeader = "Mcp-Method" nameHeader = "Mcp-Name" paramHeaderPrefix = "Mcp-Param-" - minVersionForStandardHeaders = protocolVersion20260630 + minVersionForStandardHeaders = protocolVersion20260728 base64Prefix = "=?base64?" base64Suffix = "?=" ) diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 79934b4..a9855cb 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -3223,7 +3223,7 @@ func TestStandaloneSSEEmitsCommentForHTTP2Flush(t *testing.T) { } // newProtocolBody builds a raw JSON body for a tools/call request that -// carries the >= 2026-06-30 per-request _meta fields. +// carries the >= 2026-07-28 per-request _meta fields. func newProtocolBody(t *testing.T, toolName string, args any) []byte { t.Helper() rawArgs, err := json.Marshal(args) @@ -3236,7 +3236,7 @@ func newProtocolBody(t *testing.T, toolName string, args any) []byte { "method": "tools/call", "params": map[string]any{ "_meta": map[string]any{ - MetaKeyProtocolVersion: protocolVersion20260630, + MetaKeyProtocolVersion: protocolVersion20260728, MetaKeyClientInfo: map[string]any{"name": "new-proto-client", "version": "9.9"}, MetaKeyClientCapabilities: map[string]any{"sampling": map[string]any{}}, }, @@ -3313,7 +3313,7 @@ func TestEphemeralConnectOpts(t *testing.T) { req := mkReq(tt.body) var pver string if tt.wantUsesNew { - pver = protocolVersion20260630 + pver = protocolVersion20260728 } else { pver = protocolVersion20250326 } @@ -3351,10 +3351,10 @@ type statelessHandlerCapture struct { func TestStreamableStateless_NewProtocolSession_NoFakeInit(t *testing.T) { // SEP-2575: the MCP-Protocol-Version header is mandatory for new-protocol - // requests and must be a supported version. The 2026-06-30 version is + // requests and must be a supported version. The 2026-07-28 version is // not yet in the global list, so register it for the duration of the test. orig := supportedProtocolVersions - supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630) + supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260728) t.Cleanup(func() { supportedProtocolVersions = orig }) capture := &statelessHandlerCapture{} @@ -3384,8 +3384,8 @@ func TestStreamableStateless_NewProtocolSession_NoFakeInit(t *testing.T) { } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Accept", "application/json, text/event-stream") - httpReq.Header.Set(protocolVersionHeader, protocolVersion20260630) - // >= 2026-06-30 also requires the Mcp-Method and Mcp-Name standard + httpReq.Header.Set(protocolVersionHeader, protocolVersion20260728) + // >= 2026-07-28 also requires the Mcp-Method and Mcp-Name standard // headers (see streamable_headers.go). httpReq.Header.Set(methodHeader, "tools/call") httpReq.Header.Set(nameHeader, "capture") @@ -3405,14 +3405,14 @@ func TestStreamableStateless_NewProtocolSession_NoFakeInit(t *testing.T) { if capture.sessionInitParams == nil { t.Errorf("Session.InitializeParams() is nil, want populated initializeParams for new-protocol session") } else { - if got, want := capture.sessionInitParams.ProtocolVersion, protocolVersion20260630; got != want { + if got, want := capture.sessionInitParams.ProtocolVersion, protocolVersion20260728; got != want { t.Errorf("Session.InitializeParams().ProtocolVersion = %q, want %q", got, want) } if got, want := capture.sessionInitParams.ClientInfo.Name, "new-proto-client"; got != want { t.Errorf("Session.InitializeParams().ClientInfo.Name = %q, want %q", got, want) } } - if got, want := capture.reqProtocolVersion, protocolVersion20260630; got != want { + if got, want := capture.reqProtocolVersion, protocolVersion20260728; got != want { t.Errorf("req.ProtocolVersion() = %q, want %q", got, want) } if capture.reqClientInfo == nil || capture.reqClientInfo.Name != "new-proto-client" { @@ -3424,14 +3424,14 @@ func TestStreamableStateless_NewProtocolSession_NoFakeInit(t *testing.T) { } // TestStreamableStateful_RejectsNewProtocol verifies that a stateful HTTP -// server rejects requests carrying _meta.protocolVersion (i.e. >= 2026-06-30 +// server rejects requests carrying _meta.protocolVersion (i.e. >= 2026-07-28 // requests) with HTTP 400. The new protocol is // supported on HTTP only when StreamableHTTPOptions.Stateless=true. func TestStreamableStateful_RejectsNewProtocol(t *testing.T) { - // Make 2026-06-30 a "known" version so that the request reaches servePOST + // Make 2026-07-28 a "known" version so that the request reaches servePOST // (otherwise the early header validation at ServeHTTP rejects it). orig := supportedProtocolVersions - supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630) + supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260728) t.Cleanup(func() { supportedProtocolVersions = orig }) server := NewServer(testImpl, nil) @@ -3472,7 +3472,7 @@ func TestStreamableStateful_RejectsNewProtocol(t *testing.T) { req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") req.Header.Set(sessionIDHeader, sessionID) - req.Header.Set(protocolVersionHeader, protocolVersion20260630) + req.Header.Set(protocolVersionHeader, protocolVersion20260728) req.Header.Set(methodHeader, "tools/call") req.Header.Set(nameHeader, "noop") @@ -3496,7 +3496,7 @@ func TestStreamableStateful_RejectsNewProtocol(t *testing.T) { // fire on Stateless: true). func TestStreamableStateless_AcceptsNewProtocol(t *testing.T) { orig := supportedProtocolVersions - supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630) + supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260728) t.Cleanup(func() { supportedProtocolVersions = orig }) server := NewServer(testImpl, nil) @@ -3518,7 +3518,7 @@ func TestStreamableStateless_AcceptsNewProtocol(t *testing.T) { } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") - req.Header.Set(protocolVersionHeader, protocolVersion20260630) + req.Header.Set(protocolVersionHeader, protocolVersion20260728) req.Header.Set(methodHeader, "tools/call") req.Header.Set(nameHeader, "noop") @@ -3534,7 +3534,7 @@ func TestStreamableStateless_AcceptsNewProtocol(t *testing.T) { } // TestStreamableClientUnsupportedVersionFallback exercises the full -// SEP-2575 fallback. The client requests protocolVersion20260630, which the server does +// SEP-2575 fallback. The client requests protocolVersion20260728, which the server does // not advertise in supportedProtocolVersions. The server therefore rejects // the server/discover POST at the transport-level header validation with a // plain HTTP 400 ("Bad Request: Unsupported protocol version ..."). The @@ -3556,7 +3556,7 @@ func TestStreamableClientUnsupportedVersionFallback(t *testing.T) { client := NewClient(testImpl, nil) transport := &StreamableClientTransport{Endpoint: httpServer.URL} - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -3579,14 +3579,14 @@ func TestStreamableClientUnsupportedVersionFallback(t *testing.T) { } // TestStreamableStateful_AcceptsDiscover verifies that a stateful HTTP server -// accepts a server/discover probe carrying MCP-Protocol-Version: 2026-06-30 +// accepts a server/discover probe carrying MCP-Protocol-Version: 2026-07-28 // (and the matching _meta.protocolVersion), instead of rejecting it with the // "stateless required" 400. The SEP-2575 client flow has the client probing // the server with the new protocol version to learn which versions are // supported. func TestStreamableStateful_AcceptsDiscover(t *testing.T) { orig := supportedProtocolVersions - supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630) + supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260728) t.Cleanup(func() { supportedProtocolVersions = orig }) server := NewServer(testImpl, nil) @@ -3600,7 +3600,7 @@ func TestStreamableStateful_AcceptsDiscover(t *testing.T) { "method": methodDiscover, "params": map[string]any{ "_meta": map[string]any{ - MetaKeyProtocolVersion: protocolVersion20260630, + MetaKeyProtocolVersion: protocolVersion20260728, MetaKeyClientInfo: map[string]any{"name": "new-proto-client", "version": "9.9"}, MetaKeyClientCapabilities: map[string]any{"sampling": map[string]any{}}, }, @@ -3615,7 +3615,7 @@ func TestStreamableStateful_AcceptsDiscover(t *testing.T) { } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") - req.Header.Set(protocolVersionHeader, protocolVersion20260630) + req.Header.Set(protocolVersionHeader, protocolVersion20260728) req.Header.Set(methodHeader, methodDiscover) resp, err := http.DefaultClient.Do(req) @@ -3651,9 +3651,9 @@ func TestStreamableStateful_AcceptsDiscover(t *testing.T) { if rpcResp.Result == nil { t.Fatalf("discover returned no result; body = %s", respBody) } - if slices.Contains(rpcResp.Result.SupportedVersions, protocolVersion20260630) { + if slices.Contains(rpcResp.Result.SupportedVersions, protocolVersion20260728) { t.Errorf("DiscoverResult.SupportedVersions = %v, must not include %q on a stateful transport", - rpcResp.Result.SupportedVersions, protocolVersion20260630) + rpcResp.Result.SupportedVersions, protocolVersion20260728) } if len(rpcResp.Result.SupportedVersions) == 0 { t.Errorf("DiscoverResult.SupportedVersions is empty; want at least one legacy version") @@ -3665,7 +3665,7 @@ func TestStreamableStateful_AcceptsDiscover(t *testing.T) { func TestStreamableHTTP_E2E_DiscoverSuccess(t *testing.T) { ctx := context.Background() orig := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...) + supportedProtocolVersions = append([]string{protocolVersion20260728}, slices.Clone(orig)...) t.Cleanup(func() { supportedProtocolVersions = orig }) server := NewServer(&Implementation{Name: "e2e-server", Version: "v1"}, nil) @@ -3689,7 +3689,7 @@ func TestStreamableHTTP_E2E_DiscoverSuccess(t *testing.T) { client := NewClient(&Implementation{Name: "e2e-client", Version: "v1"}, nil) transport := &StreamableClientTransport{Endpoint: httpServer.URL} - cs, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260630}) + cs, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -3699,9 +3699,9 @@ func TestStreamableHTTP_E2E_DiscoverSuccess(t *testing.T) { if ir == nil { t.Fatal("InitializeResult is nil after Connect; discover should have populated it") } - if ir.ProtocolVersion != protocolVersion20260630 { + if ir.ProtocolVersion != protocolVersion20260728 { t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (negotiated via discover)", - ir.ProtocolVersion, protocolVersion20260630) + ir.ProtocolVersion, protocolVersion20260728) } if ir.ServerInfo == nil || ir.ServerInfo.Name != "e2e-server" { t.Errorf("InitializeResult.ServerInfo = %+v, want name=e2e-server", ir.ServerInfo) From 3486f6bcaf3c514d7de132e6ad2293b31dcb10b9 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Mon, 22 Jun 2026 10:27:31 +0200 Subject: [PATCH 310/418] mco: update JSON-RPC error codes to align with updated MCP specification (#1016) This PR updates the error codes to align with the updated specifications in https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2907 ErrServerClosing and ErrClientClosing are reverted to their 1.6.0 version codes, as they do not overlap anymore with the newly introduced error codes. --- internal/jsonrpc2/wire.go | 6 ++---- mcp/client.go | 7 +++++-- mcp/shared.go | 14 +++++++------- mcp/streamable.go | 3 ++- mcp/streamable_test.go | 8 ++++---- 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/internal/jsonrpc2/wire.go b/internal/jsonrpc2/wire.go index 1215f14..4d123f2 100644 --- a/internal/jsonrpc2/wire.go +++ b/internal/jsonrpc2/wire.go @@ -31,9 +31,9 @@ var ( // ErrUnknown should be used for all non coded errors. ErrUnknown = NewError(-32001, "unknown error") // ErrServerClosing is returned for calls that arrive while the server is closing. - ErrServerClosing = NewError(-32006, "server is closing") + ErrServerClosing = NewError(-32004, "server is closing") // ErrClientClosing is a dummy error returned for calls initiated while the client is closing. - ErrClientClosing = NewError(-32007, "client is closing") + ErrClientClosing = NewError(-32003, "client is closing") // The following errors have special semantics for MCP transports @@ -45,8 +45,6 @@ var ( // should be returned to the caller to indicate that the specific request is // invalid in the current context. ErrRejected = NewError(-32005, "rejected by transport") - // ErrUnsupportedProtocolVersion is returned when a server does not support the protocol version. - ErrUnsupportedProtocolVersion = NewError(-32004, "unsupported protocol version") ) const wireVersion = "2.0" diff --git a/mcp/client.go b/mcp/client.go index 785b186..e64b8ff 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -306,7 +306,7 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp // Try to negotiate a mutually supported version if the server // reports an UnsupportedProtocolVersionError with a supported version. var werr *jsonrpc.Error - if errors.As(err, &werr) && werr.Code == CodeUnsupportedProtocolVersion && werr.Data != nil { + if errors.As(err, &werr) && werr.Code == CodeUnsupportedProtocolVersion && len(werr.Data) > 0 { var data UnsupportedProtocolVersionData if err := json.Unmarshal(werr.Data, &data); err == nil { if negotiatedVersion := negotiateMutuallySupportedVersion(data.Supported); negotiatedVersion != "" && negotiatedVersion >= protocolVersion20260728 { @@ -384,7 +384,10 @@ func (c *Client) discover(ctx context.Context, cs *ClientSession) (*InitializeRe if negotiated == "" || negotiated < protocolVersion20260728 { // If there is no overlap, fall back to initialize so version // negotiation can happen via the legacy path. - return nil, jsonrpc2.ErrUnsupportedProtocolVersion + return nil, &jsonrpc2.WireError{ + Code: CodeUnsupportedProtocolVersion, + Message: "unsupported protocol version", + } } return &InitializeResult{ diff --git a/mcp/shared.go b/mcp/shared.go index 128cbc2..a15ec20 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -355,15 +355,15 @@ func clientSessionMethod[P Params, R Result](f func(*ClientSession, context.Cont // MCP-specific error codes. const ( - // CodeMissingRequiredClientCapabilities is the JSON-RPC error code defined by - // SEP-2575 for MissingRequiredClientCapabilitiesError. - CodeMissingRequiredClientCapabilities = -32003 - // CodeUnsupportedProtocolVersion is the JSON-RPC error code defined by - // SEP-2575 for UnsupportedProtocolVersionError. - CodeUnsupportedProtocolVersion = -32004 // CodeHeaderMismatch indicates that HTTP headers do not match the corresponding values // in the request body, or that required headers are missing or malformed. - CodeHeaderMismatch = -32001 + CodeHeaderMismatch = -32020 + // CodeMissingRequiredClientCapabilities is the JSON-RPC error code defined by + // SEP-2575 for MissingRequiredClientCapabilitiesError. + CodeMissingRequiredClientCapabilities = -32021 + // CodeUnsupportedProtocolVersion is the JSON-RPC error code defined by + // SEP-2575 for UnsupportedProtocolVersionError. + CodeUnsupportedProtocolVersion = -32022 // CodeURLElicitationRequired indicates that the server requires URL elicitation // before processing the request. The client should execute the elicitation handler // with the elicitations provided in the error data. diff --git a/mcp/streamable.go b/mcp/streamable.go index 2d020c6..f01266a 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -955,7 +955,8 @@ func (s *stream) release() { // // Per SEP-2575: // - MethodNotFound (-32601) MUST return HTTP 404. -// - InvalidParams (-32602) and UnsupportedProtocolVersion (-32004) MUST +// - InvalidParams (-32602), UnsupportedProtocolVersion (-32022) and +// CodeMissingRequiredClientCapabilities (-32021) MUST // return HTTP 400. func extractErrorStatus(ctx context.Context, msg jsonrpc.Message) int { if protocolVersionFromContext(ctx) < protocolVersion20260728 { diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index a9855cb..539789b 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -2050,7 +2050,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { } // TestStreamableMcpHeaderValidationErrorFormat verifies that header -// validation errors return a JSON-RPC error with code -32001 and +// validation errors return a JSON-RPC error with code -32020 and // Content-Type application/json, per SEP-2243. func TestStreamableMcpHeaderValidationErrorFormat(t *testing.T) { orig := supportedProtocolVersions @@ -2125,7 +2125,7 @@ func TestStreamableMcpHeaderValidationErrorFormat(t *testing.T) { t.Errorf("Content-Type = %q, want %q", baseMediaType(toolCallResp.Header.Get("Content-Type")), "application/json") } - // Verify JSON-RPC error body contains error code -32001. + // Verify JSON-RPC error body contains error code -32020. msg, err := jsonrpc2.DecodeMessage(toolCallRespBody) if err != nil { t.Fatalf("failed to decode message: %v", err) @@ -2138,8 +2138,8 @@ func TestStreamableMcpHeaderValidationErrorFormat(t *testing.T) { if !errors.As(resp.Error, &wireErr) { t.Fatalf("expected *jsonrpc2.WireError, got %T", resp.Error) } - if wireErr.Code != -32001 { - t.Errorf("wireErr.Code = %d, want -32001", wireErr.Code) + if wireErr.Code != CodeHeaderMismatch { + t.Errorf("wireErr.Code = %d, want %d", wireErr.Code, CodeHeaderMismatch) } if !strings.Contains(wireErr.Message, "Mcp-Method header value") { t.Errorf("wireErr.Message = %q, want it to contain %q", wireErr.Message, "Mcp-Method header value") From cd7d80c591c8867696d4fdcb923a58efd96cc6a1 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Mon, 22 Jun 2026 18:57:36 +0200 Subject: [PATCH 311/418] mcp: Implement `subscriptions/listen` rpc (SEP-2575) (#1007) Fixes #966 --- internal/jsonrpc2/conn.go | 44 ++- mcp/client.go | 122 +++++- mcp/mcp_test.go | 790 ++++++++++++++++++++++++++++++++++++++ mcp/protocol.go | 48 +++ mcp/requests.go | 1 + mcp/server.go | 237 ++++++++++-- mcp/shared.go | 8 +- mcp/streamable.go | 131 +++++-- mcp/streamable_test.go | 14 +- mcp/transport.go | 79 +++- 10 files changed, 1369 insertions(+), 105 deletions(-) diff --git a/internal/jsonrpc2/conn.go b/internal/jsonrpc2/conn.go index 074ceab..4994c63 100644 --- a/internal/jsonrpc2/conn.go +++ b/internal/jsonrpc2/conn.go @@ -208,12 +208,30 @@ type ConnectionConfig struct { Bind func(*Connection) Handler // required OnDone func() // optional OnInternalError func(error) // optional + + // PropagateCancellation controls whether cancellation of the context + // passed to [NewConnection] is observable by request handlers. + // + // By default (false), the connection wraps that context (see [notDone]) + // so handlers' Done channels do not fire when the connection's root + // context is cancelled. Cancellation of an in-flight handler is then + // expected to flow only through the jsonrpc2 layer's explicit channels + // (the [Preempter] reacting to the peer's cancel notification, or a + // transport read/write failure cancelling every in-flight request). + // + // Set this true when cancellation of the connection's context is itself + // a meaningful signal that handlers should react to (for example, when + // the connection is tied to a carrier that owns it and whose end means + // the request is cancelled). + PropagateCancellation bool // optional } // NewConnection creates a new [Connection] object and starts processing // incoming messages. func NewConnection(ctx context.Context, cfg ConnectionConfig) *Connection { - ctx = notDone{ctx} + if !cfg.PropagateCancellation { + ctx = notDone{ctx} + } c := &Connection{ state: inFlightState{closer: cfg.Closer}, @@ -530,6 +548,14 @@ func (c *Connection) readIncoming(ctx context.Context, reader Reader, preempter ac.retire(&Response{ID: id, Error: err}) } s.outgoingCalls = nil + + // Cancel any incoming requests still in flight: with the reader gone we + // cannot receive cancellation notifications, and likely cannot write a + // response either, so parked handlers have nothing useful left to do. + // Mirrors the equivalent cleanup on write failure. + for _, r := range s.incomingByID { + r.cancel() + } }) } @@ -724,9 +750,12 @@ func (c *Connection) write(ctx context.Context, msg Message) error { var err error // Fail writes immediately if the connection is shutting down. // - // TODO(rfindley): should we allow cancellation notifications through? It - // could be the case that writes can still succeed. + // Allow outgoing "notifications" forwarded by the Notify method. + // This will allow to send the cancelled notification when the client is shutting down. c.updateInFlight(func(s *inFlightState) { + if req, ok := msg.(*Request); ok && !req.IsCall() && s.outgoingNotifications > 0 { + return + } err = s.shuttingDown(ErrServerClosing) }) if err == nil { @@ -771,6 +800,15 @@ func (c *Connection) internalErrorf(format string, args ...any) error { } // notDone is a context.Context wrapper that returns a nil Done channel. +// +// Request handlers' contexts are derived from the connection's root context, +// which by default is wrapped in notDone so a transport-level cancellation +// does not implicitly cancel every in-flight handler. Cancellation of an +// in-flight handler is instead expected to flow only through the jsonrpc2 +// layer's explicit channels: the [Preempter] calling [Connection.Cancel] in +// response to the peer's cancel notification, or the transport itself +// failing (the read loop exits on EOF or a write fails) — both of which +// cancel every in-flight incoming request in turn. type notDone struct{ ctx context.Context } func (ic notDone) Value(key any) any { diff --git a/mcp/client.go b/mcp/client.go index e64b8ff..d8fd2c1 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -300,6 +300,27 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp if hc, ok := cs.mcpConn.(clientConnection); ok { hc.sessionUpdated(cs.state) } + subscribeParams := &SubscriptionsListenParams{} + if c.opts.ToolListChangedHandler != nil { + subscribeParams.Notifications.ToolsListChanged = true + } + if c.opts.PromptListChangedHandler != nil { + subscribeParams.Notifications.PromptsListChanged = true + } + if c.opts.ResourceListChangedHandler != nil { + subscribeParams.Notifications.ResourcesListChanged = true + } + if subscribeParams.Notifications.ToolsListChanged || + subscribeParams.Notifications.PromptsListChanged || + subscribeParams.Notifications.ResourcesListChanged { + // ClientSession.Close cancels the listenCtx context to send notifications/cancelled. + listenCtx, cancelListen := context.WithCancel(context.Background()) + cs.listenCancel = cancelListen + if err := cs.subscriptionsListen(listenCtx, subscribeParams); err != nil { + cancelListen() + return nil, fmt.Errorf("opening subscriptions/listen: %w", err) + } + } return cs, nil } @@ -414,6 +435,7 @@ type ClientSession struct { conn *jsonrpc2.Connection client *Client keepaliveCancel context.CancelFunc + listenCancel context.CancelFunc mcpConn Connection // No mutex is (currently) required to guard the session state, because it is @@ -430,6 +452,15 @@ type ClientSession struct { // Pending URL elicitations waiting for completion notifications. pendingElicitationsMu sync.Mutex pendingElicitations map[string]chan struct{} + + // resourceSubsMu guards resourceSubs. + resourceSubsMu sync.Mutex + // resourceSubs maps a subscribed resource URI to the cancel func of the + // goroutine running its dedicated subscriptions/listen stream. Populated + // only under SEP-2575; the legacy protocol routes Subscribe and + // Unsubscribe straight to the resources/subscribe and resources/unsubscribe + // RPCs and leaves this map untouched. + resourceSubs map[string]context.CancelFunc } type clientSessionState struct { @@ -496,6 +527,10 @@ func (cs *ClientSession) Close() error { if cs.keepaliveCancel != nil { cs.keepaliveCancel() } + if cs.listenCancel != nil { + cs.listenCancel() + } + cs.cancelAllResourceSubscriptions() err := cs.conn.Close() if cs.onClose != nil && cs.calledOnClose.CompareAndSwap(false, true) { @@ -1083,6 +1118,7 @@ var clientMethodInfos = map[string]methodInfo{ notificationLoggingMessage: newClientMethodInfo(clientMethod((*Client).callLoggingHandler), notification), notificationProgress: newClientMethodInfo(clientSessionMethod((*ClientSession).callProgressNotificationHandler), notification), notificationElicitationComplete: newClientMethodInfo(clientMethod((*Client).callElicitationCompleteHandler), notification|missingParamsOK), + notificationSubscriptionsAck: newClientMethodInfo(clientMethod((*Client).callSubscriptionsAckHandler), notification|missingParamsOK), } func (cs *ClientSession) sendingMethodInfos() map[string]methodInfo { @@ -1279,15 +1315,89 @@ func (cs *ClientSession) Complete(ctx context.Context, params *CompleteParams) ( // Subscribe sends a "resources/subscribe" request to the server, asking for // notifications when the specified resource changes. func (cs *ClientSession) Subscribe(ctx context.Context, params *SubscribeParams) error { - _, err := handleSend[*emptyResult](ctx, methodSubscribe, newClientRequest(cs, orZero[Params](params))) + if !cs.usesNewProtocol() { + _, err := handleSend[*emptyResult](ctx, methodSubscribe, newClientRequest(cs, orZero[Params](params))) + return err + } + if params == nil || params.URI == "" { + return fmt.Errorf("Subscribe: missing URI") + } + uri := params.URI + + var listenCtx context.Context + cs.resourceSubsMu.Lock() + if _, exists := cs.resourceSubs[uri]; !exists { + var cancel context.CancelFunc + listenCtx, cancel = context.WithCancel(context.Background()) + if cs.resourceSubs == nil { + cs.resourceSubs = make(map[string]context.CancelFunc) + } + cs.resourceSubs[uri] = cancel + } + cs.resourceSubsMu.Unlock() + if listenCtx == nil { + // Already subscribed to this URI + return nil + } + + return cs.subscriptionsListen(listenCtx, &SubscriptionsListenParams{ + Notifications: NotificationSubscriptions{ + ResourceSubscriptions: []string{uri}, + }, + }) +} + +// Unsubscribe cancels a previous [ClientSession.Subscribe] for params.URI. +// +// Under the legacy protocol it sends a "resources/unsubscribe" request. +// +// Under SEP-2575 it cancels the background "subscriptions/listen" stream +// opened by Subscribe for the URI. Unsubscribe is idempotent: calling it for +// a URI that is not currently subscribed is a no-op. +func (cs *ClientSession) Unsubscribe(ctx context.Context, params *UnsubscribeParams) error { + if !cs.usesNewProtocol() { + _, err := handleSend[*emptyResult](ctx, methodUnsubscribe, newClientRequest(cs, orZero[Params](params))) + return err + } + if params == nil || params.URI == "" { + return fmt.Errorf("Unsubscribe: missing URI") + } + cs.resourceSubsMu.Lock() + cancel, ok := cs.resourceSubs[params.URI] + delete(cs.resourceSubs, params.URI) + cs.resourceSubsMu.Unlock() + if ok { + cancel() + } + return nil +} + +// cancelAllResourceSubscriptions cancels every active SEP-2575 resource +// subscription opened via Subscribe. The listen goroutines exit +// asynchronously as their contexts unwind. Called from Close. +func (cs *ClientSession) cancelAllResourceSubscriptions() { + cs.resourceSubsMu.Lock() + subs := cs.resourceSubs + cs.resourceSubs = nil + cs.resourceSubsMu.Unlock() + for _, cancel := range subs { + cancel() + } +} + +// SubscriptionsListen opens a SEP-2575 "subscriptions/listen" stream. +// +// The server's first message on the stream is "notifications/subscriptions/acknowledged"; +// subsequent opted-in notifications (e.g. tools/list_changed) are delivered through the +// usual handlers registered in [ClientOptions]. +func (cs *ClientSession) subscriptionsListen(ctx context.Context, params *SubscriptionsListenParams) error { + params = injectRequestMeta(cs, params) + _, err := handleSend[*emptyResult](ctx, methodSubscriptionsListen, newClientRequest(cs, orZero[Params](params))) return err } -// Unsubscribe sends a "resources/unsubscribe" request to the server, cancelling -// a previous subscription. -func (cs *ClientSession) Unsubscribe(ctx context.Context, params *UnsubscribeParams) error { - _, err := handleSend[*emptyResult](ctx, methodUnsubscribe, newClientRequest(cs, orZero[Params](params))) - return err +func (c *Client) callSubscriptionsAckHandler(context.Context, *ClientRequest[*SubscriptionsAcknowledgedParams]) (Result, error) { + return nil, nil } func (c *Client) callToolChangedHandler(ctx context.Context, req *ToolListChangedRequest) (Result, error) { diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 5c8e7d1..91cc57f 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -12,6 +12,8 @@ import ( "fmt" "io" "log/slog" + "net/http" + "net/http/httptest" "net/url" "path/filepath" "runtime" @@ -2449,3 +2451,791 @@ func TestSetErrorPreservesContent(t *testing.T) { } var ctrCmpOpts = []cmp.Option{cmpopts.IgnoreUnexported(CallToolResult{}, GetPromptResult{}, ReadResourceResult{})} + +// runSubscriptionsListenTest exercises the SEP-2575 auto-listen flow end-to-end +// against the supplied transport pair. It captures every notification and the +// acknowledgment the client sees, then asserts: +// +// - the auto-listen issued by Client.Connect is acknowledged with a tagged +// subscription ID; +// - tool and prompt list-changed notifications are delivered to the matching +// handlers, each carrying the same subscription ID as the ack; +// - the subscription persists across multiple unrelated changes; +// - cs.Close() ends the subscription and further changes don't deliver. +func runSubscriptionsListenTest(t *testing.T, client *Client, server *Server, ct Transport, events chan subListenEvent) { + t.Helper() + + ctx, topCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer topCancel() + + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + + waitFor := func(kind string) subListenEvent { + t.Helper() + select { + case e := <-events: + if e.kind != kind { + t.Fatalf("got event %q (id=%s), want kind %q", e.kind, e.id, kind) + } + return e + case <-time.After(5 * time.Second): + t.Fatalf("timed out waiting for %q event", kind) + return subListenEvent{} + } + } + expectNoEvent := func(d time.Duration) { + t.Helper() + select { + case e := <-events: + t.Fatalf("unexpected event %q (id=%s)", e.kind, e.id) + case <-time.After(d): + } + } + + ack := waitFor("ack") + if ack.id == "" { + t.Fatalf("acknowledgment missing subscription ID") + } + + server.AddTool(&Tool{Name: "t2", InputSchema: &jsonschema.Schema{Type: "object"}}, nil) + if e := waitFor("tool"); e.id != ack.id { + t.Errorf("first tool notif id = %s, want %s", e.id, ack.id) + } + + server.AddPrompt(&Prompt{Name: "p2"}, nil) + if e := waitFor("prompt"); e.id != ack.id { + t.Errorf("first prompt notif id = %s, want %s", e.id, ack.id) + } + + server.AddTool(&Tool{Name: "t3", InputSchema: &jsonschema.Schema{Type: "object"}}, nil) + if e := waitFor("tool"); e.id != ack.id { + t.Errorf("second tool notif id = %s, want %s", e.id, ack.id) + } + server.AddPrompt(&Prompt{Name: "p3"}, nil) + if e := waitFor("prompt"); e.id != ack.id { + t.Errorf("second prompt notif id = %s, want %s", e.id, ack.id) + } + expectNoEvent(notificationDelay * 5) + + cs.Close() + time.Sleep(50 * time.Millisecond) + + server.AddTool(&Tool{Name: "t4", InputSchema: &jsonschema.Schema{Type: "object"}}, nil) + server.AddPrompt(&Prompt{Name: "p4"}, nil) + expectNoEvent(notificationDelay * 20) +} + +type subListenEvent struct { + kind string // "ack", "tool", "prompt" + id string // subscription ID from _meta, stringified for cross-encoding equality +} + +// newSubListenClient returns a client wired to push every ack and every +// list-changed notification it receives into events, tagged with the kind +// and the subscription ID extracted from _meta. +func newSubListenClient(events chan subListenEvent) *Client { + asEvent := func(kind string, raw any) subListenEvent { + return subListenEvent{kind, fmt.Sprint(raw)} + } + c := NewClient(testImpl, &ClientOptions{ + ToolListChangedHandler: func(_ context.Context, req *ToolListChangedRequest) { + events <- asEvent("tool", req.Params.Meta[MetaKeySubscriptionID]) + }, + PromptListChangedHandler: func(_ context.Context, req *PromptListChangedRequest) { + events <- asEvent("prompt", req.Params.Meta[MetaKeySubscriptionID]) + }, + }) + c.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == notificationSubscriptionsAck { + if cr, ok := req.(*ClientRequest[*SubscriptionsAcknowledgedParams]); ok && cr.Params != nil { + events <- asEvent("ack", cr.Params.Meta[MetaKeySubscriptionID]) + } + } + return next(ctx, method, req) + } + }) + return c +} + +func newSubListenServer() *Server { + s := NewServer(testImpl, nil) + AddTool(s, &Tool{Name: "t1"}, sayHi) + s.AddPrompt(&Prompt{Name: "p1"}, nil) + return s +} + +func enableNewProtocol(t *testing.T) { + t.Helper() + orig := supportedProtocolVersions + supportedProtocolVersions = append([]string{protocolVersion20260728}, slices.Clone(orig)...) + t.Cleanup(func() { supportedProtocolVersions = orig }) +} + +// TestSubscriptionsListen_InMemory exercises the listen flow over the +// session-shared in-memory transport (semantically equivalent to STDIO). +// Cancellation here propagates via notifications/cancelled. +func TestSubscriptionsListen_InMemory(t *testing.T) { + enableNewProtocol(t) + events := make(chan subListenEvent, 64) + server := newSubListenServer() + ct, st := NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + defer ss.Close() + runSubscriptionsListenTest(t, newSubListenClient(events), server, ct, events) +} + +// TestSubscriptionsListen_Streamable exercises the listen flow over a +// stateless HTTP server (SEP-2575). Each listen rides its own SSE response +// stream; cs.Close() tears it down. +func TestSubscriptionsListen_Streamable(t *testing.T) { + enableNewProtocol(t) + events := make(chan subListenEvent, 64) + server := newSubListenServer() + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return server }, + &StreamableHTTPOptions{Stateless: true}, + ) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + defer httpServer.Close() + runSubscriptionsListenTest(t, newSubListenClient(events), server, + &StreamableClientTransport{Endpoint: httpServer.URL}, events) +} + +// TestSubscriptionsListen_NoHandlersNoListen verifies that a new-protocol +// client without any list-changed handlers registered does not open an +// auto-listen on connect, and therefore does not receive any acknowledgment +// or downstream notifications. +func TestSubscriptionsListen_NoHandlersNoListen(t *testing.T) { + enableNewProtocol(t) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + events := make(chan subListenEvent, 8) + server := newSubListenServer() + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + defer ss.Close() + + c := NewClient(testImpl, nil) + c.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == notificationSubscriptionsAck { + events <- subListenEvent{"ack", ""} + } + return next(ctx, method, req) + } + }) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer cs.Close() + + server.AddTool(&Tool{Name: "t2", InputSchema: &jsonschema.Schema{Type: "object"}}, nil) + + select { + case e := <-events: + t.Fatalf("unexpected event %q on no-handler client", e.kind) + case <-time.After(notificationDelay * 10): + } +} + +// resourceSubServer builds a server that advertises resource subscriptions +// and records every Subscribe/Unsubscribe handler invocation through chans. +func resourceSubServer(t *testing.T, subCh, unsubCh chan string) *Server { + t.Helper() + s := NewServer(testImpl, &ServerOptions{ + SubscribeHandler: func(_ context.Context, r *SubscribeRequest) error { + subCh <- r.Params.URI + return nil + }, + UnsubscribeHandler: func(_ context.Context, r *UnsubscribeRequest) error { + unsubCh <- r.Params.URI + return nil + }, + }) + s.AddResource(&Resource{Name: "r1", URI: "file:///r1"}, nil) + return s +} + +// resourceSubEvent is one delivered notifications/resources/updated. +type resourceSubEvent struct { + uri string + id string // _meta subscription ID, stringified +} + +// TestResourceSubscriptionsSEP2575_Streamable verifies the Subscribe -> +// ResourceUpdated path on a stateless Streamable HTTP server. +// +// Caveat: per-subscription Unsubscribe is intentionally NOT verified here. +// In stateless Streamable HTTP mode the subscriptions/listen handler blocks +// on its request context, and neither the HTTP POST disconnect nor the +// separate notifications/cancelled POST currently propagates to that +// handler's context. The handler only unwinds when the server next attempts +// a write to the (now-dead) SSE stream and the writeErr branch in the +// jsonrpc2 layer cancels the in-flight request. To keep the test +// hermetic we therefore trigger a write at the end by adding a resource, +// which fires notifications/resources/list_changed on the auto-listen path +// (if any) and on the per-URI listen, causing the listen handler to unwind. +// The spec-correct fix is to plumb the POST's request context down to the +// subscriptionsListen handler so HTTP disconnect is observed directly; this +// is tracked separately. +func TestResourceSubscriptions_Streamable(t *testing.T) { + enableNewProtocol(t) + + subCh := make(chan string, 8) + unsubCh := make(chan string, 8) + events := make(chan resourceSubEvent, 16) + + server := resourceSubServer(t, subCh, unsubCh) + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return server }, + &StreamableHTTPOptions{Stateless: true}, + ) + httpServer := httptest.NewServer(mustNotPanic(t, handler)) + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + c := NewClient(testImpl, &ClientOptions{ + ResourceUpdatedHandler: func(_ context.Context, req *ResourceUpdatedNotificationRequest) { + id := "" + if req.Params != nil && req.Params.Meta != nil { + id = fmt.Sprint(req.Params.Meta[MetaKeySubscriptionID]) + } + events <- resourceSubEvent{uri: req.Params.URI, id: id} + }, + }) + cs, err := c.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, + &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + + if err := cs.Subscribe(ctx, &SubscribeParams{URI: "file:///r1"}); err != nil { + t.Fatalf("subscribe r1: %v", err) + } + select { + case got := <-subCh: + if got != "file:///r1" { + t.Fatalf("got URI %q, want %q", got, "file:///r1") + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for SubscribeHandler") + } + + server.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{URI: "file:///r1"}) + select { + case e := <-events: + if e.uri != "file:///r1" { + t.Fatalf("got URI %q, want %q", e.uri, "file:///r1") + } + if e.id == "" || e.id == "" { + t.Fatalf("missing subscription ID on update (got %q)", e.id) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for resource update") + } + + // See test header comment for the explanation of this teardown ritual: + // close the client, then drop server-side TCP, then drive a write that + // will fail (any extra ResourceUpdated for our URI), to unblock the + // in-flight listen handler so httpServer.Close can return. + _ = cs.Close() + httpServer.CloseClientConnections() + server.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{URI: "file:///r1"}) + httpServer.Close() +} + +// TestResourceSubscriptions_InMemory mirrors TestResourceSubscriptions_Streamable +// over an in-memory (stdio-equivalent) transport: the per-URI Subscribe path +// uses notifications/cancelled rather than HTTP disconnect for teardown. +func TestResourceSubscriptions_InMemory(t *testing.T) { + enableNewProtocol(t) + + subCh := make(chan string, 8) + unsubCh := make(chan string, 8) + events := make(chan resourceSubEvent, 16) + + server := resourceSubServer(t, subCh, unsubCh) + ct, st := NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + defer ss.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + c := NewClient(testImpl, &ClientOptions{ + ResourceUpdatedHandler: func(_ context.Context, req *ResourceUpdatedNotificationRequest) { + id := "" + if req.Params != nil && req.Params.Meta != nil { + id = fmt.Sprint(req.Params.Meta[MetaKeySubscriptionID]) + } + events <- resourceSubEvent{uri: req.Params.URI, id: id} + }, + }) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer cs.Close() + + if err := cs.Subscribe(ctx, &SubscribeParams{URI: "file:///r1"}); err != nil { + t.Fatalf("subscribe r1: %v", err) + } + waitURI := func(ch chan string, want string) { + t.Helper() + select { + case got := <-ch: + if got != want { + t.Fatalf("got URI %q, want %q", got, want) + } + case <-time.After(5 * time.Second): + t.Fatalf("timed out waiting for URI %q", want) + } + } + waitURI(subCh, "file:///r1") + + server.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{URI: "file:///r1"}) + select { + case e := <-events: + if e.uri != "file:///r1" { + t.Fatalf("got URI %q, want %q", e.uri, "file:///r1") + } + if e.id == "" || e.id == "" { + t.Fatalf("missing subscription ID on update (got %q)", e.id) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for resource update") + } + + // On stdio/in-memory, Unsubscribe sends notifications/cancelled which + // reaches the server preempter, cancels the listen handler ctx, and + // triggers the UnsubscribeHandler. + if err := cs.Unsubscribe(ctx, &UnsubscribeParams{URI: "file:///r1"}); err != nil { + t.Fatalf("unsubscribe r1: %v", err) + } + waitURI(unsubCh, "file:///r1") + + // Updates for an unsubscribed URI MUST NOT be delivered. Give the server + // a moment to process the cancellation first. + time.Sleep(50 * time.Millisecond) + server.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{URI: "file:///r1"}) + select { + case e := <-events: + t.Fatalf("unexpected resource update after unsubscribe: %q (id=%s)", e.uri, e.id) + case <-time.After(notificationDelay * 10): + } +} + +// TestResourceSubscriptions_Subscribe_Idempotent verifies that calling +// Subscribe twice for the same URI in the same session is a no-op for the +// second call: it returns nil without invoking SubscribeHandler again and +// without opening a second listen stream. +func TestResourceSubscriptions_Subscribe_Idempotent(t *testing.T) { + enableNewProtocol(t) + + subCh := make(chan string, 8) + unsubCh := make(chan string, 8) + + server := resourceSubServer(t, subCh, unsubCh) + ct, st := NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + defer ss.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + c := NewClient(testImpl, &ClientOptions{ + ResourceUpdatedHandler: func(context.Context, *ResourceUpdatedNotificationRequest) {}, + }) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer cs.Close() + + if err := cs.Subscribe(ctx, &SubscribeParams{URI: "file:///r1"}); err != nil { + t.Fatalf("first subscribe: %v", err) + } + select { + case got := <-subCh: + if got != "file:///r1" { + t.Fatalf("got URI %q, want %q", got, "file:///r1") + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for first SubscribeHandler") + } + + // Second Subscribe for the same URI returns nil and does NOT fire + // SubscribeHandler again. + if err := cs.Subscribe(ctx, &SubscribeParams{URI: "file:///r1"}); err != nil { + t.Fatalf("second subscribe should be no-op, got error: %v", err) + } + select { + case got := <-subCh: + t.Fatalf("duplicate Subscribe should not re-invoke SubscribeHandler (got %q)", got) + case <-time.After(notificationDelay * 10): + } + + // Subsequent Unsubscribe still works (verifies the URI is tracked + // correctly even though the second Subscribe was a no-op). + if err := cs.Unsubscribe(ctx, &UnsubscribeParams{URI: "file:///r1"}); err != nil { + t.Fatalf("unsubscribe: %v", err) + } + select { + case <-unsubCh: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for UnsubscribeHandler") + } +} + +// TestResourceSubscriptions_MultipleURIs verifies that two concurrent +// Subscribe calls on the same session each open their own independent listen +// stream with a distinct subscription ID. Unsubscribing one does not affect +// the other. +func TestResourceSubscriptions_MultipleURIs(t *testing.T) { + enableNewProtocol(t) + + subCh := make(chan string, 8) + unsubCh := make(chan string, 8) + events := make(chan resourceSubEvent, 16) + + server := resourceSubServer(t, subCh, unsubCh) + server.AddResource(&Resource{Name: "r2", URI: "file:///r2"}, nil) + ct, st := NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + defer ss.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + c := NewClient(testImpl, &ClientOptions{ + ResourceUpdatedHandler: func(_ context.Context, req *ResourceUpdatedNotificationRequest) { + id := "" + if req.Params != nil && req.Params.Meta != nil { + id = fmt.Sprint(req.Params.Meta[MetaKeySubscriptionID]) + } + events <- resourceSubEvent{uri: req.Params.URI, id: id} + }, + }) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer cs.Close() + + if err := cs.Subscribe(ctx, &SubscribeParams{URI: "file:///r1"}); err != nil { + t.Fatalf("subscribe r1: %v", err) + } + if err := cs.Subscribe(ctx, &SubscribeParams{URI: "file:///r2"}); err != nil { + t.Fatalf("subscribe r2: %v", err) + } + + // Each Subscribe MUST fire its own SubscribeHandler invocation. + gotURIs := map[string]bool{} + for range 2 { + select { + case got := <-subCh: + gotURIs[got] = true + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for SubscribeHandler") + } + } + if !gotURIs["file:///r1"] || !gotURIs["file:///r2"] { + t.Fatalf("missing SubscribeHandler invocation; got %v", gotURIs) + } + + // Each update delivers exactly one event tagged with that URI's distinct + // subscription ID. + server.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{URI: "file:///r1"}) + ev1 := <-events + server.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{URI: "file:///r2"}) + ev2 := <-events + if ev1.uri != "file:///r1" || ev2.uri != "file:///r2" { + t.Fatalf("got URIs %q and %q, want r1 and r2", ev1.uri, ev2.uri) + } + if ev1.id == "" || ev2.id == "" { + t.Fatalf("missing subscription IDs: r1=%q r2=%q", ev1.id, ev2.id) + } + if ev1.id == ev2.id { + t.Fatalf("r1 and r2 should have distinct subscription IDs, both = %q", ev1.id) + } + + // Unsubscribe r1 only. r2 keeps working. + if err := cs.Unsubscribe(ctx, &UnsubscribeParams{URI: "file:///r1"}); err != nil { + t.Fatalf("unsubscribe r1: %v", err) + } + select { + case got := <-unsubCh: + if got != "file:///r1" { + t.Fatalf("got unsubscribe URI %q, want %q", got, "file:///r1") + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for UnsubscribeHandler") + } + time.Sleep(50 * time.Millisecond) // let server-side cancellation settle + + server.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{URI: "file:///r1"}) + server.ResourceUpdated(ctx, &ResourceUpdatedNotificationParams{URI: "file:///r2"}) + + // Only the r2 update should arrive. + select { + case e := <-events: + if e.uri != "file:///r2" || e.id != ev2.id { + t.Fatalf("post-unsubscribe got %q (id=%s), want r2 (id=%s)", e.uri, e.id, ev2.id) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for r2 update after r1 unsubscribe") + } + select { + case e := <-events: + t.Fatalf("unexpected event after r1 unsubscribe: %q (id=%s)", e.uri, e.id) + case <-time.After(notificationDelay * 10): + } + + // Explicit Unsubscribe r2 to verify it still works independently. + if err := cs.Unsubscribe(ctx, &UnsubscribeParams{URI: "file:///r2"}); err != nil { + t.Fatalf("unsubscribe r2: %v", err) + } + select { + case <-unsubCh: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for r2 UnsubscribeHandler") + } +} + +// TestSubscriptionsListen_MultipleSessions verifies that two concurrent +// client sessions on the same server are isolated: a list-changed +// notification is fanned out to BOTH sessions, each with its own subscription +// ID; closing one session does not affect deliveries to the other. +func TestSubscriptionsListen_MultipleSessions(t *testing.T) { + enableNewProtocol(t) + + server := newSubListenServer() + + // Open two clients on the same server. + open := func(t *testing.T) (*ClientSession, chan subListenEvent, *ServerSession) { + t.Helper() + events := make(chan subListenEvent, 16) + ct, st := NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + c := newSubListenClient(events) + cs, err := c.Connect(context.Background(), ct, + &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + return cs, events, ss + } + csA, evA, ssA := open(t) + defer ssA.Close() + csB, evB, ssB := open(t) + defer ssB.Close() + + waitFor := func(ch chan subListenEvent, kind string) subListenEvent { + t.Helper() + select { + case e := <-ch: + if e.kind != kind { + t.Fatalf("got event %q, want %q", e.kind, kind) + } + return e + case <-time.After(5 * time.Second): + t.Fatalf("timed out waiting for %q event", kind) + return subListenEvent{} + } + } + ackA := waitFor(evA, "ack") + ackB := waitFor(evB, "ack") + // Request IDs are per-connection, so both sessions may legitimately use + // the same ID number; we only require that each session's own + // notifications carry its own ack ID. + + // A single change fans out to BOTH sessions, each tagged with that + // session's own ack ID. + server.AddTool(&Tool{Name: "t2", InputSchema: &jsonschema.Schema{Type: "object"}}, nil) + gotA := waitFor(evA, "tool") + gotB := waitFor(evB, "tool") + if gotA.id != ackA.id { + t.Errorf("session A: tool notif id=%s, want %s", gotA.id, ackA.id) + } + if gotB.id != ackB.id { + t.Errorf("session B: tool notif id=%s, want %s", gotB.id, ackB.id) + } + + // Close A; B's subscription must keep delivering. + csA.Close() + time.Sleep(50 * time.Millisecond) + server.AddTool(&Tool{Name: "t3", InputSchema: &jsonschema.Schema{Type: "object"}}, nil) + gotB2 := waitFor(evB, "tool") + if gotB2.id != ackB.id { + t.Errorf("session B after A closed: tool notif id=%s, want %s", gotB2.id, ackB.id) + } + select { + case e := <-evA: + t.Fatalf("session A unexpected event after Close: %q", e.kind) + case <-time.After(notificationDelay * 5): + } + + csB.Close() +} + +// TestSubscriptionsListen_ResourceListChanged covers the resources/list_changed +// auto-listen branch (the other two list-changed types are covered by +// runSubscriptionsListenTest, but resources is not). +func TestSubscriptionsListen_ResourceListChanged(t *testing.T) { + enableNewProtocol(t) + events := make(chan subListenEvent, 16) + + server := NewServer(testImpl, nil) + server.AddResource(&Resource{Name: "r1", URI: "file:///r1"}, nil) + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + defer ss.Close() + + asEvent := func(kind string, raw any) subListenEvent { + return subListenEvent{kind, fmt.Sprint(raw)} + } + c := NewClient(testImpl, &ClientOptions{ + ResourceListChangedHandler: func(_ context.Context, req *ResourceListChangedRequest) { + id := any(nil) + if req.Params != nil && req.Params.Meta != nil { + id = req.Params.Meta[MetaKeySubscriptionID] + } + events <- asEvent("resource", id) + }, + }) + c.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == notificationSubscriptionsAck { + if cr, ok := req.(*ClientRequest[*SubscriptionsAcknowledgedParams]); ok && cr.Params != nil { + events <- asEvent("ack", cr.Params.Meta[MetaKeySubscriptionID]) + } + } + return next(ctx, method, req) + } + }) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + + waitFor := func(kind string) subListenEvent { + t.Helper() + select { + case e := <-events: + if e.kind != kind { + t.Fatalf("got event %q, want %q", e.kind, kind) + } + return e + case <-time.After(5 * time.Second): + t.Fatalf("timed out waiting for %q", kind) + return subListenEvent{} + } + } + ack := waitFor("ack") + if ack.id == "" { + t.Fatal("acknowledgment missing subscription ID") + } + server.AddResource(&Resource{Name: "r2", URI: "file:///r2"}, nil) + got := waitFor("resource") + if got.id != ack.id { + t.Errorf("resource notif id=%s, want %s", got.id, ack.id) + } + cs.Close() +} + +// TestSubscriptionsListen_DisconnectScrubsMaps verifies that closing a +// session removes its entries from the server's three per-type subscription +// maps via Server.disconnect. +func TestSubscriptionsListen_DisconnectScrubsMaps(t *testing.T) { + enableNewProtocol(t) + events := make(chan subListenEvent, 16) + server := newSubListenServer() + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + c := newSubListenClient(events) + cs, err := c.Connect(context.Background(), ct, + &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + + // Wait for the auto-listen to actually register on the server side. + select { + case e := <-events: + if e.kind != "ack" { + t.Fatalf("got %q, want ack", e.kind) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for ack") + } + + // Server should now have this session in tools+prompts maps (the fixture + // client opts in to both via newSubListenClient). + server.mu.Lock() + if _, ok := server.toolChangeSubscriptions[ss]; !ok { + server.mu.Unlock() + t.Fatal("session missing from toolChangeSubscriptions before close") + } + if _, ok := server.promptChangeSubscriptions[ss]; !ok { + server.mu.Unlock() + t.Fatal("session missing from promptChangeSubscriptions before close") + } + server.mu.Unlock() + + cs.Close() + ss.Close() + // Closures complete asynchronously on the server side. + deadline := time.Now().Add(5 * time.Second) + for { + server.mu.Lock() + _, inTool := server.toolChangeSubscriptions[ss] + _, inPrompt := server.promptChangeSubscriptions[ss] + _, inResource := server.resourceChangeSubscriptions[ss] + server.mu.Unlock() + if !inTool && !inPrompt && !inResource { + return + } + if time.Now().After(deadline) { + t.Fatalf("subscription maps not scrubbed after Close: tool=%v prompt=%v resource=%v", + inTool, inPrompt, inResource) + } + time.Sleep(10 * time.Millisecond) + } +} diff --git a/mcp/protocol.go b/mcp/protocol.go index 4b0872b..3553925 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -1931,6 +1931,49 @@ type ResourceUpdatedNotificationParams struct { func (x *ResourceUpdatedNotificationParams) isParams() {} func (x *ResourceUpdatedNotificationParams) isNil() bool { return x == nil } +// NotificationSubscriptions describes the set of server-to-client +// notifications a client wishes to receive on a [SubscriptionsListenParams] +// stream. Each field is an explicit opt-in: a server MUST NOT push +// notifications of a type the client did not request. +type NotificationSubscriptions struct { + // ToolsListChanged opts in to "notifications/tools/list_changed". + ToolsListChanged bool `json:"toolsListChanged,omitempty"` + // PromptsListChanged opts in to "notifications/prompts/list_changed". + PromptsListChanged bool `json:"promptsListChanged,omitempty"` + // ResourcesListChanged opts in to "notifications/resources/list_changed". + ResourcesListChanged bool `json:"resourcesListChanged,omitempty"` + // ResourceSubscriptions enumerates the resource URIs for which the client + // wants "notifications/resources/updated". Replaces the legacy + // resources/subscribe RPC. + ResourceSubscriptions []string `json:"resourceSubscriptions,omitempty"` +} + +// SubscriptionsListenParams are the parameters for the +// "subscriptions/listen" RPC. +type SubscriptionsListenParams struct { + // Meta carries the per-request `_meta` triple. + Meta `json:"_meta,omitempty"` + // Notifications declares which notification types the client wants to + // receive on this stream. + Notifications NotificationSubscriptions `json:"notifications"` +} + +func (x *SubscriptionsListenParams) isParams() {} +func (x *SubscriptionsListenParams) isNil() bool { return x == nil } + +// SubscriptionsAcknowledgedParams are the parameters for the +// "notifications/subscriptions/acknowledged" notification, which the server +// MUST send as the first message on a subscriptions/listen stream. It carries +// the subset of the requested [NotificationSubscriptions] that the server has +// agreed to honor. +type SubscriptionsAcknowledgedParams struct { + Meta `json:"_meta,omitempty"` + Notifications NotificationSubscriptions `json:"notifications"` +} + +func (x *SubscriptionsAcknowledgedParams) isParams() {} +func (x *SubscriptionsAcknowledgedParams) isNil() bool { return x == nil } + // TODO(jba): add CompleteRequest and related types. // A request from the server to elicit additional information from the user via the client. @@ -2134,8 +2177,10 @@ const ( notificationRootsListChanged = "notifications/roots/list_changed" methodSetLevel = "logging/setLevel" methodSubscribe = "resources/subscribe" + methodSubscriptionsListen = "subscriptions/listen" notificationToolListChanged = "notifications/tools/list_changed" methodUnsubscribe = "resources/unsubscribe" + notificationSubscriptionsAck = "notifications/subscriptions/acknowledged" ) // Per-request _meta field names for the >= 2026-07-28 protocol version. @@ -2152,6 +2197,9 @@ const ( MetaKeyClientCapabilities = "io.modelcontextprotocol/clientCapabilities" // MetaKeyLogLevel identifies the desired log level for the request. MetaKeyLogLevel = "io.modelcontextprotocol/logLevel" + // MetaKeySubscriptionID identifies the subscriptions/listen request that an + // out-of-band notification belongs to. + MetaKeySubscriptionID = "io.modelcontextprotocol/subscriptionId" ) // UnsupportedProtocolVersionData is the SEP-2575 payload carried in the diff --git a/mcp/requests.go b/mcp/requests.go index 36368c9..64414ca 100644 --- a/mcp/requests.go +++ b/mcp/requests.go @@ -19,6 +19,7 @@ type ( ReadResourceRequest = ServerRequest[*ReadResourceParams] RootsListChangedRequest = ServerRequest[*RootsListChangedParams] SubscribeRequest = ServerRequest[*SubscribeParams] + SubscriptionsListenRequest = ServerRequest[*SubscriptionsListenParams] UnsubscribeRequest = ServerRequest[*UnsubscribeParams] ) diff --git a/mcp/server.go b/mcp/server.go index af59b40..011ea56 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -44,16 +44,19 @@ type Server struct { impl *Implementation opts ServerOptions - mu sync.Mutex - prompts *featureSet[*serverPrompt] - tools *featureSet[*serverTool] - resources *featureSet[*serverResource] - resourceTemplates *featureSet[*serverResourceTemplate] - sessions []*ServerSession - sendingMethodHandler_ MethodHandler - receivingMethodHandler_ MethodHandler - resourceSubscriptions map[string]map[*ServerSession]bool // uri -> session -> bool - pendingNotifications map[string]*time.Timer // notification name -> timer for pending notification send + mu sync.Mutex + prompts *featureSet[*serverPrompt] + tools *featureSet[*serverTool] + resources *featureSet[*serverResource] + resourceTemplates *featureSet[*serverResourceTemplate] + sessions []*ServerSession + sendingMethodHandler_ MethodHandler + receivingMethodHandler_ MethodHandler + toolChangeSubscriptions map[*ServerSession]jsonrpc.ID // session -> requestID for "tools/changed" + promptChangeSubscriptions map[*ServerSession]jsonrpc.ID // session -> requestID for "prompts/changed" + resourceChangeSubscriptions map[*ServerSession]jsonrpc.ID // session -> requestID for "resources/changed" + resourceSubscriptions map[string]map[*ServerSession]jsonrpc.ID // uri -> session -> requestID + pendingNotifications map[string]*time.Timer // notification name -> timer for pending notification send } // ServerOptions is used to configure behavior of the server. @@ -195,16 +198,19 @@ func NewServer(impl *Implementation, options *ServerOptions) *Server { } s := &Server{ - impl: impl, - opts: opts, - prompts: newFeatureSet(func(p *serverPrompt) string { return p.prompt.Name }), - tools: newFeatureSet(func(t *serverTool) string { return t.tool.Name }), - resources: newFeatureSet(func(r *serverResource) string { return r.resource.URI }), - resourceTemplates: newFeatureSet(func(t *serverResourceTemplate) string { return t.resourceTemplate.URITemplate }), - sendingMethodHandler_: defaultSendingMethodHandler, - receivingMethodHandler_: defaultReceivingMethodHandler[*ServerSession], - resourceSubscriptions: make(map[string]map[*ServerSession]bool), - pendingNotifications: make(map[string]*time.Timer), + impl: impl, + opts: opts, + prompts: newFeatureSet(func(p *serverPrompt) string { return p.prompt.Name }), + tools: newFeatureSet(func(t *serverTool) string { return t.tool.Name }), + resources: newFeatureSet(func(r *serverResource) string { return r.resource.URI }), + resourceTemplates: newFeatureSet(func(t *serverResourceTemplate) string { return t.resourceTemplate.URITemplate }), + sendingMethodHandler_: defaultSendingMethodHandler, + receivingMethodHandler_: defaultReceivingMethodHandler[*ServerSession], + toolChangeSubscriptions: make(map[*ServerSession]jsonrpc.ID), + promptChangeSubscriptions: make(map[*ServerSession]jsonrpc.ID), + resourceChangeSubscriptions: make(map[*ServerSession]jsonrpc.ID), + resourceSubscriptions: make(map[string]map[*ServerSession]jsonrpc.ID), + pendingNotifications: make(map[string]*time.Timer), } s.AddReceivingMiddleware(serverMultiRoundTripMiddleware()) return s @@ -647,12 +653,13 @@ func (s *Server) complete(ctx context.Context, req *CompleteRequest) (*CompleteR return s.opts.CompletionHandler(ctx, req) } -// Map from notification name to its corresponding params. The params have no fields, -// so a single struct can be reused. -var changeNotificationParams = map[string]Params{ - notificationToolListChanged: &ToolListChangedParams{}, - notificationPromptListChanged: &PromptListChangedParams{}, - notificationResourceListChanged: &ResourceListChangedParams{}, +// Map from notification name to a function creating its corresponding Params. +// We need to create a fresh one each time to add the jsonrpc ID. See +// [injectMetaSubscriptionID]. +var changeNotificationParams = map[string]func() Params{ + notificationToolListChanged: func() Params { return &ToolListChangedParams{} }, + notificationPromptListChanged: func() Params { return &PromptListChangedParams{} }, + notificationResourceListChanged: func() Params { return &ResourceListChangedParams{} }, } // How long to wait before sending a change notification. @@ -686,12 +693,62 @@ func (s *Server) changeAndNotify(notification string, change func() bool) { // notifySessions sends the notification n to all existing sessions. // It is called asynchronously by changeAndNotify. +// +// Legacy (pre-SEP-2575) sessions receive the notification on the shared +// session channel. Sessions speaking the new protocol receive it only if they +// have an active subscriptions/listen stream that opted in to this +// notification type. func (s *Server) notifySessions(n string) { s.mu.Lock() - sessions := slices.Clone(s.sessions) s.pendingNotifications[n] = nil + // Legacy (pre-SEP-2575) sessions receive list-changed notifications on the + // shared session channel without opt-in; collect them while we hold the lock. + var legacySessions []*ServerSession + for _, sess := range s.sessions { + if sess.InitializeParams().isNil() || sess.InitializeParams().ProtocolVersion < protocolVersion20260728 { + legacySessions = append(legacySessions, sess) + } + } + var subscribers map[*ServerSession]jsonrpc.ID + switch n { + case notificationToolListChanged: + subscribers = maps.Clone(s.toolChangeSubscriptions) + case notificationPromptListChanged: + subscribers = maps.Clone(s.promptChangeSubscriptions) + case notificationResourceListChanged: + subscribers = maps.Clone(s.resourceChangeSubscriptions) + } s.mu.Unlock() // Don't hold the lock during notification: it causes deadlock. - notifySessions(sessions, n, changeNotificationParams[n], s.opts.Logger) + + notifySessions(legacySessions, n, changeNotificationParams[n](), s.opts.Logger) + + // Sessions receive the notification only if they opened a + // subscriptions/listen stream that opted in to this notification type. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + for sess, reqID := range subscribers { + params := changeNotificationParams[n]() + injectMetaSubscriptionID(params, reqID) + req := newRequest(sess, params) + if err := handleNotify(ctx, n, req); err != nil { + s.opts.Logger.Warn(fmt.Sprintf("calling %s: %v", n, err)) + } + } +} + +// injectMetaSubscriptionID stamps the listen request's JSON-RPC ID into the +// params' _meta under [MetaKeySubscriptionID], so the receiving client can +// correlate the notification with the [subscriptions/listen] stream it +// originated. +// +// [subscriptions/listen]: https://modelcontextprotocol.io/seps/2575-stateless-mcp#multiple-concurrent-subscriptions +func injectMetaSubscriptionID(params Params, reqID jsonrpc.ID) { + m := params.GetMeta() + if m == nil { + m = map[string]any{} + } + m[MetaKeySubscriptionID] = reqID.Raw() + params.SetMeta(m) } // shouldSendListChangedNotification checks if the server's capabilities allow @@ -1006,12 +1063,38 @@ func (s *Server) ResourceUpdated(ctx context.Context, params *ResourceUpdatedNot subscribedSessions := s.resourceSubscriptions[params.URI] sessions := slices.Collect(maps.Keys(subscribedSessions)) s.mu.Unlock() - notifySessions(sessions, notificationResourceUpdated, params, s.opts.Logger) + // Only add legacy sessions for the notification, new ones use the new notification mechanism. + var legacySessions []*ServerSession + var newSessions []*ServerSession + for _, sess := range sessions { + if sess.InitializeParams().isNil() || sess.InitializeParams().ProtocolVersion < protocolVersion20260728 { + legacySessions = append(legacySessions, sess) + } else { + newSessions = append(newSessions, sess) + } + } + notifySessions(legacySessions, notificationResourceUpdated, params, s.opts.Logger) s.opts.Logger.Info("resource updated notification sent", "uri", params.URI, "subscriber_count", len(sessions)) + + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + for _, sess := range newSessions { + reqID := subscribedSessions[sess] + p := *params + injectMetaSubscriptionID(&p, reqID) + req := newRequest(sess, &p) + if err := handleNotify(ctx, notificationResourceUpdated, req); err != nil { + s.opts.Logger.Warn(fmt.Sprintf("calling %s: %v", notificationResourceUpdated, err)) + } + } return nil } func (s *Server) subscribe(ctx context.Context, req *SubscribeRequest) (*emptyResult, error) { + requestID, ok := ctx.Value(idContextKey{}).(jsonrpc.ID) + if !ok || !requestID.IsValid() { + return nil, fmt.Errorf("%w: subscribe requires a request ID", jsonrpc2.ErrInvalidRequest) + } if s.opts.SubscribeHandler == nil { return nil, fmt.Errorf("%w: server does not support resource subscriptions", jsonrpc2.ErrMethodNotFound) } @@ -1022,10 +1105,10 @@ func (s *Server) subscribe(ctx context.Context, req *SubscribeRequest) (*emptyRe s.mu.Lock() defer s.mu.Unlock() if s.resourceSubscriptions[req.Params.URI] == nil { - s.resourceSubscriptions[req.Params.URI] = make(map[*ServerSession]bool) + s.resourceSubscriptions[req.Params.URI] = make(map[*ServerSession]jsonrpc.ID) } - s.resourceSubscriptions[req.Params.URI][req.Session] = true - s.opts.Logger.Info("resource subscribed", "uri", req.Params.URI, "session_id", req.Session.ID()) + s.resourceSubscriptions[req.Params.URI][req.Session] = requestID + s.opts.Logger.Info("resource subscribed", "uri", req.Params.URI, "session_id", req.Session.ID(), "request_id", requestID) return &emptyResult{}, nil } @@ -1052,6 +1135,82 @@ func (s *Server) unsubscribe(ctx context.Context, req *UnsubscribeRequest) (*emp return &emptyResult{}, nil } +func (s *Server) subscriptionsListen(ctx context.Context, req *SubscriptionsListenRequest) (*emptyResult, error) { + requestID, ok := ctx.Value(idContextKey{}).(jsonrpc.ID) + if !ok || !requestID.IsValid() { + return nil, fmt.Errorf("%w: subscriptions/listen requires a request ID", jsonrpc2.ErrInvalidRequest) + } + + allowed := s.allowedSubscriptions(req.Params.Notifications) + s.mu.Lock() + if allowed.ToolsListChanged { + s.toolChangeSubscriptions[req.Session] = requestID + } + if allowed.PromptsListChanged { + s.promptChangeSubscriptions[req.Session] = requestID + } + if allowed.ResourcesListChanged { + s.resourceChangeSubscriptions[req.Session] = requestID + } + s.mu.Unlock() + defer func() { + s.mu.Lock() + delete(s.toolChangeSubscriptions, req.Session) + delete(s.promptChangeSubscriptions, req.Session) + delete(s.resourceChangeSubscriptions, req.Session) + s.mu.Unlock() + }() + + for _, uri := range allowed.ResourceSubscriptions { + _, err := s.subscribe(ctx, &SubscribeRequest{ + Session: req.Session, + Params: &SubscribeParams{ + URI: uri, + Meta: req.Params.GetMeta(), + }, + }) + if err != nil { + return nil, err + } + defer s.unsubscribe(ctx, &UnsubscribeRequest{ + Session: req.Session, + Params: &UnsubscribeParams{ + URI: uri, + Meta: req.Params.GetMeta(), + }, + }) + } + + ackParams := &SubscriptionsAcknowledgedParams{ + Notifications: allowed, + Meta: Meta{MetaKeySubscriptionID: requestID.Raw()}, + } + if err := req.Session.notifySubscriptionAcked(ctx, ackParams); err != nil { + return nil, fmt.Errorf("sending subscriptions/acknowledged: %w", err) + } + + <-ctx.Done() + return &emptyResult{}, nil +} + +func (s *Server) allowedSubscriptions(want NotificationSubscriptions) NotificationSubscriptions { + caps := s.capabilities() + agreed := NotificationSubscriptions{} + if want.ToolsListChanged && caps.Tools != nil && caps.Tools.ListChanged { + agreed.ToolsListChanged = true + } + if want.PromptsListChanged && caps.Prompts != nil && caps.Prompts.ListChanged { + agreed.PromptsListChanged = true + } + if want.ResourcesListChanged && caps.Resources != nil && caps.Resources.ListChanged { + agreed.ResourcesListChanged = true + } + if len(want.ResourceSubscriptions) > 0 && caps.Resources != nil && caps.Resources.Subscribe { + agreed.ResourceSubscriptions = slices.Clone(want.ResourceSubscriptions) + } + return agreed +} + // Run runs the server over the given transport, which must be persistent. // // Run blocks until the client terminates the connection or the provided @@ -1121,6 +1280,10 @@ func (s *Server) disconnect(cc *ServerSession) { for _, subscribedSessions := range s.resourceSubscriptions { delete(subscribedSessions, cc) } + delete(s.toolChangeSubscriptions, cc) + delete(s.promptChangeSubscriptions, cc) + delete(s.resourceChangeSubscriptions, cc) + s.opts.Logger.Info("server session disconnected", "session_id", cc.ID()) } @@ -1221,6 +1384,13 @@ func (ss *ServerSession) NotifyProgress(ctx context.Context, params *ProgressNot return handleNotify(ctx, notificationProgress, newServerRequest(ss, orZero[Params](params))) } +// notifySubscriptionAcked sends a "notifications/subscriptions/acknowledged" +// notification on the listen stream represented by this session, indicating +// the subscription filter the server accepted (SEP-2575). +func (ss *ServerSession) notifySubscriptionAcked(ctx context.Context, params *SubscriptionsAcknowledgedParams) error { + return handleNotify(ctx, notificationSubscriptionsAck, newServerRequest(ss, orZero[Params](params))) +} + func newServerRequest[P Params](ss *ServerSession, params P) *ServerRequest[P] { return &ServerRequest[P]{Session: ss, Params: params} } @@ -1522,6 +1692,7 @@ var serverMethodInfos = map[string]methodInfo{ methodReadResource: newServerMethodInfo(serverMethod((*Server).readResource), 0), methodSetLevel: newServerMethodInfo(serverSessionMethod((*ServerSession).setLevel), 0), methodSubscribe: newServerMethodInfo(serverMethod((*Server).subscribe), 0), + methodSubscriptionsListen: newServerMethodInfo(serverMethod((*Server).subscriptionsListen), 0), methodUnsubscribe: newServerMethodInfo(serverMethod((*Server).unsubscribe), 0), notificationCancelled: newServerMethodInfo(serverSessionMethod((*ServerSession).cancel), notification|missingParamsOK), notificationInitialized: newServerMethodInfo(serverSessionMethod((*ServerSession).initialized), notification|missingParamsOK), @@ -1598,7 +1769,7 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, } switch req.Method { - case methodInitialize, methodPing, notificationInitialized, methodSetLevel: + case methodInitialize, methodPing, notificationInitialized, methodSetLevel, methodSubscribe, methodUnsubscribe: if validatedMeta.usesNewProtocol { ss.server.opts.Logger.Error("method removed in the new protocol", "method", req.Method) return nil, &jsonrpc.Error{ diff --git a/mcp/shared.go b/mcp/shared.go index a15ec20..ac07ee4 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -123,8 +123,12 @@ func defaultSendingMethodHandler(ctx context.Context, method string, req Request // Create the result to unmarshal into. // The concrete type of the result is the return type of the receiving function. res := info.newResult() - if err := call(ctx, req.GetSession().getConn(), method, params, res); err != nil { - return nil, err + if method == methodSubscriptionsListen { + callSubscriptionsListen(ctx, req.GetSession().getConn(), method, params) + } else { + if err := call(ctx, req.GetSession().getConn(), method, params, res); err != nil { + return nil, err + } } return res, nil } diff --git a/mcp/streamable.go b/mcp/streamable.go index f01266a..b71bc77 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -366,14 +366,14 @@ func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http. return } - connectOpts, usesNewProtocol, err := h.ephemeralConnectOpts(req) + info, err := h.ephemeralConnectOpts(req) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } var sessionID string - if legacySessions && !usesNewProtocol { + if legacySessions && !info.usesNewProtocol { sessionID = req.Header.Get(sessionIDHeader) if sessionID == "" { sessionID = server.opts.GetSessionID() @@ -381,14 +381,15 @@ func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http. } transport := &StreamableServerTransport{ - SessionID: sessionID, - Stateless: true, - EventStore: h.opts.EventStore, - jsonResponse: h.opts.JSONResponse, - logger: h.opts.Logger, + SessionID: sessionID, + Stateless: true, + EventStore: h.opts.EventStore, + jsonResponse: h.opts.JSONResponse, + logger: h.opts.Logger, + shouldPropagateCancellation: info.isSubscriptionsListen && info.usesNewProtocol, } - session, err := connectStreamable(req.Context(), server, transport, connectOpts) + session, err := connectStreamable(req.Context(), server, transport, info.opts) if err != nil { h.opts.Logger.Error(fmt.Sprintf("failed to connect: %v", err)) http.Error(w, "failed connection", http.StatusInternalServerError) @@ -412,27 +413,29 @@ func (h *StreamableHTTPHandler) serveStatelessLegacyDELETE(w http.ResponseWriter w.WriteHeader(http.StatusNoContent) } -// ephemeralConnectOpts peeks at the request body to determine whether it -// contains an initialize or initialized message or whether the protocol version -// header indicates a protocol version >= 2026-07-28 (SEP-2575). +type ephemeralConnectInfo struct { + opts *ServerSessionOptions + usesNewProtocol bool + isSubscriptionsListen bool +} + +// ephemeralConnectOpts peeks at the request body to determine connection +// parameters and whether protocol version >= 2026-06-30 (SEP-2575). // // For old-protocol requests, default session state is synthesized so that // the session's init gate doesn't reject the request. // // It is used for both stateless servers and stateful servers with no session ID. -// -// The returned usesNewProtocol bool reports whether the protocol version -// header indicates a protocol version >= 2026-07-28 (SEP-2575). -func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (opts *ServerSessionOptions, usesNewProtocol bool, err error) { +func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (*ephemeralConnectInfo, error) { protocolVersion := protocolVersionFromContext(req.Context()) if protocolVersion == "" { protocolVersion = protocolVersion20250326 } - var hasInitialize, hasInitialized bool + var hasInitialize, hasInitialized, usesNewProtocol, isSubscriptionsListen bool body, err := io.ReadAll(req.Body) if err != nil { - return nil, false, fmt.Errorf("failed to read body") + return nil, fmt.Errorf("failed to read body") } req.Body.Close() req.Body = io.NopCloser(bytes.NewBuffer(body)) @@ -445,6 +448,8 @@ func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (opts *S hasInitialize = true case notificationInitialized: hasInitialized = true + case methodSubscriptionsListen: + isSubscriptionsListen = true } if protocolVersion >= protocolVersion20260728 { usesNewProtocol = true @@ -467,9 +472,13 @@ func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (opts *S if !usesNewProtocol { state.LogLevel = "info" } - return &ServerSessionOptions{ - State: state, - }, usesNewProtocol, nil + return &ephemeralConnectInfo{ + opts: &ServerSessionOptions{ + State: state, + }, + usesNewProtocol: usesNewProtocol, + isSubscriptionsListen: isSubscriptionsListen, + }, nil } func connectStreamable(ctx context.Context, server *Server, transport *StreamableServerTransport, opts *ServerSessionOptions) (*ServerSession, error) { @@ -614,12 +623,12 @@ func (h *StreamableHTTPHandler) serveStatefulPOST(w http.ResponseWriter, req *ht // that arrives before a session exists (e.g. initialize or ping) on a // server configured this way. if sessionID == "" { - connectOpts, _, err := h.ephemeralConnectOpts(req) + info, err := h.ephemeralConnectOpts(req) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - session, err := connectStreamable(req.Context(), server, transport, connectOpts) + session, err := connectStreamable(req.Context(), server, transport, info.opts) if err != nil { h.opts.Logger.Error(fmt.Sprintf("failed to connect: %v", err)) http.Error(w, "failed connection", http.StatusInternalServerError) @@ -775,6 +784,10 @@ type StreamableServerTransport struct { // to write their own streamable HTTP handler. logger *slog.Logger + // shouldPropagateCancellation is forwarded to the underlying + // [streamableServerConn]. See its docstring. + shouldPropagateCancellation bool + // connection is non-nil if and only if the transport has been connected. connection *streamableServerConn } @@ -785,15 +798,16 @@ func (t *StreamableServerTransport) Connect(ctx context.Context) (Connection, er return nil, fmt.Errorf("transport already connected") } t.connection = &streamableServerConn{ - sessionID: t.SessionID, - stateless: t.Stateless, - eventStore: t.EventStore, - jsonResponse: t.jsonResponse, - logger: ensureLogger(t.logger), // see #556: must be non-nil - incoming: make(chan jsonrpc.Message, 10), - done: make(chan struct{}), - streams: make(map[string]*stream), - requestStreams: make(map[jsonrpc.ID]string), + sessionID: t.SessionID, + stateless: t.Stateless, + eventStore: t.EventStore, + jsonResponse: t.jsonResponse, + logger: ensureLogger(t.logger), // see #556: must be non-nil + shouldPropagateCancellation: t.shouldPropagateCancellation, + incoming: make(chan jsonrpc.Message, 10), + done: make(chan struct{}), + streams: make(map[string]*stream), + requestStreams: make(map[jsonrpc.ID]string), } // Stream 0 corresponds to the standalone SSE stream. // @@ -823,6 +837,13 @@ type streamableServerConn struct { jsonResponse bool eventStore EventStore + // shouldPropagateCancellation is true when the underlying HTTP request's + // lifetime IS the connection's cancellation signal (e.g., a stateless + // POST that owns a long-lived subscriptions/listen stream). It is read + // by the [cancellationPropagator] interface so the jsonrpc2 layer wires + // handler contexts to observe the carrier's cancellation. + shouldPropagateCancellation bool + logger *slog.Logger toolLookup func(name string) (*serverTool, bool) @@ -860,6 +881,15 @@ func (c *streamableServerConn) SessionID() string { return c.sessionID } +// propagateCancellation implements [cancellationPropagator]. It returns true +// when this connection is bound to a single HTTP request whose lifetime +// should drive request-handler cancellation — for example, a stateless POST +// carrying a long-lived subscriptions/listen stream that must unwind when +// the client TCP-disconnects. +func (c *streamableServerConn) propagateCancellation() bool { + return c.shouldPropagateCancellation +} + // A stream is a single logical stream of SSE events within a server session. // A stream begins with a client request, or with a client GET that has // no Last-Event-ID header. @@ -914,6 +944,12 @@ type stream struct { // the spec and earlier there was a concept of batching, in which POST // payloads could hold multiple requests or responses. requests map[jsonrpc.ID]struct{} + + // isListen reports whether this stream was opened by a + // subscriptions/listen request. Listen streams are always SSE, live for + // the duration of the subscription, and act as the target for + // out-of-band notifications routed through this connection. + isListen bool } // close sends a 'close' event to the client (if protocolVersion >= 2025-11-25 @@ -1372,6 +1408,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques calls := make(map[jsonrpc.ID]struct{}) tokenInfo := auth.TokenInfoFromContext(req.Context()) isInitialize := false + isSubscriptionsListen := false var initializeProtocolVersion string for _, msg := range incoming { if jreq, ok := msg.(*jsonrpc.Request); ok { @@ -1397,6 +1434,9 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques initializeProtocolVersion = params.ProtocolVersion } } + if jreq.Method == methodSubscriptionsListen { + isSubscriptionsListen = true + } // SEP-2575: requests carrying `_meta.protocolVersion` require the // Mcp-Protocol-Version HTTP header to be present and to match the // per-request `_meta.protocolVersion` value. @@ -1542,14 +1582,22 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques http.Error(w, fmt.Sprintf("storing stream: %v", err), http.StatusInternalServerError) return } + stream.isListen = isSubscriptionsListen + + // subscriptions/listen is inherently a long-lived SSE endpoint (SEP-2575): + // it has no synchronous result, the response stream stays open until the + // client cancels, and the server pushes notifications on it as they occur. + // Force SSE mode (bypassing JSONResponse) so the buffered application/json + // path doesn't deadlock waiting for a completion that won't come. + useSSE := !c.jsonResponse || isSubscriptionsListen // Set response headers. Accept was checked in [StreamableHTTPHandler]. w.Header().Set("Cache-Control", "no-cache, no-transform") - if c.jsonResponse { - w.Header().Set("Content-Type", "application/json") - } else { + if useSSE { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Connection", "keep-alive") + } else { + w.Header().Set("Content-Type", "application/json") } if c.sessionID != "" && isInitialize { w.Header().Set(sessionIDHeader, c.sessionID) @@ -1584,7 +1632,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // remaining requests here, since the client will never get the results. defer stream.release() - if c.jsonResponse { + if !useSSE { // JSON mode: collect messages in pendingJSONMessages until done. // Set pendingJSONMessages to a non-nil value to signal that this is an // application/json stream. @@ -1724,7 +1772,18 @@ func (c *streamableServerConn) Write(ctx context.Context, msg jsonrpc.Message) e s = c.streams[streamID] } } else { - s = c.streams[""] // standalone SSE stream + // In stateless mode there will always be only one stream per connection. + // If that stream was open to listen for subscription notifications, + // automatically select as the one to write the notification to. + for _, stream := range c.streams { + if stream.isListen { + s = stream + break + } + } + if s == nil { + s = c.streams[""] // standalone SSE stream + } } if responseTo.IsValid() { // Once we've responded to a request, disallow related messages by removing diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 539789b..b5c0eb9 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -3319,20 +3319,20 @@ func TestEphemeralConnectOpts(t *testing.T) { } req.Header.Set(protocolVersionHeader, pver) req = req.WithContext(context.WithValue(req.Context(), protocolVersionContextKey{}, pver)) - opts, usesNew, err := h.ephemeralConnectOpts(req) + info, err := h.ephemeralConnectOpts(req) if err != nil { t.Fatal(err) } - if usesNew != tt.wantUsesNew { - t.Errorf("usesNewProtocol = %v, want %v", usesNew, tt.wantUsesNew) + if info.usesNewProtocol != tt.wantUsesNew { + t.Errorf("usesNewProtocol = %v, want %v", info.usesNewProtocol, tt.wantUsesNew) } - if got := opts.State.InitializeParams != nil; got != tt.wantInitializeParams { + if got := info.opts.State.InitializeParams != nil; got != tt.wantInitializeParams { t.Errorf("InitializeParams non-nil = %v, want %v (value = %+v)", - got, tt.wantInitializeParams, opts.State.InitializeParams) + got, tt.wantInitializeParams, info.opts.State.InitializeParams) } - if got := opts.State.InitializedParams != nil; got != tt.wantInitializedParams { + if got := info.opts.State.InitializedParams != nil; got != tt.wantInitializedParams { t.Errorf("InitializedParams non-nil = %v, want %v (value = %+v)", - got, tt.wantInitializedParams, opts.State.InitializedParams) + got, tt.wantInitializedParams, info.opts.State.InitializedParams) } }) } diff --git a/mcp/transport.go b/mcp/transport.go index f67c43d..55c73d7 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -191,6 +191,13 @@ func connect[H handler, State any](ctx context.Context, t Transport, b binder[H, preempter.conn = conn return jsonrpc2.HandlerFunc(h.handle) } + // Transports may opt in to propagating cancellation of ctx into request + // handler contexts when their own lifecycle IS the cancellation signal + // (e.g., a connection bound to a single HTTP request). + var propagateCancellation bool + if cp, ok := mcpConn.(cancellationPropagator); ok { + propagateCancellation = cp.propagateCancellation() + } _ = jsonrpc2.NewConnection(ctx, jsonrpc2.ConnectionConfig{ Reader: reader, Writer: writer, @@ -203,11 +210,22 @@ func connect[H handler, State any](ctx context.Context, t Transport, b binder[H, OnInternalError: func(err error) { logger.Error("jsonrpc2 internal error", "error", err) }, + PropagateCancellation: propagateCancellation, }) assert(preempter.conn != nil, "unbound preempter") return h, nil } +// cancellationPropagator is an optional interface implemented by a +// [Connection] whose own lifecycle should propagate cancellation into request +// handler contexts. The default jsonrpc2 behavior is to suppress propagation; +// transports that bind a connection to a single short-lived carrier (such as +// a one-shot HTTP request) should return true here so that handlers unwind +// when the carrier observes the peer going away. +type cancellationPropagator interface { + propagateCancellation() bool +} + // A canceller is a jsonrpc2.Preempter that cancels in-flight requests on MCP // cancelled notifications. type canceller struct { @@ -230,6 +248,24 @@ func (c *canceller) Preempt(ctx context.Context, req *jsonrpc.Request) (result a return nil, jsonrpc2.ErrNotHandled } +// callSubscriptionsListen issues a "subscriptions/listen" call (SEP-2575) +// without awaiting its JSON-RPC response. The call's logical lifetime is the +// stream of notifications that follow on the same channel — the empty +// response, if ever delivered, only marks subscription teardown — so the +// caller has nothing useful to block on. +// +// Cancellation is driven by ctx: when it is cancelled, a background goroutine +// sends a "notifications/cancelled" notification referencing the listen's +// request ID and retires the call from the connection's outgoing-calls map. +func callSubscriptionsListen(ctx context.Context, conn *jsonrpc2.Connection, method string, params Params) { + call := conn.Call(ctx, method, params) + + go func() { + <-ctx.Done() + _ = cancelCall(ctx, conn, call) + }() +} + // call executes and awaits a jsonrpc2 call on the given connection, // translating errors into the mcp domain. func call(ctx context.Context, conn *jsonrpc2.Connection, method string, params Params, result Result) error { @@ -240,24 +276,7 @@ func call(ctx context.Context, conn *jsonrpc2.Connection, method string, params case errors.Is(err, jsonrpc2.ErrClientClosing), errors.Is(err, jsonrpc2.ErrServerClosing): return fmt.Errorf("%w: calling %q: %v", ErrConnectionClosed, method, err) case ctx.Err() != nil: - notifyCtx, cancelNotify := context.WithTimeout(context.WithoutCancel(ctx), notifyCancellationTimeout) - defer cancelNotify() - err := conn.Notify(notifyCtx, notificationCancelled, &CancelledParams{ - Reason: ctx.Err().Error(), - RequestID: call.ID().Raw(), - }) - // By default, the jsonrpc2 library waits for graceful shutdown when the - // connection is closed, meaning it expects all outgoing and incoming - // requests to complete. However, for MCP this expectation is unrealistic, - // and can lead to hanging shutdown. For example, if a streamable client is - // killed, the server will not be able to detect this event, except via - // keepalive pings (if they are configured), and so outgoing calls may hang - // indefinitely. - // - // Therefore, we choose to eagerly retire calls, removing them from the - // outgoingCalls map, when the caller context is cancelled: if the caller - // will never receive the response, there's no need to track it. - conn.Retire(call, ctx.Err()) + err := cancelCall(ctx, conn, call) return errors.Join(ctx.Err(), err) case err != nil: return fmt.Errorf("calling %q: %w", method, err) @@ -265,6 +284,30 @@ func call(ctx context.Context, conn *jsonrpc2.Connection, method string, params return nil } +// cancelCall sends a "notifications/cancelled" notification for call and eagerly +// retires it from conn. +// +// By default, the jsonrpc2 library waits for graceful shutdown when the +// connection is closed, meaning it expects all outgoing and incoming requests +// to complete. However, for MCP this expectation is unrealistic, and can lead +// to hanging shutdown. For example, if a streamable client is killed, the +// server will not be able to detect this event, except via keepalive pings (if +// they are configured), and so outgoing calls may hang indefinitely. +// +// Therefore, we choose to eagerly retire calls, removing them from the +// outgoingCalls map, when the caller context is cancelled: if the caller will +// never receive the response, there's no need to track it. +func cancelCall(ctx context.Context, conn *jsonrpc2.Connection, call *jsonrpc2.AsyncCall) error { + notifyCtx, cancelNotify := context.WithTimeout(context.WithoutCancel(ctx), notifyCancellationTimeout) + defer cancelNotify() + err := conn.Notify(notifyCtx, notificationCancelled, &CancelledParams{ + Reason: ctx.Err().Error(), + RequestID: call.ID().Raw(), + }) + conn.Retire(call, ctx.Err()) + return err +} + // A LoggingTransport is a [Transport] that delegates to another transport, // writing RPC logs to an io.Writer. type LoggingTransport struct { From b6649db4f925e6271fc2cc50a51032017be7c61f Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Tue, 23 Jun 2026 11:11:08 +0200 Subject: [PATCH 312/418] mcp: Deprecate roots, sampling and logging (SEP-2577) (#1017) Fixes #1013 --- README.md | 7 ++ conformance/everything-server/main.go | 2 + docs/client.md | 14 +++ docs/server.md | 7 ++ examples/server/distributed/main.go | 2 + examples/server/everything/main.go | 2 + internal/docs/client.src.md | 14 +++ internal/docs/server.src.md | 7 ++ internal/readme/README.src.md | 7 ++ mcp/capabilities_test.go | 2 + mcp/client.go | 47 ++++++++- mcp/client_example_test.go | 2 + mcp/client_test.go | 2 + mcp/content.go | 10 ++ mcp/logging.go | 15 +++ mcp/mcp_test.go | 2 + mcp/mrtr_test.go | 2 + mcp/protocol.go | 146 +++++++++++++++++++++++++- mcp/sampling_test.go | 2 + mcp/server.go | 49 ++++++--- mcp/server_example_test.go | 2 + mcp/streamable_test.go | 2 + 22 files changed, 320 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index ffaf33e..f9bf4eb 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,13 @@ The following table shows which versions of the Go SDK support which versions of \*\* Partial support for 2025-11-25 (client side OAuth and Sampling with tools not available). +The roots, sampling, and logging features are deprecated as of protocol version +2026-07-28 by +[SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging). +The SDK continues to support them for compatibility during the deprecation +window (at least twelve months). See the individual feature documentation for +migration guidance. + New releases of the SDK target only supported versions of Go. See https://go.dev/doc/devel/release#policy for more information. diff --git a/conformance/everything-server/main.go b/conformance/everything-server/main.go index 70bd4bc..b815b56 100644 --- a/conformance/everything-server/main.go +++ b/conformance/everything-server/main.go @@ -5,6 +5,8 @@ // The conformance server implements features required for MCP conformance testing. // It mirrors the functionality of the TypeScript conformance server at // https://github.com/modelcontextprotocol/conformance/blob/main/examples/servers/typescript/everything-server.ts +// +//lint:file-ignore SA1019 conformance server exercises deprecated SEP-2577 APIs (roots, sampling, logging). package main import ( diff --git a/docs/client.md b/docs/client.md index 565fae9..cb96ff9 100644 --- a/docs/client.md +++ b/docs/client.md @@ -10,6 +10,13 @@ ## Roots +> **Note:** The roots feature is deprecated as of protocol version 2026-07-28 +> ([SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging)). +> It remains fully functional during the deprecation window (at least twelve +> months). The SDK continues to support roots for compatibility. New code +> should pass paths via tool parameters, resource URIs, or configuration +> instead. + MCP allows clients to specify a set of filesystem ["roots"](https://modelcontextprotocol.io/specification/2025-06-18/client/roots). The SDK supports this as follows: @@ -77,6 +84,13 @@ func Example_roots() { ## Sampling +> **Note:** The sampling feature is deprecated as of protocol version +> 2026-07-28 +> ([SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging)). +> It remains fully functional during the deprecation window (at least twelve +> months). The SDK continues to support sampling for compatibility. Servers +> that need LLM completions should call LLM provider APIs directly. + [Sampling](https://modelcontextprotocol.io/specification/2025-06-18/client/sampling) is a way for servers to leverage the client's AI capabilities. It is implemented in the SDK as follows: diff --git a/docs/server.md b/docs/server.md index 8fe6bd3..a24a58e 100644 --- a/docs/server.md +++ b/docs/server.md @@ -474,6 +474,13 @@ _ = mcp.NewServer(&mcp.Implementation{Name: "server"}, &mcp.ServerOptions{ ### Logging +> **Note:** The logging feature is deprecated as of protocol version +> 2026-07-28 +> ([SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging)). +> It remains fully functional during the deprecation window (at least twelve +> months). The SDK continues to support logging for compatibility. Servers +> should migrate to stderr logging (for STDIO transports) or OpenTelemetry. + MCP servers can send logging messages to MCP clients. (This form of logging is distinct from server-side logging, where the server produces logs that remain server-side, for use by server maintainers.) diff --git a/examples/server/distributed/main.go b/examples/server/distributed/main.go index 0e5dfe5..888cba5 100644 --- a/examples/server/distributed/main.go +++ b/examples/server/distributed/main.go @@ -16,6 +16,8 @@ // Example: // // ./distributed -http=localhost:8080 -child_ports=8081,8082 +// +//lint:file-ignore SA1019 example server exercises deprecated SEP-2577 logging API for demonstration. package main import ( diff --git a/examples/server/everything/main.go b/examples/server/everything/main.go index e7634ce..a08cac8 100644 --- a/examples/server/everything/main.go +++ b/examples/server/everything/main.go @@ -3,6 +3,8 @@ // license that can be found in the LICENSE file. // The everything server implements all supported features of an MCP server. +// +//lint:file-ignore SA1019 example server exercises deprecated SEP-2577 APIs (roots, sampling, logging) for demonstration. package main import ( diff --git a/internal/docs/client.src.md b/internal/docs/client.src.md index f6ae786..213078d 100644 --- a/internal/docs/client.src.md +++ b/internal/docs/client.src.md @@ -4,6 +4,13 @@ ## Roots +> **Note:** The roots feature is deprecated as of protocol version 2026-07-28 +> ([SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging)). +> It remains fully functional during the deprecation window (at least twelve +> months). The SDK continues to support roots for compatibility. New code +> should pass paths via tool parameters, resource URIs, or configuration +> instead. + MCP allows clients to specify a set of filesystem ["roots"](https://modelcontextprotocol.io/specification/2025-06-18/client/roots). The SDK supports this as follows: @@ -26,6 +33,13 @@ method. To receive notifications about root changes, set ## Sampling +> **Note:** The sampling feature is deprecated as of protocol version +> 2026-07-28 +> ([SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging)). +> It remains fully functional during the deprecation window (at least twelve +> months). The SDK continues to support sampling for compatibility. Servers +> that need LLM completions should call LLM provider APIs directly. + [Sampling](https://modelcontextprotocol.io/specification/2025-06-18/client/sampling) is a way for servers to leverage the client's AI capabilities. It is implemented in the SDK as follows: diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index 26dba5a..744015f 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -236,6 +236,13 @@ requests. ### Logging +> **Note:** The logging feature is deprecated as of protocol version +> 2026-07-28 +> ([SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging)). +> It remains fully functional during the deprecation window (at least twelve +> months). The SDK continues to support logging for compatibility. Servers +> should migrate to stderr logging (for STDIO transports) or OpenTelemetry. + MCP servers can send logging messages to MCP clients. (This form of logging is distinct from server-side logging, where the server produces logs that remain server-side, for use by server maintainers.) diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md index d419b02..84126ad 100644 --- a/internal/readme/README.src.md +++ b/internal/readme/README.src.md @@ -43,6 +43,13 @@ The following table shows which versions of the Go SDK support which versions of \*\* Partial support for 2025-11-25 (client side OAuth and Sampling with tools not available). +The roots, sampling, and logging features are deprecated as of protocol version +2026-07-28 by +[SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging). +The SDK continues to support them for compatibility during the deprecation +window (at least twelve months). See the individual feature documentation for +migration guidance. + New releases of the SDK target only supported versions of Go. See https://go.dev/doc/devel/release#policy for more information. diff --git a/mcp/capabilities_test.go b/mcp/capabilities_test.go index 01ff45f..b845616 100644 --- a/mcp/capabilities_test.go +++ b/mcp/capabilities_test.go @@ -2,6 +2,8 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. +//lint:file-ignore SA1019 tests exercise deprecated SEP-2577 APIs (roots, sampling, logging). + package mcp import ( diff --git a/mcp/client.go b/mcp/client.go index d8fd2c1..8ccbdfa 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -82,6 +82,12 @@ type ClientOptions struct { // &SamplingCapabilities{}. If [ClientOptions.Capabilities] is set and has a // non nil value for [ClientCapabilities.Sampling], that value overrides the // inferred capability. + // + // Deprecated: the sampling feature is deprecated as of protocol version + // 2026-07-28 (SEP-2577). It remains functional during the deprecation + // window (at least twelve months). Migrate to calling LLM provider APIs + // directly from your server. See + // https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. CreateMessageHandler func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) // CreateMessageWithToolsHandler handles incoming sampling/createMessage // requests that may involve tool use. It returns @@ -95,6 +101,12 @@ type ClientOptions struct { // // It is a panic to set both CreateMessageHandler and // CreateMessageWithToolsHandler. + // + // Deprecated: the sampling feature is deprecated as of protocol version + // 2026-07-28 (SEP-2577). It remains functional during the deprecation + // window (at least twelve months). Migrate to calling LLM provider APIs + // directly from your server. See + // https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. CreateMessageWithToolsHandler func(context.Context, *CreateMessageWithToolsRequest) (*CreateMessageWithToolsResult, error) // ElicitationHandler handles incoming requests for elicitation/create. // @@ -152,10 +164,17 @@ type ClientOptions struct { // ElicitationCompleteHandler handles incoming notifications for notifications/elicitation/complete. ElicitationCompleteHandler func(context.Context, *ElicitationCompleteNotificationRequest) // Handlers for notifications from the server. - ToolListChangedHandler func(context.Context, *ToolListChangedRequest) - PromptListChangedHandler func(context.Context, *PromptListChangedRequest) - ResourceListChangedHandler func(context.Context, *ResourceListChangedRequest) - ResourceUpdatedHandler func(context.Context, *ResourceUpdatedNotificationRequest) + ToolListChangedHandler func(context.Context, *ToolListChangedRequest) + PromptListChangedHandler func(context.Context, *PromptListChangedRequest) + ResourceListChangedHandler func(context.Context, *ResourceListChangedRequest) + ResourceUpdatedHandler func(context.Context, *ResourceUpdatedNotificationRequest) + // LoggingMessageHandler handles incoming notifications/message + // notifications from the server. + // + // Deprecated: the logging feature is deprecated as of protocol version + // 2026-07-28 (SEP-2577). It remains functional during the deprecation + // window (at least twelve months). See + // https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. LoggingMessageHandler func(context.Context, *LoggingMessageRequest) ProgressNotificationHandler func(context.Context, *ProgressNotificationClientRequest) // MultiRoundTrip configures the automatic MultiRoundTrip (Multi Round-Trip Requests) middleware. @@ -615,6 +634,12 @@ func (cs *ClientSession) startKeepalive(interval time.Duration) { // AddRoots adds the given roots to the client, // replacing any with the same URIs, // and notifies any connected servers. +// +// Deprecated: the roots feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). Migrate to passing paths via tool parameters, +// resource URIs, or configuration. See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. func (c *Client) AddRoots(roots ...*Root) { // Only notify if something could change. if len(roots) == 0 { @@ -627,6 +652,12 @@ func (c *Client) AddRoots(roots ...*Root) { // RemoveRoots removes the roots with the given URIs, // and notifies any connected servers if the list has changed. // It is not an error to remove a nonexistent root. +// +// Deprecated: the roots feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). Migrate to passing paths via tool parameters, +// resource URIs, or configuration. See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. func (c *Client) RemoveRoots(uris ...string) { changeAndNotify(c, notificationRootsListChanged, &RootsListChangedParams{}, func() bool { return c.roots.remove(uris...) }) @@ -1242,6 +1273,14 @@ func (cs *ClientSession) CallTool(ctx context.Context, params *CallToolParams) ( return handleSend[*CallToolResult](ctx, methodCallTool, newClientRequest(cs, orZero[Params](params))) } +// SetLoggingLevel sets the minimum severity level for log messages sent by +// the server. +// +// Deprecated: the logging feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). Migrate to consuming stderr output (for STDIO +// servers) or OpenTelemetry. See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. func (cs *ClientSession) SetLoggingLevel(ctx context.Context, params *SetLoggingLevelParams) error { _, err := handleSend[*emptyResult](ctx, methodSetLevel, newClientRequest(cs, orZero[Params](params))) return err diff --git a/mcp/client_example_test.go b/mcp/client_example_test.go index cc7146c..33b0a44 100644 --- a/mcp/client_example_test.go +++ b/mcp/client_example_test.go @@ -2,6 +2,8 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. +//lint:file-ignore SA1019 examples exercise deprecated SEP-2577 APIs (roots, sampling) for demonstration. + package mcp_test import ( diff --git a/mcp/client_test.go b/mcp/client_test.go index 5e3a94a..815f04c 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -2,6 +2,8 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. +//lint:file-ignore SA1019 tests exercise deprecated SEP-2577 APIs (roots, sampling, logging). + package mcp import ( diff --git a/mcp/content.go b/mcp/content.go index 39c25ff..7af90fe 100644 --- a/mcp/content.go +++ b/mcp/content.go @@ -189,6 +189,11 @@ func (c *EmbeddedResource) fromWire(wire *wireContent) { // ToolUseContent represents a request from the assistant to invoke a tool. // This content type is only valid in sampling messages. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type ToolUseContent struct { // ID is a unique identifier for this tool use, used to match with ToolResultContent. ID string @@ -229,6 +234,11 @@ func (c *ToolUseContent) fromWire(wire *wireContent) { // ToolResultContent represents the result of a tool invocation. // This content type is only valid in sampling messages with role "user". +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type ToolResultContent struct { // ToolUseID references the ID from the corresponding ToolUseContent. ToolUseID string diff --git a/mcp/logging.go b/mcp/logging.go index e77c6e1..402e243 100644 --- a/mcp/logging.go +++ b/mcp/logging.go @@ -69,6 +69,11 @@ func compareLevels(l1, l2 LoggingLevel) int { } // LoggingHandlerOptions are options for a LoggingHandler. +// +// Deprecated: the logging feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type LoggingHandlerOptions struct { // The value for the "logger" field of logging notifications. LoggerName string @@ -79,6 +84,11 @@ type LoggingHandlerOptions struct { } // A LoggingHandler is a [slog.Handler] for MCP. +// +// Deprecated: the logging feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type LoggingHandler struct { opts LoggingHandlerOptions ss *ServerSession @@ -101,6 +111,11 @@ func ensureLogger(l *slog.Logger) *slog.Logger { // NewLoggingHandler creates a [LoggingHandler] that logs to the given [ServerSession] using a // [slog.JSONHandler]. +// +// Deprecated: the logging feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. func NewLoggingHandler(ss *ServerSession, opts *LoggingHandlerOptions) *LoggingHandler { var buf bytes.Buffer jsonHandler := slog.NewJSONHandler(&buf, &slog.HandlerOptions{ diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 91cc57f..de12cbd 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -2,6 +2,8 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. +//lint:file-ignore SA1019 tests exercise deprecated SEP-2577 APIs (roots, sampling, logging). + package mcp import ( diff --git a/mcp/mrtr_test.go b/mcp/mrtr_test.go index c0335d8..a5b286f 100644 --- a/mcp/mrtr_test.go +++ b/mcp/mrtr_test.go @@ -2,6 +2,8 @@ // Use of this source code is governed by the license // that can be found in the LICENSE file. +//lint:file-ignore SA1019 tests exercise deprecated SEP-2577 APIs (roots, sampling, logging). + package mcp import ( diff --git a/mcp/protocol.go b/mcp/protocol.go index 3553925..f3c2efc 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -439,6 +439,11 @@ func (x *CancelledParams) GetProgressToken() any { return getProgressToken(x) } func (x *CancelledParams) SetProgressToken(t any) { setProgressToken(x, t) } // RootCapabilities describes a client's support for roots. +// +// Deprecated: the roots feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type RootCapabilities struct { // ListChanged reports whether the client supports notifications for // changes to the roots list. @@ -466,14 +471,27 @@ type ClientCapabilities struct { // Deprecated: use RootsV2. As described in #607, Roots should have been a // pointer to a RootCapabilities value. Roots will be continue to be // populated, but any new fields will only be added in the RootsV2 field. + // + // The roots feature itself is also deprecated by SEP-2577; see RootsV2. Roots struct { // ListChanged reports whether the client supports notifications for // changes to the roots list. ListChanged bool `json:"listChanged,omitempty"` } `json:"roots,omitempty"` - // RootsV2 is present if the client supports roots. When capabilities are explicitly configured via [ClientOptions.Capabilities] + // RootsV2 is present if the client supports roots. When capabilities are + // explicitly configured via [ClientOptions.Capabilities]. + // + // Deprecated: the roots feature is deprecated as of protocol version + // 2026-07-28 (SEP-2577). It remains functional during the deprecation + // window (at least twelve months). See + // https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. RootsV2 *RootCapabilities `json:"-"` // Sampling is present if the client supports sampling from an LLM. + // + // Deprecated: the sampling feature is deprecated as of protocol version + // 2026-07-28 (SEP-2577). It remains functional during the deprecation + // window (at least twelve months). See + // https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. Sampling *SamplingCapabilities `json:"sampling,omitempty"` // Elicitation is present if the client supports elicitation from the server. Elicitation *ElicitationCapabilities `json:"elicitation,omitempty"` @@ -641,6 +659,12 @@ type CompleteResult struct { func (*CompleteResult) isResult() {} +// CreateMessageParams holds parameters for a sampling/createMessage request. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type CreateMessageParams struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. @@ -648,10 +672,11 @@ type CreateMessageParams struct { // A request to include context from one or more MCP servers (including the // caller), to be attached to the prompt. The client may ignore this request. // - // The default is "none". Values "thisServer" and - // "allServers" are soft-deprecated. Servers SHOULD only use these values if - // the client declares ClientCapabilities.sampling.context. These values may - // be removed in future spec releases. + // The default is "none". The values "thisServer" and "allServers" are + // deprecated as of protocol version 2025-11-25 (SEP-2596) and will be + // removed no later than the sampling feature itself (SEP-2577). Servers + // SHOULD omit this field or use "none". See + // https://modelcontextprotocol.io/seps/2596-feature-lifecycle-and-deprecation-policy. IncludeContext string `json:"includeContext,omitempty"` // The maximum number of tokens to sample, as requested by the server. The // client may choose to sample fewer tokens than requested. @@ -681,6 +706,11 @@ func (x *CreateMessageParams) SetProgressToken(t any) { setProgressToken(x, t) } // and messages that support array content (for parallel tool calls). // // Use with [ServerSession.CreateMessageWithTools]. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type CreateMessageWithToolsParams struct { Meta `json:"_meta,omitempty"` IncludeContext string `json:"includeContext,omitempty"` @@ -741,6 +771,11 @@ func (p *CreateMessageWithToolsParams) toBase() (*CreateMessageParams, error) { // object for compatibility with pre-2025-11-25 implementations. When // unmarshaling, a single JSON content object is accepted and wrapped in a // one-element slice. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type SamplingMessageV2 struct { Content []Content `json:"content"` Role Role `json:"role"` @@ -782,6 +817,11 @@ func (m *SamplingMessageV2) UnmarshalJSON(data []byte) error { // The client should inform the user before returning the sampled message, to // allow them to inspect the response (human in the loop) and decide whether to // allow the server to see it. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type CreateMessageResult struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. @@ -828,6 +868,11 @@ func (r *CreateMessageResult) UnmarshalJSON(data []byte) error { // // When unmarshaling, a single JSON content object is accepted and wrapped in a // one-element slice, for compatibility with clients that return a single block. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type CreateMessageWithToolsResult struct { Meta `json:"_meta,omitempty"` Content []Content `json:"content"` @@ -1218,6 +1263,12 @@ type ListResourcesResult struct { func (x *ListResourcesResult) isResult() {} func (x *ListResourcesResult) nextCursorPtr() *string { return &x.NextCursor } +// ListRootsParams holds parameters for a roots/list request. +// +// Deprecated: the roots feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type ListRootsParams struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. @@ -1233,6 +1284,11 @@ func (x *ListRootsParams) SetProgressToken(t any) { setProgressToken(x, t) } // The client's response to a roots/list request from the server. This result // contains an array of Root objects, each representing a root directory or file // that the server can operate on. +// +// Deprecated: the roots feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type ListRootsResult struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. @@ -1277,8 +1333,20 @@ func (x *ListToolsResult) nextCursorPtr() *string { return &x.NextCursor } // // These map to syslog message severities, as specified in RFC-5424: // https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 +// +// Deprecated: the logging feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type LoggingLevel string +// LoggingMessageParams holds the parameters for a notifications/message +// notification. +// +// Deprecated: the logging feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type LoggingMessageParams struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. @@ -1301,6 +1369,11 @@ func (x *LoggingMessageParams) SetProgressToken(t any) { setProgressToken(x, t) // // Keys not declared here are currently left unspecified by the spec and are up // to the client to interpret. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type ModelHint struct { // A hint for a model name. // @@ -1327,6 +1400,11 @@ type ModelHint struct { // These preferences are always advisory. The client may ignore them. It is also // up to the client to decide how to interpret these preferences and how to // balance them against other considerations. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type ModelPreferences struct { // How much to prioritize cost when selecting a model. A value of 0 means cost // is not important, while a value of 1 means cost is the most important factor. @@ -1652,6 +1730,11 @@ type ResourceTemplate struct { type Role string // Represents a root directory or file that the server can operate on. +// +// Deprecated: the roots feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type Root struct { // See [specification/2025-06-18/basic/index#general-fields] for notes on _meta // usage. @@ -1666,6 +1749,13 @@ type Root struct { URI string `json:"uri"` } +// RootsListChangedParams holds parameters for a notifications/roots/list_changed +// notification. +// +// Deprecated: the roots feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type RootsListChangedParams struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. @@ -1681,6 +1771,11 @@ func (x *RootsListChangedParams) SetProgressToken(t any) { setProgressToken(x, t // below directly above ClientCapabilities. // SamplingCapabilities describes the client's support for sampling. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type SamplingCapabilities struct { // Context indicates the client supports includeContext values other than "none". Context *SamplingContextCapabilities `json:"context,omitempty"` @@ -1689,12 +1784,27 @@ type SamplingCapabilities struct { } // SamplingContextCapabilities indicates the client supports context inclusion. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type SamplingContextCapabilities struct{} // SamplingToolsCapabilities indicates the client supports tool use in sampling. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type SamplingToolsCapabilities struct{} // ToolChoice controls how the model uses tools during sampling. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type ToolChoice struct { // Mode controls tool invocation behavior: // - "auto": Model decides whether to use tools (default) @@ -1721,6 +1831,11 @@ type URLElicitationCapabilities struct{} // // For assistant messages, Content may be text, image, audio, or tool_use. // For user messages, Content may be text, image, audio, or tool_result. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type SamplingMessage struct { Content Content `json:"content"` Role Role `json:"role"` @@ -1746,6 +1861,12 @@ func (m *SamplingMessage) UnmarshalJSON(data []byte) error { return nil } +// SetLoggingLevelParams holds parameters for a logging/setLevel request. +// +// Deprecated: the logging feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type SetLoggingLevelParams struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. @@ -2071,6 +2192,11 @@ type Implementation struct { type CompletionCapabilities struct{} // LoggingCapabilities describes the server's support for sending log messages to the client. +// +// Deprecated: the logging feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. type LoggingCapabilities struct{} // PromptCapabilities describes the server's support for prompts. @@ -2114,6 +2240,11 @@ type ServerCapabilities struct { // suggestions. Completions *CompletionCapabilities `json:"completions,omitempty"` // Logging is present if the server supports log messages. + // + // Deprecated: the logging feature is deprecated as of protocol version + // 2026-07-28 (SEP-2577). It remains functional during the deprecation + // window (at least twelve months). See + // https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. Logging *LoggingCapabilities `json:"logging,omitempty"` // Prompts is present if the server supports prompts. Prompts *PromptCapabilities `json:"prompts,omitempty"` @@ -2196,6 +2327,11 @@ const ( // MetaKeyClientCapabilities carries the client's [ClientCapabilities]. MetaKeyClientCapabilities = "io.modelcontextprotocol/clientCapabilities" // MetaKeyLogLevel identifies the desired log level for the request. + // + // Deprecated: the logging feature is deprecated as of protocol version + // 2026-07-28 (SEP-2577). It remains functional during the deprecation + // window (at least twelve months). See + // https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. MetaKeyLogLevel = "io.modelcontextprotocol/logLevel" // MetaKeySubscriptionID identifies the subscriptions/listen request that an // out-of-band notification belongs to. diff --git a/mcp/sampling_test.go b/mcp/sampling_test.go index bdffd45..79c8213 100644 --- a/mcp/sampling_test.go +++ b/mcp/sampling_test.go @@ -2,6 +2,8 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. +//lint:file-ignore SA1019 tests exercise deprecated SEP-2577 sampling APIs. + package mcp // TODO: move other sampling-related tests to this file. diff --git a/mcp/server.go b/mcp/server.go index 011ea56..be63b9f 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -73,6 +73,12 @@ type ServerOptions struct { // If zero, defaults to [DefaultPageSize]. PageSize int // If non-nil, called when "notifications/roots/list_changed" is received. + // + // Deprecated: the roots feature is deprecated as of protocol version + // 2026-07-28 (SEP-2577). It remains functional during the deprecation + // window (at least twelve months). Migrate to passing paths via tool + // parameters, resource URIs, or configuration. See + // https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. RootsListChangedHandler func(context.Context, *RootsListChangedRequest) // If non-nil, called when "notifications/progress" is received. ProgressNotificationHandler func(context.Context, *ProgressNotificationServerRequest) @@ -1474,6 +1480,12 @@ func (ss *ServerSession) Ping(ctx context.Context, params *PingParams) error { } // ListRoots lists the client roots. +// +// Deprecated: the roots feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). Migrate to passing paths via tool parameters, +// resource URIs, or configuration. See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. func (ss *ServerSession) ListRoots(ctx context.Context, params *ListRootsParams) (*ListRootsResult, error) { if err := ss.checkInitialized(methodListRoots); err != nil { return nil, err @@ -1486,6 +1498,12 @@ func (ss *ServerSession) ListRoots(ctx context.Context, params *ListRootsParams) // If the client returns multiple content blocks (e.g. parallel tool calls), // CreateMessage returns an error. Use [ServerSession.CreateMessageWithTools] // for tool-enabled sampling. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). Migrate to calling LLM provider APIs directly +// from your server. See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. func (ss *ServerSession) CreateMessage(ctx context.Context, params *CreateMessageParams) (*CreateMessageResult, error) { if err := ss.checkInitialized(methodCreateMessage); err != nil { return nil, err @@ -1523,6 +1541,12 @@ func (ss *ServerSession) CreateMessage(ctx context.Context, params *CreateMessag // returning a [CreateMessageWithToolsResult] that supports array content // (for parallel tool calls). Use this instead of [ServerSession.CreateMessage] // when the request includes tools. +// +// Deprecated: the sampling feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). Migrate to calling LLM provider APIs directly +// from your server. See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. func (ss *ServerSession) CreateMessageWithTools(ctx context.Context, params *CreateMessageWithToolsParams) (*CreateMessageWithToolsResult, error) { if err := ss.checkInitialized(methodCreateMessage); err != nil { return nil, err @@ -1609,28 +1633,21 @@ func (ss *ServerSession) Elicit(ctx context.Context, params *ElicitParams) (*Eli return res, nil } -// logLevelContextKey carries the per-request log level from -// [ServerSession.handle] to [ServerSession.Log] for new-protocol -// (>= 2026-07-28) requests. The level is scoped to a single in-flight request -// — including handler goroutines that call [ServerSession.Log] concurrently — -// rather than to the session, which avoids races between concurrent requests -// and aligns with SEP-2575's per-request opt-in model. The value type is -// [LoggingLevel]; an empty string means the request opted out of log messages. -type logLevelContextKey struct{} - // Log sends a log message to the client. // // For new-protocol (>= 2026-07-28) requests, the level is taken from the // originating request's `_meta` field (SEP-2575); an absent or empty value // suppresses the message per spec. For old-protocol requests, the level is // taken from the session state set via `logging/setLevel`. +// +// Deprecated: the logging feature is deprecated as of protocol version +// 2026-07-28 (SEP-2577). It remains functional during the deprecation window +// (at least twelve months). See +// https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. func (ss *ServerSession) Log(ctx context.Context, params *LoggingMessageParams) error { - logLevel, ok := ctx.Value(logLevelContextKey{}).(LoggingLevel) - if !ok { - ss.mu.Lock() - logLevel = ss.state.LogLevel - ss.mu.Unlock() - } + ss.mu.Lock() + logLevel := ss.state.LogLevel + ss.mu.Unlock() if logLevel == "" { // The spec is unclear, but seems to imply that no log messages are sent until the client // sets the level. @@ -1806,7 +1823,7 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, ctx = context.WithValue(ctx, idContextKey{}, req.ID) // For new-protocol requests, propagate the per-request log level. if validatedMeta.usesNewProtocol { - ctx = context.WithValue(ctx, logLevelContextKey{}, validatedMeta.logLevel) + ss.setLevel(ctx, &SetLoggingLevelParams{Level: validatedMeta.logLevel}) } return handleReceive(ctx, ss, req) } diff --git a/mcp/server_example_test.go b/mcp/server_example_test.go index db04920..808be5a 100644 --- a/mcp/server_example_test.go +++ b/mcp/server_example_test.go @@ -2,6 +2,8 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. +//lint:file-ignore SA1019 examples exercise deprecated SEP-2577 logging APIs for demonstration. + package mcp_test import ( diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index b5c0eb9..25d3625 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -2,6 +2,8 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. +//lint:file-ignore SA1019 tests exercise deprecated SEP-2577 APIs (roots, sampling, logging). + package mcp import ( From 01847b35b3cc61fcbc96ed9b6176ff41ed91472e Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Tue, 23 Jun 2026 11:50:33 +0200 Subject: [PATCH 313/418] mcp: refactor notification of subscribed sessions (#1018) This PR improves the notification of subscribed sessions removing duplicated part --- mcp/server.go | 53 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index be63b9f..bd0588b 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -726,18 +726,31 @@ func (s *Server) notifySessions(n string) { } s.mu.Unlock() // Don't hold the lock during notification: it causes deadlock. + // Notify legacy sessions on the shared session channel regardless of + // their subscription status. notifySessions(legacySessions, n, changeNotificationParams[n](), s.opts.Logger) - // Sessions receive the notification only if they opened a - // subscriptions/listen stream that opted in to this notification type. + // Notify modern sessions only if they have an active subscription for + // this notification type. + s.notifySubscribedSessions(subscribers, n, changeNotificationParams[n]) +} + +// notifySubscribedSessions delivers a list-changed or resource-updated +// notification to each session in subscribers, stamping the session's +// listen-request ID into the params' _meta so the receiving client can demultiplex +// notifications belonging to different concurrent listens. +func (s *Server) notifySubscribedSessions(subscribers map[*ServerSession]jsonrpc.ID, method string, makeParams func() Params) { + if len(subscribers) == 0 { + return + } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() for sess, reqID := range subscribers { - params := changeNotificationParams[n]() + params := makeParams() injectMetaSubscriptionID(params, reqID) req := newRequest(sess, params) - if err := handleNotify(ctx, n, req); err != nil { - s.opts.Logger.Warn(fmt.Sprintf("calling %s: %v", n, err)) + if err := handleNotify(ctx, method, req); err != nil { + s.opts.Logger.Warn(fmt.Sprintf("calling %s: %v", method, err)) } } } @@ -1067,32 +1080,28 @@ func fileResourceHandler(dir string) ResourceHandler { func (s *Server) ResourceUpdated(ctx context.Context, params *ResourceUpdatedNotificationParams) error { s.mu.Lock() subscribedSessions := s.resourceSubscriptions[params.URI] - sessions := slices.Collect(maps.Keys(subscribedSessions)) - s.mu.Unlock() // Only add legacy sessions for the notification, new ones use the new notification mechanism. var legacySessions []*ServerSession - var newSessions []*ServerSession - for _, sess := range sessions { + newSessions := make(map[*ServerSession]jsonrpc.ID) + for sess, reqID := range subscribedSessions { if sess.InitializeParams().isNil() || sess.InitializeParams().ProtocolVersion < protocolVersion20260728 { legacySessions = append(legacySessions, sess) } else { - newSessions = append(newSessions, sess) + newSessions[sess] = reqID } } - notifySessions(legacySessions, notificationResourceUpdated, params, s.opts.Logger) - s.opts.Logger.Info("resource updated notification sent", "uri", params.URI, "subscriber_count", len(sessions)) + s.mu.Unlock() - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - for _, sess := range newSessions { - reqID := subscribedSessions[sess] + notifySessions(legacySessions, notificationResourceUpdated, params, s.opts.Logger) + + // Notify modern sessions, injecting the per-session subscription ID into the notification's metadata. + s.notifySubscribedSessions(newSessions, notificationResourceUpdated, func() Params { p := *params - injectMetaSubscriptionID(&p, reqID) - req := newRequest(sess, &p) - if err := handleNotify(ctx, notificationResourceUpdated, req); err != nil { - s.opts.Logger.Warn(fmt.Sprintf("calling %s: %v", notificationResourceUpdated, err)) - } - } + return &p + }) + s.opts.Logger.Info("resource updated notification sent", + "uri", params.URI, + "subscriber_count", len(legacySessions)+len(newSessions)) return nil } From b9e6fc5a66b3c46454960267e27feda62091ce34 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Tue, 23 Jun 2026 17:07:38 +0200 Subject: [PATCH 314/418] mcp: add documentation for new protocol version (#1019) Add documentation for new protocol version in the `internal/docs` --- README.md | 11 ++- docs/client.md | 55 +++++++++++ docs/protocol.md | 145 ++++++++++++++++++++++++++--- docs/server.md | 169 ++++++++++++++++++++++++++++++++++ internal/docs/client.src.md | 53 +++++++++++ internal/docs/protocol.src.md | 142 +++++++++++++++++++++++++--- internal/docs/server.src.md | 82 +++++++++++++++++ internal/readme/README.src.md | 11 ++- mcp/server_example_test.go | 87 +++++++++++++++++ 9 files changed, 719 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index f9bf4eb..baeb3e7 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,12 @@ contains feature documentation, mapping the MCP spec to the packages above. The following table shows which versions of the Go SDK support which versions of the MCP specification: -| SDK Version | Latest MCP Spec | All Supported MCP Specs | -|-----------------|-------------------|----------------------------------------------------| -| v1.4.0+ | 2025-11-25\* | 2025-11-25\*, 2025-06-18, 2025-03-26, 2024-11-05 | -| v1.2.0 - v1.3.1 | 2025-11-25\*\* | 2025-11-25\*\*, 2025-06-18, 2025-03-26, 2024-11-05 | -| v1.0.0 - v1.1.0 | 2025-06-18 | 2025-06-18, 2025-03-26, 2024-11-05 | +| SDK Version | Latest MCP Spec | All Supported MCP Specs | +|-----------------|-------------------|------------------------------------------------------------------| +| v1.7.0+ | 2026-07-28 | 2026-07-28, 2025-11-25\*, 2025-06-18, 2025-03-26, 2024-11-05 | +| v1.4.0 - v1.6.1 | 2025-11-25\* | 2025-11-25\*, 2025-06-18, 2025-03-26, 2024-11-05 | +| v1.2.0 - v1.3.1 | 2025-11-25\*\* | 2025-11-25\*\*, 2025-06-18, 2025-03-26, 2024-11-05 | +| v1.0.0 - v1.1.0 | 2025-06-18 | 2025-06-18, 2025-03-26, 2024-11-05 | \* Client side OAuth has experimental support. diff --git a/docs/client.md b/docs/client.md index cb96ff9..b5d0032 100644 --- a/docs/client.md +++ b/docs/client.md @@ -4,9 +4,11 @@ 1. [Roots](#roots) 1. [Sampling](#sampling) 1. [Elicitation](#elicitation) +1. [Multi Round-Trip Requests (MRTR)](#multi-round-trip-requests-(mrtr)) 1. [Capabilities](#capabilities) 1. [Capability inference](#capability-inference) 1. [Explicit capabilities](#explicit-capabilities) + 1. [Extensions](#extensions) ## Roots @@ -34,6 +36,10 @@ client, a call to `AddRoot` or `RemoveRoots` will result in a [`ServerSession.ListRoots`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.ListRoots) method. To receive notifications about root changes, set [`ServerOptions.RootsListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.RootsListChangedHandler). +For protocol versions `2026-07-28` and later, `ListRoots` requests are +delivered via the +[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr) +pattern. ```go func Example_roots() { @@ -102,6 +108,11 @@ This function is invoked whenever the server requests sampling. **Server-side**: To use sampling from the server, call [`ServerSession.CreateMessage`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.CreateMessage). +For protocol versions `2026-07-28` and later, sampling requests are +delivered via the +[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr) +pattern. + ```go func Example_sampling() { ctx := context.Background() @@ -154,6 +165,11 @@ you must declare that capability explicitly (see [Capabilities](#capabilities)) **Server-side**: To use elicitation from the server, call [`ServerSession.Elicit`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.Elicit). +For protocol versions `2026-07-28` and later, elicitation requests are +delivered via the +[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr) +pattern. + ```go func Example_elicitation() { ctx := context.Background() @@ -191,6 +207,37 @@ func Example_elicitation() { } ``` +## Multi Round-Trip Requests (MRTR) + +[SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322) +introduces the MRTR pattern: server-to-client requests for sampling, +elicitation, and roots are no longer issued as fresh JSON-RPC requests but +are carried inside the in-flight reply of a `tools/call`, `prompts/get`, or +`resources/read`. The client must respond by retrying the original request +with the produced responses. + +The SDK installs `clientMultiRoundTripMiddleware` for every client by +default. The middleware: + +1. Inspects each `tools/call`/`prompts/get`/`resources/read` reply. +2. If the result's `NeedsInput()` is true, fans out the `InputRequests` map + concurrently, calling the configured handler for each (`elicit`, + `createMessage`/`createMessageWithTools`, or `listRoots`). +3. Threads the server-supplied opaque `RequestState` back unchanged. +4. Retries the original request with the responses set, repeating until the + result no longer needs input. + +The middleware is enabled by default. To opt out, set +[`ClientOptions.MultiRoundTrip.Disabled = true`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#MultiRoundTripOptions); +the client will then surface `InputRequiredResult` values directly to the +caller and your code must fulfil the requests manually. + +For legacy (`<= 2025-11-25`) servers, the SDK transparently sends server +requests on the legacy server-initiated channel; the MRTR machinery is a +no-op in that direction. For legacy clients talking to MRTR-style servers, +the server SDK applies the inverse compatibility shim — see the +[server-side documentation](server.md#multi-round-trip-requests-mrtr). + ## Capabilities Client capabilities are advertised to servers during the initialization @@ -247,3 +294,11 @@ client := mcp.NewClient(impl, &mcp.ClientOptions{ }) ``` +### Extensions + +[SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133) +adds an `extensions` map to `ClientCapabilities` and `ServerCapabilities` so +that optional capabilities outside the core protocol can be declared on the +wire. Keys are namespaced as `"{vendor-prefix}/{extension-name}"`; values +are per-extension settings objects. + diff --git a/docs/protocol.md b/docs/protocol.md index f45d1df..5f043f2 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -2,6 +2,8 @@ # Support for the MCP base protocol 1. [Lifecycle](#lifecycle) + 1. [Discovery (`server/discover`)](#discovery-(server/discover)) + 1. [Per-request `_meta` keys](#per-request-meta-keys) 1. [Transports](#transports) 1. [Stdio Transport](#stdio-transport) 1. [Streamable Transport](#streamable-transport) @@ -20,20 +22,27 @@ 1. [Cancellation](#cancellation) 1. [Ping](#ping) 1. [Progress](#progress) + 1. [Error codes](#error-codes) ## Lifecycle The SDK provides an API for defining both MCP clients and servers, and connecting them over various transports. When a client and server are -connected, it creates a logical session, which follows the MCP spec's -[lifecycle](https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle). +connected, it creates a logical session. -In this SDK, both a -[`Client`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Client) -and -[`Server`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server) -can handle multiple peers. Every time a new peer is connected, it creates a new -session. +The MCP specification defines two lifecycle models, and the SDK supports both +transparently based on the negotiated protocol version: + +- The **legacy `initialize` handshake** + ([lifecycle](https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle)) + used by protocol versions through `2025-11-25`. +- A **stateless** model introduced in `2026-07-28` by + [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), + in which there is no `initialize`/`notifications/initialized` handshake, and + each request carries its protocol version, client identity, and client + capabilities in `_meta`. + +In both models, the SDK exposes the same API: - A `Client` is a logical MCP client, configured with various [`ClientOptions`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions). @@ -49,11 +58,16 @@ session. [`Server.Connect`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.Connect), it creates a [`ServerSession`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession). - This session is not initialized until the client sends the - `notifications/initialized` message. Use `ServerOptions.InitializedHandler` - to listen for this event, or just use the session through various feature - handlers (such as a `ToolHandler`). Requests to the server are rejected until - the client has initialized the session. + +In the legacy model, the server session is not considered initialized until +the client sends the `notifications/initialized` message. Use +`ServerOptions.InitializedHandler` to listen for this event, or just use the +session through various feature handlers (such as a `ToolHandler`). Requests +to the server are rejected until the client has initialized the session. + +In the stateless model (`2026-07-28`+), there is no handshake. The server +processes the first request the moment it arrives, validates the per-request +`_meta` fields. Both `ClientSession` and `ServerSession` have a `Close` method to terminate the session, and a `Wait` method to await session termination by the peer. Typically, @@ -98,6 +112,41 @@ func Example_lifecycle() { } ``` +### Discovery (`server/discover`) + +Introduced in `2026-07-28` by +[SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), +the `server/discover` RPC lets a client discover a server's supported +protocol versions, capabilities, and identity before issuing any other +request. Servers implementing `2026-07-28` MUST implement it. + +- **Server**: `Server.Connect` registers the + `server/discover` handler automatically; the response is computed from the + server's static configuration (capabilities, server info, instructions) and + the transport-filtered list of supported protocol versions. +- **Client**: `Client.Connect` calls `server/discover` first and + uses the result to negotiate a mutually supported version. If discovery + fails or the server does not support the latest version, the client falls back to the + legacy `initialize` handshake. + +### Per-request `_meta` keys + +When the negotiated protocol version is `2026-07-28` or later, every request +carries these keys inside its `_meta` map (constants live in +[`mcp/protocol.go`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#pkg-constants)): + +| Constant | Wire key | Type | +|---|---|---| +| `MetaKeyProtocolVersion` | `io.modelcontextprotocol/protocolVersion` | `string` | +| `MetaKeyClientInfo` | `io.modelcontextprotocol/clientInfo` | `*Implementation` | +| `MetaKeyClientCapabilities` | `io.modelcontextprotocol/clientCapabilities` | `*ClientCapabilities` | +| `MetaKeyLogLevel` | `io.modelcontextprotocol/logLevel` | `LoggingLevel` (deprecated by SEP-2577) | + +The client populates these keys automatically on every outgoing request. +Server-side handlers can read them +through `ServerRequest[P].ProtocolVersion()`, `ServerRequest[P].ClientInfo()`, +and `ServerRequest[P].ClientCapabilities()`. + ## Transports A @@ -188,6 +237,39 @@ client, err := mcp.Connect(ctx, transport, &mcp.ClientOptions{...}) The `StreamableClientTransport` handles the HTTP requests and communicates with the server using the streamable transport protocol. +#### HTTP Headers + +[SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243) +standardises a small set of MCP-specific HTTP headers, sent and validated by +the SDK only for sessions negotiated on protocol version `2026-07-28` or +later. + +| Header | Direction | Purpose | +|---|---|---| +| `Mcp-Protocol-Version` | request | Mirrors `_meta.io.modelcontextprotocol/protocolVersion` from the body | +| `Mcp-Session-Id` | request/response | Logical session identifier (removed in stateless mode) | +| `Mcp-Method` | request | Mirrors the JSON-RPC `method` field; mismatch ⇒ `-32020` | +| `Mcp-Name` | request | Mirrors the request's principal name (`tools/call.params.name`, `prompts/get.params.name`, `resources/read.params.uri`); mismatch ⇒ `-32020` | +| `Mcp-Param-{Header}` | request | Per-tool parameter passthrough; see below | + +The header-name set is exhaustive: for protocol versions earlier than +`2026-07-28`, only `Mcp-Protocol-Version` and `Mcp-Session-Id` are recognised. + +**Body↔header mirroring.** When `Mcp-Method` or `Mcp-Name` is present but +does not match the JSON-RPC body, the server returns +[`CodeHeaderMismatch`](#error-codes) (`-32020`). The same applies to a +mismatch between `Mcp-Protocol-Version` and the body's +`_meta.io.modelcontextprotocol/protocolVersion`. + +**`Mcp-Param-*` passthrough.** A tool may annotate properties of its +`InputSchema` with `x-mcp-header: ""`. When a client invokes that +tool over HTTP, the SDK serializes the matching argument(s) as +`Mcp-Param-` request headers (using `=?base64?...?=` encoding for +non-ASCII values). On the server, the headers are validated against the body +and either accepted or rejected with `-32020`. Properties so annotated must +be typed `string`, `integer`, or `boolean`; tools that fail validation at +registration time are silently dropped from `tools/list`. + #### Resumability and Redelivery By default, the streamable server does not support [resumability or @@ -201,6 +283,13 @@ value. The SDK provides a `MemoryEventStore` for testing or simple use cases; for production use it is generally advisable to use a more sophisticated implementation. +> **Note**: [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575) +> removes SSE stream resumability (`Last-Event-ID`, SSE event IDs) for protocol +> version `2026-07-28`. The SDK preserves the `EventStore` and `Last-Event-ID` +> code paths for backward compatibility with `2025-11-25` and earlier; on +> `2026-07-28` sessions, a broken response stream loses the in-flight request +> and the client must re-issue it as a new request with a new ID. + #### Stateless Mode The streamable server supports a _stateless mode_ by setting @@ -219,6 +308,10 @@ to see the logical session > modelcontextprotocol/modelcontextprotocol#1372, or > modelcontextprotocol/modelcontextprotocol#1442 for potential refinements. +> **Required for `2026-07-28`**: the streamable HTTP transport accepts +> requests at protocol version `2026-07-28` **only** when `Stateless = true`. +> Requests at that version against a non-stateless handler are rejected. + _See [examples/server/distributed](https://github.com/modelcontextprotocol/go-sdk/blob/main/examples/server/distributed/main.go) for an example using stateless mode to implement a server distributed across multiple processes._ @@ -597,6 +690,13 @@ session if the ping fails, set or [`ServerOptions.KeepAlive`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.KeepAlive). +> **Note**: `ping` is removed from the protocol as of `2026-07-28` by +> [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575). +> The SDK preserves the API for backward compatibility with legacy clients; +> when both peers negotiate `2026-07-28`, the server returns +> `MethodNotFound` (`-32601`) for `ping` and `KeepAlive` should not be +> enabled. + ### Progress [Progress](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress) @@ -658,3 +758,22 @@ func Example_progress() { } ``` +### Error codes + +The SDK uses the standard JSON-RPC base codes (parse error `-32700`, +invalid request `-32600`, method not found `-32601`, invalid params +`-32602`, internal error `-32603`) plus the following MCP-specific codes: + +| Constant | Code | Meaning | +|---|---|---| +| [`CodeHeaderMismatch`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CodeHeaderMismatch) | `-32020` | An MCP HTTP header (`Mcp-Method`, `Mcp-Name`, `Mcp-Protocol-Version`, or `Mcp-Param-*`) does not match the JSON-RPC body | +| [`CodeMissingRequiredClientCapabilities`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CodeMissingRequiredClientCapabilities) | `-32021` | Server requires client capabilities the client did not declare | +| [`CodeUnsupportedProtocolVersion`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CodeUnsupportedProtocolVersion) | `-32022` | Requested protocol version not supported. Data: `UnsupportedProtocolVersionData{Supported []string, Requested string}` | +| [`CodeResourceNotFound`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CodeResourceNotFound) | `-32602` | Resource URI not found (variable; was `-32002` before SEP-2164) | + +The error code allocation policy defined in `2026-07-28` +([SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575)) +partitions the JSON-RPC server-error range: + +- `-32000` to `-32019`: implementation-defined; existing SDK usage is grandfathered. +- `-32020` to `-32099`: reserved for the MCP specification. diff --git a/docs/server.md b/docs/server.md index a24a58e..e7ee307 100644 --- a/docs/server.md +++ b/docs/server.md @@ -4,12 +4,17 @@ 1. [Prompts](#prompts) 1. [Resources](#resources) 1. [Tools](#tools) +1. [Multi Round-Trip Requests (MRTR)](#multi-round-trip-requests-(mrtr)) + 1. [Talking to legacy clients](#talking-to-legacy-clients) + 1. [Example](#example) +1. [Cacheable list results](#cacheable-list-results) 1. [Utilities](#utilities) 1. [Completion](#completion) 1. [Logging](#logging) 1. [Capabilities](#capabilities) 1. [Capability inference](#capability-inference) 1. [Explicit capabilities](#explicit-capabilities) + 1. [Extensions](#extensions) 1. [Pagination](#pagination) ## Prompts @@ -424,6 +429,162 @@ func handleRequest(w http.ResponseWriter, r *http.Request) { } ``` +## Multi Round-Trip Requests (MRTR) + +[SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322) +defines a new pattern for server-to-client requests (sampling, elicitation, +roots). Instead of issuing a fresh JSON-RPC request mid-flight, the server +returns an `InputRequiredResult` from its in-flight handler — the +`InputRequests` field of `CallToolResult`, `GetPromptResult`, or +`ReadResourceResult` carries the requests for additional information. The +client responds by retrying the original call with `InputResponses` set. + +The SDK supports this pattern from both sides without requiring callers to +choose: + +- **Returning input requests from a handler.** Set `InputRequests` on the + result you return. + Leave `Content` / `StructuredContent` empty — returning + both at once is a server bug and the SDK returns `-32603 InternalError`. +- **Calling the legacy APIs.** `ServerSession.Elicit`, + `ServerSession.CreateMessage(WithTools)`, and `ServerSession.ListRoots` + remain available and work for both new and legacy clients. + +### Talking to legacy clients + +The server installs `serverMultiRoundTripMiddleware` automatically. For +clients on a protocol version earlier than `2026-07-28`, the middleware +intercepts any `InputRequiredResult` your handler returns, fulfils each +input request itself by calling the legacy server-initiated APIs +(`Elicit`, `CreateMessage`, `ListRoots`), and re-invokes your handler +exactly once with the responses already populated. This means a handler +written in the MRTR style works against both old and new clients without +code changes. + +### Example + +The following example shows a "greet" tool whose handler asks the user +for their name via elicitation before producing the final greeting. The +handler runs twice — once to issue the elicitation, once to consume the +response — but the client call site sees a single `CallTool` returning the +final result, because the SDK's MRTR middleware handles the round trip on +either side of the wire (depending on the negotiated protocol version). + +```go +// Example_mrtr demonstrates the [Multi Round-Trip Requests] pattern +// (SEP-2322). A tool handler signals "I need more information from the user" +// by returning a [mcp.CallToolResult] whose `InputRequests` field carries the +// requests for the additional information. Each request can be an +// [mcp.ElicitParams] (ask the user a question), +// [mcp.CreateMessageParams] (sample from the client's LLM), or +// [mcp.ListRootsParams] (list the client's roots). +// +// On protocol version `2026-07-28` and later, the client's +// `clientMultiRoundTripMiddleware` fulfils each input request from the +// configured handler and retries the original call transparently. On earlier +// protocol versions, the server's `serverMultiRoundTripMiddleware` performs +// the equivalent dance from the server side by calling the legacy +// server-to-client API (`ServerSession.Elicit`, `CreateMessage`, or +// `ListRoots`) and re-invoking the handler with the response. Either way, +// the user-facing client call site sees only the final result. +// +// [Multi Round-Trip Requests]: https://modelcontextprotocol.io/specification/draft/basic/patterns#multi-round-trip-requests +func Example_mrtr() { + ctx := context.Background() + + // Server: a "greet" tool that asks the user for their name before + // returning a greeting. The handler runs twice per logical call: + // once to issue the elicitation, once to consume the response. + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(s, &mcp.Tool{Name: "greet"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + // First call: ask the user for their name. The map key + // ("user_name") is a label the handler chooses; the client must + // echo it back in InputResponses. The wire-level method name + // ("elicitation/create") is derived from the value's Go type. + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{ + "user_name": &mcp.ElicitParams{ + Message: "What's your name?", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": {Type: "string"}, + }, + }, + }, + }, + // RequestState is an opaque token the client echoes back on + // the retry, letting the handler resume its work without + // per-session storage. + RequestState: "step=1", + }, nil, nil + } + // Retry: read the elicitation response and produce the final greeting. + name := req.Params.InputResponses["user_name"].(*mcp.ElicitResult).Content["name"].(string) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "Hello " + name}}, + }, nil, nil + }) + + // Client: declares an elicitation handler. The SDK's MRTR middleware + // uses it to fulfil any input request the tool handler returns. + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + ElicitationHandler: func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"name": "Ada"}}, nil + }, + }) + + ct, st := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, st, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + // Single call site. The MRTR round trip is invisible to the caller. + res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "greet"}) + if err != nil { + log.Fatal(err) + } + fmt.Println(res.Content[0].(*mcp.TextContent).Text) +} +``` + +## Cacheable list results + +[SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549) +adds `ttlMs` and `cacheScope` fields to the results of `tools/list`, +`prompts/list`, `resources/list`, `resources/templates/list`, and +`resources/read`. Both fields complement existing list +results: clients use `ttlMs` as a freshness hint to reduce polling, +and `cacheScope` (`"public"` or `"private"`) controls whether shared +intermediaries may cache the response. + +**Server-side defaults.** After paginating, the SDK sets `CacheScope = "public"` +and leaves `TTLMs = 0` (which the client cache treats as "immediately +stale"). A handler that wants its responses cached must set `TTLMs` +explicitly on the returned result. + +```go +mcp.AddTool(server, &mcp.Tool{Name: "expensive"}, func(...) (*mcp.CallToolResult, any, error) { + // ... +}) +// Override the default for tools/list: +server.AddSendingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + res, err := next(ctx, method, req) + if lr, ok := res.(*mcp.ListToolsResult); ok { + lr.TTLMs = 30_000 // 30s cache freshness hint + } + return res, err + } +}) +``` + ## Utilities ### Completion @@ -613,6 +774,14 @@ server := mcp.NewServer(impl, &mcp.ServerOptions{ **Deprecated**: The `HasPrompts`, `HasResources`, and `HasTools` fields on `ServerOptions` are deprecated. Use `Capabilities` instead. +### Extensions + +[SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133) +adds an `extensions` map to `ServerCapabilities` so that optional +capabilities outside the core protocol can be declared on the wire. Keys +are namespaced as `"{vendor-prefix}/{extension-name}"`; values are +per-extension settings objects. + ### Pagination Server-side feature lists may be diff --git a/internal/docs/client.src.md b/internal/docs/client.src.md index 213078d..7caa081 100644 --- a/internal/docs/client.src.md +++ b/internal/docs/client.src.md @@ -28,6 +28,10 @@ client, a call to `AddRoot` or `RemoveRoots` will result in a [`ServerSession.ListRoots`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.ListRoots) method. To receive notifications about root changes, set [`ServerOptions.RootsListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.RootsListChangedHandler). +For protocol versions `2026-07-28` and later, `ListRoots` requests are +delivered via the +[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr) +pattern. %include ../../mcp/client_example_test.go roots - @@ -51,6 +55,11 @@ This function is invoked whenever the server requests sampling. **Server-side**: To use sampling from the server, call [`ServerSession.CreateMessage`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.CreateMessage). +For protocol versions `2026-07-28` and later, sampling requests are +delivered via the +[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr) +pattern. + %include ../../mcp/client_example_test.go sampling - ## Elicitation @@ -68,8 +77,44 @@ you must declare that capability explicitly (see [Capabilities](#capabilities)) **Server-side**: To use elicitation from the server, call [`ServerSession.Elicit`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.Elicit). +For protocol versions `2026-07-28` and later, elicitation requests are +delivered via the +[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr) +pattern. + %include ../../mcp/client_example_test.go elicitation - +## Multi Round-Trip Requests (MRTR) + +[SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322) +introduces the MRTR pattern: server-to-client requests for sampling, +elicitation, and roots are no longer issued as fresh JSON-RPC requests but +are carried inside the in-flight reply of a `tools/call`, `prompts/get`, or +`resources/read`. The client must respond by retrying the original request +with the produced responses. + +The SDK installs `clientMultiRoundTripMiddleware` for every client by +default. The middleware: + +1. Inspects each `tools/call`/`prompts/get`/`resources/read` reply. +2. If the result's `NeedsInput()` is true, fans out the `InputRequests` map + concurrently, calling the configured handler for each (`elicit`, + `createMessage`/`createMessageWithTools`, or `listRoots`). +3. Threads the server-supplied opaque `RequestState` back unchanged. +4. Retries the original request with the responses set, repeating until the + result no longer needs input. + +The middleware is enabled by default. To opt out, set +[`ClientOptions.MultiRoundTrip.Disabled = true`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#MultiRoundTripOptions); +the client will then surface `InputRequiredResult` values directly to the +caller and your code must fulfil the requests manually. + +For legacy (`<= 2025-11-25`) servers, the SDK transparently sends server +requests on the legacy server-initiated channel; the MRTR machinery is a +no-op in that direction. For legacy clients talking to MRTR-style servers, +the server SDK applies the inverse compatibility shim — see the +[server-side documentation](server.md#multi-round-trip-requests-mrtr). + ## Capabilities Client capabilities are advertised to servers during the initialization @@ -126,3 +171,11 @@ client := mcp.NewClient(impl, &mcp.ClientOptions{ }) ``` +### Extensions + +[SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133) +adds an `extensions` map to `ClientCapabilities` and `ServerCapabilities` so +that optional capabilities outside the core protocol can be declared on the +wire. Keys are namespaced as `"{vendor-prefix}/{extension-name}"`; values +are per-extension settings objects. + diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index 4044a6f..5bdbac3 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -6,15 +6,21 @@ The SDK provides an API for defining both MCP clients and servers, and connecting them over various transports. When a client and server are -connected, it creates a logical session, which follows the MCP spec's -[lifecycle](https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle). +connected, it creates a logical session. -In this SDK, both a -[`Client`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Client) -and -[`Server`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server) -can handle multiple peers. Every time a new peer is connected, it creates a new -session. +The MCP specification defines two lifecycle models, and the SDK supports both +transparently based on the negotiated protocol version: + +- The **legacy `initialize` handshake** + ([lifecycle](https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle)) + used by protocol versions through `2025-11-25`. +- A **stateless** model introduced in `2026-07-28` by + [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), + in which there is no `initialize`/`notifications/initialized` handshake, and + each request carries its protocol version, client identity, and client + capabilities in `_meta`. + +In both models, the SDK exposes the same API: - A `Client` is a logical MCP client, configured with various [`ClientOptions`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions). @@ -30,11 +36,16 @@ session. [`Server.Connect`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.Connect), it creates a [`ServerSession`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession). - This session is not initialized until the client sends the - `notifications/initialized` message. Use `ServerOptions.InitializedHandler` - to listen for this event, or just use the session through various feature - handlers (such as a `ToolHandler`). Requests to the server are rejected until - the client has initialized the session. + +In the legacy model, the server session is not considered initialized until +the client sends the `notifications/initialized` message. Use +`ServerOptions.InitializedHandler` to listen for this event, or just use the +session through various feature handlers (such as a `ToolHandler`). Requests +to the server are rejected until the client has initialized the session. + +In the stateless model (`2026-07-28`+), there is no handshake. The server +processes the first request the moment it arrives, validates the per-request +`_meta` fields. Both `ClientSession` and `ServerSession` have a `Close` method to terminate the session, and a `Wait` method to await session termination by the peer. Typically, @@ -42,6 +53,41 @@ it is the client's responsibility to end the session. %include ../../mcp/mcp_example_test.go lifecycle - +### Discovery (`server/discover`) + +Introduced in `2026-07-28` by +[SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), +the `server/discover` RPC lets a client discover a server's supported +protocol versions, capabilities, and identity before issuing any other +request. Servers implementing `2026-07-28` MUST implement it. + +- **Server**: `Server.Connect` registers the + `server/discover` handler automatically; the response is computed from the + server's static configuration (capabilities, server info, instructions) and + the transport-filtered list of supported protocol versions. +- **Client**: `Client.Connect` calls `server/discover` first and + uses the result to negotiate a mutually supported version. If discovery + fails or the server does not support the latest version, the client falls back to the + legacy `initialize` handshake. + +### Per-request `_meta` keys + +When the negotiated protocol version is `2026-07-28` or later, every request +carries these keys inside its `_meta` map (constants live in +[`mcp/protocol.go`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#pkg-constants)): + +| Constant | Wire key | Type | +|---|---|---| +| `MetaKeyProtocolVersion` | `io.modelcontextprotocol/protocolVersion` | `string` | +| `MetaKeyClientInfo` | `io.modelcontextprotocol/clientInfo` | `*Implementation` | +| `MetaKeyClientCapabilities` | `io.modelcontextprotocol/clientCapabilities` | `*ClientCapabilities` | +| `MetaKeyLogLevel` | `io.modelcontextprotocol/logLevel` | `LoggingLevel` (deprecated by SEP-2577) | + +The client populates these keys automatically on every outgoing request. +Server-side handlers can read them +through `ServerRequest[P].ProtocolVersion()`, `ServerRequest[P].ClientInfo()`, +and `ServerRequest[P].ClientCapabilities()`. + ## Transports A @@ -112,6 +158,39 @@ client, err := mcp.Connect(ctx, transport, &mcp.ClientOptions{...}) The `StreamableClientTransport` handles the HTTP requests and communicates with the server using the streamable transport protocol. +#### HTTP Headers + +[SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243) +standardises a small set of MCP-specific HTTP headers, sent and validated by +the SDK only for sessions negotiated on protocol version `2026-07-28` or +later. + +| Header | Direction | Purpose | +|---|---|---| +| `Mcp-Protocol-Version` | request | Mirrors `_meta.io.modelcontextprotocol/protocolVersion` from the body | +| `Mcp-Session-Id` | request/response | Logical session identifier (removed in stateless mode) | +| `Mcp-Method` | request | Mirrors the JSON-RPC `method` field; mismatch ⇒ `-32020` | +| `Mcp-Name` | request | Mirrors the request's principal name (`tools/call.params.name`, `prompts/get.params.name`, `resources/read.params.uri`); mismatch ⇒ `-32020` | +| `Mcp-Param-{Header}` | request | Per-tool parameter passthrough; see below | + +The header-name set is exhaustive: for protocol versions earlier than +`2026-07-28`, only `Mcp-Protocol-Version` and `Mcp-Session-Id` are recognised. + +**Body↔header mirroring.** When `Mcp-Method` or `Mcp-Name` is present but +does not match the JSON-RPC body, the server returns +[`CodeHeaderMismatch`](#error-codes) (`-32020`). The same applies to a +mismatch between `Mcp-Protocol-Version` and the body's +`_meta.io.modelcontextprotocol/protocolVersion`. + +**`Mcp-Param-*` passthrough.** A tool may annotate properties of its +`InputSchema` with `x-mcp-header: ""`. When a client invokes that +tool over HTTP, the SDK serializes the matching argument(s) as +`Mcp-Param-` request headers (using `=?base64?...?=` encoding for +non-ASCII values). On the server, the headers are validated against the body +and either accepted or rejected with `-32020`. Properties so annotated must +be typed `string`, `integer`, or `boolean`; tools that fail validation at +registration time are silently dropped from `tools/list`. + #### Resumability and Redelivery By default, the streamable server does not support [resumability or @@ -125,6 +204,13 @@ value. The SDK provides a `MemoryEventStore` for testing or simple use cases; for production use it is generally advisable to use a more sophisticated implementation. +> **Note**: [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575) +> removes SSE stream resumability (`Last-Event-ID`, SSE event IDs) for protocol +> version `2026-07-28`. The SDK preserves the `EventStore` and `Last-Event-ID` +> code paths for backward compatibility with `2025-11-25` and earlier; on +> `2026-07-28` sessions, a broken response stream loses the in-flight request +> and the client must re-issue it as a new request with a new ID. + #### Stateless Mode The streamable server supports a _stateless mode_ by setting @@ -143,6 +229,10 @@ to see the logical session > modelcontextprotocol/modelcontextprotocol#1372, or > modelcontextprotocol/modelcontextprotocol#1442 for potential refinements. +> **Required for `2026-07-28`**: the streamable HTTP transport accepts +> requests at protocol version `2026-07-28` **only** when `Stateless = true`. +> Requests at that version against a non-stateless handler are rejected. + _See [examples/server/distributed](https://github.com/modelcontextprotocol/go-sdk/blob/main/examples/server/distributed/main.go) for an example using stateless mode to implement a server distributed across multiple processes._ @@ -463,6 +553,13 @@ session if the ping fails, set or [`ServerOptions.KeepAlive`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.KeepAlive). +> **Note**: `ping` is removed from the protocol as of `2026-07-28` by +> [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575). +> The SDK preserves the API for backward compatibility with legacy clients; +> when both peers negotiate `2026-07-28`, the server returns +> `MethodNotFound` (`-32601`) for `ping` and `KeepAlive` should not be +> enabled. + ### Progress [Progress](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress) @@ -480,3 +577,22 @@ Issue #460 discusses some potential ergonomic improvements to this API. %include ../../mcp/mcp_example_test.go progress - +### Error codes + +The SDK uses the standard JSON-RPC base codes (parse error `-32700`, +invalid request `-32600`, method not found `-32601`, invalid params +`-32602`, internal error `-32603`) plus the following MCP-specific codes: + +| Constant | Code | Meaning | +|---|---|---| +| [`CodeHeaderMismatch`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CodeHeaderMismatch) | `-32020` | An MCP HTTP header (`Mcp-Method`, `Mcp-Name`, `Mcp-Protocol-Version`, or `Mcp-Param-*`) does not match the JSON-RPC body | +| [`CodeMissingRequiredClientCapabilities`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CodeMissingRequiredClientCapabilities) | `-32021` | Server requires client capabilities the client did not declare | +| [`CodeUnsupportedProtocolVersion`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CodeUnsupportedProtocolVersion) | `-32022` | Requested protocol version not supported. Data: `UnsupportedProtocolVersionData{Supported []string, Requested string}` | +| [`CodeResourceNotFound`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#CodeResourceNotFound) | `-32602` | Resource URI not found (variable; was `-32002` before SEP-2164) | + +The error code allocation policy defined in `2026-07-28` +([SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575)) +partitions the JSON-RPC server-error range: + +- `-32000` to `-32019`: implementation-defined; existing SDK usage is grandfathered. +- `-32020` to `-32099`: reserved for the MCP specification. diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index 744015f..fa1599d 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -214,6 +214,80 @@ func handleRequest(w http.ResponseWriter, r *http.Request) { } ``` +## Multi Round-Trip Requests (MRTR) + +[SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322) +defines a new pattern for server-to-client requests (sampling, elicitation, +roots). Instead of issuing a fresh JSON-RPC request mid-flight, the server +returns an `InputRequiredResult` from its in-flight handler — the +`InputRequests` field of `CallToolResult`, `GetPromptResult`, or +`ReadResourceResult` carries the requests for additional information. The +client responds by retrying the original call with `InputResponses` set. + +The SDK supports this pattern from both sides without requiring callers to +choose: + +- **Returning input requests from a handler.** Set `InputRequests` on the + result you return. + Leave `Content` / `StructuredContent` empty — returning + both at once is a server bug and the SDK returns `-32603 InternalError`. +- **Calling the legacy APIs.** `ServerSession.Elicit`, + `ServerSession.CreateMessage(WithTools)`, and `ServerSession.ListRoots` + remain available and work for both new and legacy clients. + +### Talking to legacy clients + +The server installs `serverMultiRoundTripMiddleware` automatically. For +clients on a protocol version earlier than `2026-07-28`, the middleware +intercepts any `InputRequiredResult` your handler returns, fulfils each +input request itself by calling the legacy server-initiated APIs +(`Elicit`, `CreateMessage`, `ListRoots`), and re-invokes your handler +exactly once with the responses already populated. This means a handler +written in the MRTR style works against both old and new clients without +code changes. + +### Example + +The following example shows a "greet" tool whose handler asks the user +for their name via elicitation before producing the final greeting. The +handler runs twice — once to issue the elicitation, once to consume the +response — but the client call site sees a single `CallTool` returning the +final result, because the SDK's MRTR middleware handles the round trip on +either side of the wire (depending on the negotiated protocol version). + +%include ../../mcp/server_example_test.go mrtr - + +## Cacheable list results + +[SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549) +adds `ttlMs` and `cacheScope` fields to the results of `tools/list`, +`prompts/list`, `resources/list`, `resources/templates/list`, and +`resources/read`. Both fields complement existing list +results: clients use `ttlMs` as a freshness hint to reduce polling, +and `cacheScope` (`"public"` or `"private"`) controls whether shared +intermediaries may cache the response. + +**Server-side defaults.** After paginating, the SDK sets `CacheScope = "public"` +and leaves `TTLMs = 0` (which the client cache treats as "immediately +stale"). A handler that wants its responses cached must set `TTLMs` +explicitly on the returned result. + +```go +mcp.AddTool(server, &mcp.Tool{Name: "expensive"}, func(...) (*mcp.CallToolResult, any, error) { + // ... +}) +// Override the default for tools/list: +server.AddSendingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + res, err := next(ctx, method, req) + if lr, ok := res.(*mcp.ListToolsResult); ok { + lr.TTLMs = 30_000 // 30s cache freshness hint + } + return res, err + } +}) +``` + ## Utilities ### Completion @@ -318,6 +392,14 @@ server := mcp.NewServer(impl, &mcp.ServerOptions{ **Deprecated**: The `HasPrompts`, `HasResources`, and `HasTools` fields on `ServerOptions` are deprecated. Use `Capabilities` instead. +### Extensions + +[SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133) +adds an `extensions` map to `ServerCapabilities` so that optional +capabilities outside the core protocol can be declared on the wire. Keys +are namespaced as `"{vendor-prefix}/{extension-name}"`; values are +per-extension settings objects. + ### Pagination Server-side feature lists may be diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md index 84126ad..ce1f6fc 100644 --- a/internal/readme/README.src.md +++ b/internal/readme/README.src.md @@ -33,11 +33,12 @@ contains feature documentation, mapping the MCP spec to the packages above. The following table shows which versions of the Go SDK support which versions of the MCP specification: -| SDK Version | Latest MCP Spec | All Supported MCP Specs | -|-----------------|-------------------|----------------------------------------------------| -| v1.4.0+ | 2025-11-25\* | 2025-11-25\*, 2025-06-18, 2025-03-26, 2024-11-05 | -| v1.2.0 - v1.3.1 | 2025-11-25\*\* | 2025-11-25\*\*, 2025-06-18, 2025-03-26, 2024-11-05 | -| v1.0.0 - v1.1.0 | 2025-06-18 | 2025-06-18, 2025-03-26, 2024-11-05 | +| SDK Version | Latest MCP Spec | All Supported MCP Specs | +|-----------------|-------------------|------------------------------------------------------------------| +| v1.7.0+ | 2026-07-28 | 2026-07-28, 2025-11-25\*, 2025-06-18, 2025-03-26, 2024-11-05 | +| v1.4.0 - v1.6.1 | 2025-11-25\* | 2025-11-25\*, 2025-06-18, 2025-03-26, 2024-11-05 | +| v1.2.0 - v1.3.1 | 2025-11-25\*\* | 2025-11-25\*\*, 2025-06-18, 2025-03-26, 2024-11-05 | +| v1.0.0 - v1.1.0 | 2025-06-18 | 2025-06-18, 2025-03-26, 2024-11-05 | \* Client side OAuth has experimental support. diff --git a/mcp/server_example_test.go b/mcp/server_example_test.go index 808be5a..58d4eee 100644 --- a/mcp/server_example_test.go +++ b/mcp/server_example_test.go @@ -13,6 +13,7 @@ import ( "log/slog" "sync/atomic" + "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -220,3 +221,89 @@ func Example_resources() { } // !-resources + +// !+mrtr + +// Example_mrtr demonstrates the [Multi Round-Trip Requests] pattern +// (SEP-2322). A tool handler signals "I need more information from the user" +// by returning a [mcp.CallToolResult] whose `InputRequests` field carries the +// requests for the additional information. Each request can be an +// [mcp.ElicitParams] (ask the user a question), +// [mcp.CreateMessageParams] (sample from the client's LLM), or +// [mcp.ListRootsParams] (list the client's roots). +// +// On protocol version `2026-07-28` and later, the client's +// `clientMultiRoundTripMiddleware` fulfils each input request from the +// configured handler and retries the original call transparently. On earlier +// protocol versions, the server's `serverMultiRoundTripMiddleware` performs +// the equivalent dance from the server side by calling the legacy +// server-to-client API (`ServerSession.Elicit`, `CreateMessage`, or +// `ListRoots`) and re-invoking the handler with the response. Either way, +// the user-facing client call site sees only the final result. +// +// [Multi Round-Trip Requests]: https://modelcontextprotocol.io/specification/draft/basic/patterns#multi-round-trip-requests +func Example_mrtr() { + ctx := context.Background() + + // Server: a "greet" tool that asks the user for their name before + // returning a greeting. The handler runs twice per logical call: + // once to issue the elicitation, once to consume the response. + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(s, &mcp.Tool{Name: "greet"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + // First call: ask the user for their name. The map key + // ("user_name") is a label the handler chooses; the client must + // echo it back in InputResponses. The wire-level method name + // ("elicitation/create") is derived from the value's Go type. + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{ + "user_name": &mcp.ElicitParams{ + Message: "What's your name?", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": {Type: "string"}, + }, + }, + }, + }, + // RequestState is an opaque token the client echoes back on + // the retry, letting the handler resume its work without + // per-session storage. + RequestState: "step=1", + }, nil, nil + } + // Retry: read the elicitation response and produce the final greeting. + name := req.Params.InputResponses["user_name"].(*mcp.ElicitResult).Content["name"].(string) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "Hello " + name}}, + }, nil, nil + }) + + // Client: declares an elicitation handler. The SDK's MRTR middleware + // uses it to fulfil any input request the tool handler returns. + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + ElicitationHandler: func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"name": "Ada"}}, nil + }, + }) + + ct, st := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, st, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + // Single call site. The MRTR round trip is invisible to the caller. + res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "greet"}) + if err != nil { + log.Fatal(err) + } + fmt.Println(res.Content[0].(*mcp.TextContent).Text) +} + +// !-mrtr From 7f4aa4a0cec84f363748f5e5053ef79996e19968 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Wed, 24 Jun 2026 12:02:56 +0200 Subject: [PATCH 315/418] mcp: add `2026-07-28` to the supported protocol versions (#1020) This PR adds the protocol version `2026-07-28` to the list of supported protocol versions and fixes legacy tests hardcoding the legacy protocol version for those use cases. --- mcp/capabilities_test.go | 8 +- mcp/client.go | 4 +- mcp/client_test.go | 62 +++----- mcp/conformance_test.go | 30 ++++ mcp/mcp_example_test.go | 16 +-- mcp/mcp_test.go | 32 ++--- mcp/mrtr_test.go | 24 +--- mcp/server.go | 14 +- mcp/server_example_test.go | 2 +- mcp/server_test.go | 6 - mcp/shared.go | 16 ++- mcp/streamable_client_test.go | 132 +++++++++++------- mcp/streamable_example_test.go | 2 +- mcp/streamable_test.go | 96 +++++++------ .../conformance/server/discover.txtar | 39 ++++++ mcp/testdata/conformance/server/mrtr.txtar | 85 +++++++++++ mcp/transport_example_test.go | 5 +- 17 files changed, 355 insertions(+), 218 deletions(-) create mode 100644 mcp/testdata/conformance/server/discover.txtar create mode 100644 mcp/testdata/conformance/server/mrtr.txtar diff --git a/mcp/capabilities_test.go b/mcp/capabilities_test.go index b845616..86c9970 100644 --- a/mcp/capabilities_test.go +++ b/mcp/capabilities_test.go @@ -27,8 +27,12 @@ func TestServerListChangedNotifications(t *testing.T) { wantNotifyCount int64 }{ { - name: "Default: notification sent", - serverOpts: nil, + name: "Default: notification sent", + serverOpts: &ServerOptions{ + Capabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: true}, + }, + }, wantNotifyCount: 1, }, { diff --git a/mcp/client.go b/mcp/client.go index 8ccbdfa..552b3d6 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -403,7 +403,7 @@ func (c *Client) discover(ctx context.Context, cs *ClientSession) (*InitializeRe Meta: Meta{ MetaKeyProtocolVersion: protocolVersion, MetaKeyClientInfo: c.impl, - MetaKeyClientCapabilities: caps, + MetaKeyClientCapabilities: caps.toV2(), }, } req := &DiscoverRequest{Session: cs, Params: params} @@ -518,7 +518,7 @@ func injectRequestMeta[T any, P interface { m[MetaKeyClientInfo] = cs.client.impl } if _, ok := m[MetaKeyClientCapabilities]; !ok { - m[MetaKeyClientCapabilities] = cs.client.capabilities(res.ProtocolVersion) + m[MetaKeyClientCapabilities] = cs.client.capabilities(res.ProtocolVersion).toV2() } params.SetMeta(m) return params diff --git a/mcp/client_test.go b/mcp/client_test.go index 815f04c..fe1d3a2 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -10,7 +10,6 @@ import ( "context" "fmt" "log/slog" - "slices" "sync/atomic" "testing" @@ -644,13 +643,6 @@ func TestClientCapabilitiesOverWire(t *testing.T) { // don't overlap with the SDK. The test then asserts the resulting session // state and whether the legacy initialize handshake ran. func TestClientConnectDiscover(t *testing.T) { - // Temporarily enable 2026-07-28 support in the SDK for this test - oldSupported := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260728}, supportedProtocolVersions...) - t.Cleanup(func() { - supportedProtocolVersions = oldSupported - }) - const otherVersionsOnly = "1999-01-01" tests := []struct { @@ -686,7 +678,7 @@ func TestClientConnectDiscover(t *testing.T) { return nil, jsonrpc2.ErrMethodNotFound }, wantInitialize: true, - wantVersion: latestProtocolVersion, + wantVersion: protocolVersion20251125, }, { name: "unsupported protocol version falls back to initialize", @@ -697,7 +689,7 @@ func TestClientConnectDiscover(t *testing.T) { } }, wantInitialize: true, - wantVersion: latestProtocolVersion, + wantVersion: protocolVersion20251125, }, { name: "no overlapping supported version falls back to initialize", @@ -709,7 +701,7 @@ func TestClientConnectDiscover(t *testing.T) { }, nil }, wantInitialize: true, - wantVersion: latestProtocolVersion, + wantVersion: protocolVersion20251125, }, } @@ -771,10 +763,6 @@ func TestClientConnectDiscover(t *testing.T) { // request sent by Client.Connect carries the SEP-2575 per-request _meta triple: // protocolVersion, clientInfo, and clientCapabilities. func TestClientConnectDiscover_RequestContents(t *testing.T) { - orig := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260728}, slices.Clone(orig)...) - t.Cleanup(func() { supportedProtocolVersions = orig }) - ctx := context.Background() type captured struct { @@ -850,10 +838,6 @@ func TestClientConnectDiscover_RequestContents(t *testing.T) { func TestInMemory_E2E_DiscoverSuccess(t *testing.T) { ctx := context.Background() - orig := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260728}, slices.Clone(orig)...) - t.Cleanup(func() { supportedProtocolVersions = orig }) - server := NewServer(&Implementation{Name: "stdio-like-server", Version: "v1"}, nil) ct, st := NewInMemoryTransports() ss, err := server.Connect(ctx, st, nil) @@ -889,15 +873,11 @@ func TestInMemory_E2E_DiscoverSuccess(t *testing.T) { // TestInMemory_E2E_DiscoverFallback_NoOverlap verifies the fallback path // over an InMemory (STDIO-equivalent) transport: the client probes with -// _meta.protocolVersion = 2026-07-28, but the server's supported list does -// NOT include that version (the default for an SDK server that hasn't -// shimmed supportedProtocolVersions). +// _meta.protocolVersion = 2026-07-28, but the server overrides discover via +// middleware to advertise only legacy versions, so the client must fall +// back to the legacy initialize handshake. func TestInMemory_E2E_DiscoverFallback_NoOverlap(t *testing.T) { ctx := context.Background() - orig := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260728}, slices.Clone(orig)...) - t.Cleanup(func() { supportedProtocolVersions = orig }) - server := NewServer(&Implementation{Name: "vpre-like-server", Version: "v1"}, nil) // Intercept discover and reply as if we were a server that only // supports legacy versions. @@ -932,9 +912,11 @@ func TestInMemory_E2E_DiscoverFallback_NoOverlap(t *testing.T) { if ir == nil { t.Fatal("InitializeResult is nil after fallback initialize") } - if ir.ProtocolVersion != latestProtocolVersion { + // The fallback initialize explicitly requests protocolVersion20251125 + // (see client.go), so the server negotiates that version. + if ir.ProtocolVersion != protocolVersion20251125 { t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback after no-overlap discover)", - ir.ProtocolVersion, latestProtocolVersion) + ir.ProtocolVersion, protocolVersion20251125) } // Prove the session is usable after fallback. @@ -949,10 +931,6 @@ func TestInMemory_E2E_DiscoverFallback_NoOverlap(t *testing.T) { func TestInMemory_E2E_DiscoverFallback_MethodNotFound(t *testing.T) { ctx := context.Background() - orig := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260728}, slices.Clone(orig)...) - t.Cleanup(func() { supportedProtocolVersions = orig }) - server := NewServer(&Implementation{Name: "vpre-server", Version: "v1"}, nil) server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { return func(ctx context.Context, method string, req Request) (Result, error) { @@ -981,9 +959,11 @@ func TestInMemory_E2E_DiscoverFallback_MethodNotFound(t *testing.T) { if ir == nil { t.Fatal("InitializeResult is nil after fallback initialize") } - if ir.ProtocolVersion != latestProtocolVersion { + // The fallback initialize explicitly requests protocolVersion20251125 + // (see client.go), so the server negotiates that version. + if ir.ProtocolVersion != protocolVersion20251125 { t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback after MethodNotFound)", - ir.ProtocolVersion, latestProtocolVersion) + ir.ProtocolVersion, protocolVersion20251125) } if _, err := cs.ListTools(ctx, nil); err != nil { @@ -998,10 +978,6 @@ func TestInMemory_E2E_DiscoverFallback_MethodNotFound(t *testing.T) { func TestInMemory_E2E_DiscoverFallback_UnsupportedProtocolVersion(t *testing.T) { ctx := context.Background() - orig := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260728}, slices.Clone(orig)...) - t.Cleanup(func() { supportedProtocolVersions = orig }) - server := NewServer(&Implementation{Name: "strict-server", Version: "v1"}, nil) server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { return func(ctx context.Context, method string, req Request) (Result, error) { @@ -1033,9 +1009,11 @@ func TestInMemory_E2E_DiscoverFallback_UnsupportedProtocolVersion(t *testing.T) if ir == nil { t.Fatal("InitializeResult is nil after fallback initialize") } - if ir.ProtocolVersion != latestProtocolVersion { + // The fallback initialize explicitly requests protocolVersion20251125 + // (see client.go), so the server negotiates that version. + if ir.ProtocolVersion != protocolVersion20251125 { t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback after UnsupportedProtocolVersion)", - ir.ProtocolVersion, latestProtocolVersion) + ir.ProtocolVersion, protocolVersion20251125) } } @@ -1045,10 +1023,6 @@ func TestInMemory_E2E_DiscoverFallback_UnsupportedProtocolVersion(t *testing.T) // UnsupportedProtocolVersionError carries Data.Supported, and the client // selects a mutually supported version from that list and retries. func TestClientConnectDiscover_UnsupportedVersionNegotiation(t *testing.T) { - oldSupported := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260728}, slices.Clone(oldSupported)...) - t.Cleanup(func() { supportedProtocolVersions = oldSupported }) - ctx := context.Background() const unsupportedClientVersion = "2099-12-31" diff --git a/mcp/conformance_test.go b/mcp/conformance_test.go index 7f1a744..d7f4f74 100644 --- a/mcp/conformance_test.go +++ b/mcp/conformance_test.go @@ -143,6 +143,31 @@ func incTool(_ context.Context, _ *CallToolRequest, args incInput) (*CallToolRes return nil, incOutput{args.X + 1}, nil } +// confirmThenGreet exercises the multi-round-trip pattern in +// conformance fixtures. +func confirmThenGreet(_ context.Context, req *CallToolRequest, _ struct{}) (*CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + return &CallToolResult{ + InputRequests: InputRequestMap{ + "who": &ElicitParams{ + Message: "What is your name?", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": {Type: "string"}, + }, + }, + }, + }, + RequestState: "step=1", + }, nil, nil + } + name := req.Params.InputResponses["who"].(*ElicitResult).Content["name"].(string) + return &CallToolResult{ + Content: []Content{&TextContent{Text: "hello " + name}}, + }, nil, nil +} + var iconObj = Icon{ Source: "foobar", MIMEType: "image/png", @@ -193,6 +218,11 @@ func runServerTest(t *testing.T, test *conformanceTest) { Title: "contentTool", Description: "return resourceLink content with Icon", }, contentTool) + case "confirmThenGreet": + AddTool(s, &Tool{ + Name: "confirmThenGreet", + Description: "ask the user for their name via elicitation, then greet them", + }, confirmThenGreet) default: t.Fatalf("unknown tool %q", tn) } diff --git a/mcp/mcp_example_test.go b/mcp/mcp_example_test.go index 25f39fb..120789c 100644 --- a/mcp/mcp_example_test.go +++ b/mcp/mcp_example_test.go @@ -19,19 +19,13 @@ import ( func Example_lifecycle() { ctx := context.Background() - // Create a client and server. - // Wait for the client to initialize the session. + // Create a client and server. Under protocol version 2026-07-28, there + // is no dedicated initialize/initialized handshake: the session is + // implicitly live the moment the client issues its first request. client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) - server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ - InitializedHandler: func(context.Context, *mcp.InitializedRequest) { - fmt.Println("initialized!") - }, - }) + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) // Connect the server and client using in-memory transports. - // - // Connect the server first so that it's ready to receive initialization - // messages from the client. t1, t2 := mcp.NewInMemoryTransports() serverSession, err := server.Connect(ctx, t1, nil) if err != nil { @@ -50,7 +44,7 @@ func Example_lifecycle() { if err := serverSession.Wait(); err != nil { log.Fatal(err) } - // Output: initialized! + // Output: } // !-lifecycle diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index de12cbd..82a919d 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -171,7 +171,10 @@ func TestEndToEnd(t *testing.T) { c.AddRoots(&Root{URI: "file://" + rootAbs}) // Connect the client. - cs, err := c.Connect(ctx, ct, nil) + // + // Pin the session to 2025-11-25 so the legacy + // semantics apply. + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -758,7 +761,10 @@ func TestMiddleware(t *testing.T) { c.AddSendingMiddleware(traceCalls[*ClientSession](&cbuf, "S1"), traceCalls[*ClientSession](&cbuf, "S2")) c.AddReceivingMiddleware(traceCalls[*ClientSession](&cbuf, "R1"), traceCalls[*ClientSession](&cbuf, "R2")) - cs, err := c.Connect(ctx, ct, nil) + // Pin to 2025-11-25 because the test's expected wire sequence asserts + // the legacy initialize / notifications/initialized handshake, which + // 2026-07-28 replaces with server/discover. + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1903,7 +1909,10 @@ func TestKeepAliveFailure_Logged(t *testing.T) { Logger: slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelError})), } c := NewClient(testImpl, clientOpts) - cs, err := c.Connect(ctx, ct, nil) + // Pin to 2025-11-25: KeepAlive uses the ping RPC, which is removed + // in 2026-07-28, so keepalive is only meaningful on legacy protocol + // versions. + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -2570,18 +2579,10 @@ func newSubListenServer() *Server { return s } -func enableNewProtocol(t *testing.T) { - t.Helper() - orig := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260728}, slices.Clone(orig)...) - t.Cleanup(func() { supportedProtocolVersions = orig }) -} - // TestSubscriptionsListen_InMemory exercises the listen flow over the // session-shared in-memory transport (semantically equivalent to STDIO). // Cancellation here propagates via notifications/cancelled. func TestSubscriptionsListen_InMemory(t *testing.T) { - enableNewProtocol(t) events := make(chan subListenEvent, 64) server := newSubListenServer() ct, st := NewInMemoryTransports() @@ -2597,7 +2598,6 @@ func TestSubscriptionsListen_InMemory(t *testing.T) { // stateless HTTP server (SEP-2575). Each listen rides its own SSE response // stream; cs.Close() tears it down. func TestSubscriptionsListen_Streamable(t *testing.T) { - enableNewProtocol(t) events := make(chan subListenEvent, 64) server := newSubListenServer() handler := NewStreamableHTTPHandler( @@ -2615,7 +2615,6 @@ func TestSubscriptionsListen_Streamable(t *testing.T) { // auto-listen on connect, and therefore does not receive any acknowledgment // or downstream notifications. func TestSubscriptionsListen_NoHandlersNoListen(t *testing.T) { - enableNewProtocol(t) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -2694,7 +2693,6 @@ type resourceSubEvent struct { // subscriptionsListen handler so HTTP disconnect is observed directly; this // is tracked separately. func TestResourceSubscriptions_Streamable(t *testing.T) { - enableNewProtocol(t) subCh := make(chan string, 8) unsubCh := make(chan string, 8) @@ -2764,7 +2762,6 @@ func TestResourceSubscriptions_Streamable(t *testing.T) { // over an in-memory (stdio-equivalent) transport: the per-URI Subscribe path // uses notifications/cancelled rather than HTTP disconnect for teardown. func TestResourceSubscriptions_InMemory(t *testing.T) { - enableNewProtocol(t) subCh := make(chan string, 8) unsubCh := make(chan string, 8) @@ -2849,7 +2846,6 @@ func TestResourceSubscriptions_InMemory(t *testing.T) { // second call: it returns nil without invoking SubscribeHandler again and // without opening a second listen stream. func TestResourceSubscriptions_Subscribe_Idempotent(t *testing.T) { - enableNewProtocol(t) subCh := make(chan string, 8) unsubCh := make(chan string, 8) @@ -2914,7 +2910,6 @@ func TestResourceSubscriptions_Subscribe_Idempotent(t *testing.T) { // stream with a distinct subscription ID. Unsubscribing one does not affect // the other. func TestResourceSubscriptions_MultipleURIs(t *testing.T) { - enableNewProtocol(t) subCh := make(chan string, 8) unsubCh := make(chan string, 8) @@ -3032,7 +3027,6 @@ func TestResourceSubscriptions_MultipleURIs(t *testing.T) { // notification is fanned out to BOTH sessions, each with its own subscription // ID; closing one session does not affect deliveries to the other. func TestSubscriptionsListen_MultipleSessions(t *testing.T) { - enableNewProtocol(t) server := newSubListenServer() @@ -3110,7 +3104,6 @@ func TestSubscriptionsListen_MultipleSessions(t *testing.T) { // auto-listen branch (the other two list-changed types are covered by // runSubscriptionsListenTest, but resources is not). func TestSubscriptionsListen_ResourceListChanged(t *testing.T) { - enableNewProtocol(t) events := make(chan subListenEvent, 16) server := NewServer(testImpl, nil) @@ -3182,7 +3175,6 @@ func TestSubscriptionsListen_ResourceListChanged(t *testing.T) { // session removes its entries from the server's three per-type subscription // maps via Server.disconnect. func TestSubscriptionsListen_DisconnectScrubsMaps(t *testing.T) { - enableNewProtocol(t) events := make(chan subListenEvent, 16) server := newSubListenServer() diff --git a/mcp/mrtr_test.go b/mcp/mrtr_test.go index a5b286f..238bc09 100644 --- a/mcp/mrtr_test.go +++ b/mcp/mrtr_test.go @@ -23,10 +23,6 @@ func TestMultiRoundTrip_ManualRetry(t *testing.T) { Reason string `json:"reason,omitempty"` } - orig := supportedProtocolVersions - supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260728) - t.Cleanup(func() { supportedProtocolVersions = orig }) - ctx := context.Background() srv := NewServer(testImpl, nil) @@ -95,9 +91,6 @@ func TestMultiRoundTrip_ManualRetry(t *testing.T) { } func TestMultiRoundTrip_AutoRetry(t *testing.T) { - orig := supportedProtocolVersions - supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260728) - t.Cleanup(func() { supportedProtocolVersions = orig }) tests := []struct { name string @@ -220,9 +213,6 @@ func TestMultiRoundTrip_MaxRetries(t *testing.T) { wantRetries: maxMultiRoundTripRetries, }, } - orig := supportedProtocolVersions - supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260728) - t.Cleanup(func() { supportedProtocolVersions = orig }) for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { @@ -322,7 +312,7 @@ func TestMultiRoundTrip_ServerMiddleware(t *testing.T) { }, }) c.AddRoots(&Root{URI: "file:///workspace", Name: "workspace"}) - cs, err := c.Connect(t.Context(), ct, &ClientSessionOptions{}) + cs, err := c.Connect(t.Context(), ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() error = %v", err) } @@ -340,9 +330,6 @@ func TestMultiRoundTrip_ServerMiddleware(t *testing.T) { } func TestMultiRoundTrip_GetPrompt_AutoRetry(t *testing.T) { - orig := supportedProtocolVersions - supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260728) - t.Cleanup(func() { supportedProtocolVersions = orig }) ctx := context.Background() @@ -382,9 +369,6 @@ func TestMultiRoundTrip_GetPrompt_AutoRetry(t *testing.T) { } func TestMultiRoundTrip_GetPrompt_ManualRetry(t *testing.T) { - orig := supportedProtocolVersions - supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260728) - t.Cleanup(func() { supportedProtocolVersions = orig }) ctx := context.Background() @@ -434,9 +418,6 @@ func TestMultiRoundTrip_GetPrompt_ManualRetry(t *testing.T) { } func TestMultiRoundTrip_ReadResource_AutoRetry(t *testing.T) { - orig := supportedProtocolVersions - supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260728) - t.Cleanup(func() { supportedProtocolVersions = orig }) ctx := context.Background() @@ -475,9 +456,6 @@ func TestMultiRoundTrip_ReadResource_AutoRetry(t *testing.T) { } func TestMultiRoundTrip_ReadResource_ManualRetry(t *testing.T) { - orig := supportedProtocolVersions - supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260728) - t.Cleanup(func() { supportedProtocolVersions = orig }) ctx := context.Background() diff --git a/mcp/server.go b/mcp/server.go index bd0588b..4cc9ae4 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -856,7 +856,9 @@ func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetProm // instructions, allowing clients to negotiate without performing the legacy // initialize handshake. func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams]) (*DiscoverResult, error) { + req.Session.mu.Lock() versions := req.Session.supportedVersions + req.Session.mu.Unlock() if versions == nil { versions = slices.Clone(supportedProtocolVersions) } @@ -1335,7 +1337,17 @@ func (s *Server) Connect(ctx context.Context, t Transport, opts *ServerSessionOp // Compute the protocol versions this session can serve, filtered by the // transport's capabilities (if it implements [ProtocolVersionSupporter]). // The list is consumed by the SEP-2575 server/discover handler. + // + // The write is guarded by ss.mu to establish a happens-before edge with + // the matching read in Server.discover, which runs on the jsonrpc2 read + // goroutine spawned inside connect(). The two are not concurrent in + // wall-clock terms (no incoming message is dispatched until the caller + // has fed the transport, which happens after Server.Connect returns), + // but without the lock the Go memory model gives the read goroutine no + // guarantee of seeing this write, and -race flags it. + ss.mu.Lock() ss.supportedVersions = filterSupportedVersions(t) + ss.mu.Unlock() // Start keepalive before returning the session to avoid race conditions with Close. // This is safe because the spec allows sending pings before initialization (see ServerSession.handle for details). @@ -1795,7 +1807,7 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, } switch req.Method { - case methodInitialize, methodPing, notificationInitialized, methodSetLevel, methodSubscribe, methodUnsubscribe: + case methodInitialize, methodPing, notificationInitialized, notificationRootsListChanged, methodSetLevel, methodSubscribe, methodUnsubscribe: if validatedMeta.usesNewProtocol { ss.server.opts.Logger.Error("method removed in the new protocol", "method", req.Method) return nil, &jsonrpc.Error{ diff --git a/mcp/server_example_test.go b/mcp/server_example_test.go index 58d4eee..5e95585 100644 --- a/mcp/server_example_test.go +++ b/mcp/server_example_test.go @@ -284,7 +284,7 @@ func Example_mrtr() { // uses it to fulfil any input request the tool handler returns. c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ ElicitationHandler: func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) { - return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"name": "Ada"}}, nil + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"name": "MCP Go"}}, nil }, }) diff --git a/mcp/server_test.go b/mcp/server_test.go index f6938d1..fc9db1f 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -1290,9 +1290,6 @@ func TestServerCapabilitiesOverWire(t *testing.T) { // that opts into the new protocol via `_meta.protocolVersion` must be // rejected with `Method not found` (-32601). func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) { - orig := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260728}, slices.Clone(orig)...) - t.Cleanup(func() { supportedProtocolVersions = orig }) tests := []struct { name string @@ -1407,9 +1404,6 @@ func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) { // `ping`) all return Method not found when the request opts into the new // protocol via `_meta.protocolVersion`. func TestServerSessionHandle_RejectsRemovedMethodsOnNewProtocol(t *testing.T) { - orig := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260728}, slices.Clone(orig)...) - t.Cleanup(func() { supportedProtocolVersions = orig }) newProtoMeta := map[string]any{ "_meta": map[string]any{ diff --git a/mcp/shared.go b/mcp/shared.go index ac07ee4..2cf37b1 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -35,7 +35,7 @@ const ( // // It is the version that the client sends in the initialization request, and // the default version used by the server. - latestProtocolVersion = protocolVersion20251125 + latestProtocolVersion = protocolVersion20260728 protocolVersion20260728 = "2026-07-28" protocolVersion20251125 = "2025-11-25" protocolVersion20250618 = "2025-06-18" @@ -44,6 +44,7 @@ const ( ) var supportedProtocolVersions = []string{ + protocolVersion20260728, protocolVersion20251125, protocolVersion20250618, protocolVersion20250326, @@ -58,8 +59,11 @@ func negotiatedVersion(clientVersion string) string { // // This handles the case where a new spec version is released, and the SDK // does not support it yet. + // Cap the supported versions at the legacy protocolVersion20251125, as this + // method is used by the initialize method which is deprecated in + // version protocolVersion20260728. if !slices.Contains(supportedProtocolVersions, clientVersion) { - return latestProtocolVersion + return protocolVersion20251125 } return clientVersion } @@ -536,7 +540,7 @@ func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) { Message: fmt.Sprintf("missing or invalid _meta field %q", MetaKeyClientInfo), } } - capabilities, ok := decodeMetaValue[*ClientCapabilities](meta, MetaKeyClientCapabilities) + capabilities, ok := decodeMetaValue[*clientCapabilitiesV2](meta, MetaKeyClientCapabilities) if !ok { return nil, &jsonrpc.Error{ Code: jsonrpc.CodeInvalidParams, @@ -546,7 +550,7 @@ func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) { logLevel, _ := decodeMetaValue[LoggingLevel](meta, MetaKeyLogLevel) return &validatedMeta{usesNewProtocol: true, initializeParams: &InitializeParams{ ProtocolVersion: protocolVersion, - Capabilities: capabilities, + Capabilities: capabilities.toV1(), ClientInfo: clientInfo, }, logLevel: logLevel}, nil } @@ -659,8 +663,8 @@ func (r *ServerRequest[P]) ClientInfo() *Implementation { // back to the session-level [InitializeParams]. func (r *ServerRequest[P]) ClientCapabilities() *ClientCapabilities { if m := getRequestMeta(r); m != nil { - if v, ok := decodeMetaValue[*ClientCapabilities](m, MetaKeyClientCapabilities); ok { - return v + if v, ok := decodeMetaValue[*clientCapabilitiesV2](m, MetaKeyClientCapabilities); ok { + return v.toV1() } } if r.Session != nil { diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 24179d1..59bf1bd 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -160,7 +160,8 @@ var ( Logging: &LoggingCapabilities{}, Tools: &ToolCapabilities{ListChanged: true}, }, - ProtocolVersion: latestProtocolVersion, + // Pin negotiated version to 2025-11-25 + ProtocolVersion: protocolVersion20251125, ServerInfo: &Implementation{Name: "testServer", Version: "v1.0.0"}, } initResp = resp(1, initResult, nil) @@ -193,13 +194,13 @@ func TestStreamableClientTransportLifecycle(t *testing.T) { }, {"POST", "123", notificationInitialized, ""}: { status: http.StatusAccepted, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, {"GET", "123", "", ""}: { header: header{ "Content-Type": "text/event-stream", }, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, {"DELETE", "123", "", ""}: {}, }, @@ -210,7 +211,10 @@ func TestStreamableClientTransportLifecycle(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, nil) + // Pin to 2025-11-25: the fixture's canned initialize response uses + // hardcoded id=1, which only matches when initialize is the first + // request. Under 2026-07-28 the client probes server/discover first. + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -244,7 +248,7 @@ func TestStreamableClientRedundantDelete(t *testing.T) { }, {"POST", "123", notificationInitialized, ""}: { status: http.StatusAccepted, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, {"GET", "123", "", ""}: { status: http.StatusMethodNotAllowed, @@ -260,7 +264,10 @@ func TestStreamableClientRedundantDelete(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, nil) + // Pin to 2025-11-25: the fixture's canned initialize response uses + // hardcoded id=1, which only matches when initialize is the first + // request. Under 2026-07-28 the client probes server/discover first. + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -307,14 +314,14 @@ func TestStreamableClientGETHandling(t *testing.T) { }, {"POST", "123", notificationInitialized, ""}: { status: http.StatusAccepted, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, {"GET", "123", "", ""}: { header: header{ "Content-Type": test.contentType, }, status: test.status, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, {"DELETE", "123", "", ""}: {optional: true}, }, @@ -324,7 +331,9 @@ func TestStreamableClientGETHandling(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, nil) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ + protocolVersion: protocolVersion20251125, + }) if err == nil { defer session.Close() } @@ -376,14 +385,14 @@ func TestStreamableClientStrictness(t *testing.T) { }, {"POST", "123", notificationInitialized, ""}: { status: test.initializedStatus, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, {"GET", "123", "", ""}: { header: header{ "Content-Type": "text/event-stream", }, status: test.getStatus, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, {"POST", "123", methodListTools, ""}: { header: header{ @@ -401,7 +410,11 @@ func TestStreamableClientStrictness(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL, strict: test.strict} client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, nil) + // Pin to 2025-11-25: the fixture's canned initialize response + // uses hardcoded id=1, which only matches when initialize is + // the first request. Under 2026-07-28 the client probes + // server/discover first. + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if (err != nil) != test.wantConnectError { t.Errorf("client.Connect() returned error %v; want error: %t", err, test.wantConnectError) } @@ -441,7 +454,7 @@ func TestStreamableClientUnresumableRequest(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - cs, err := client.Connect(ctx, transport, nil) + cs, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err == nil { cs.Close() t.Fatalf("Connect succeeded unexpectedly") @@ -515,7 +528,7 @@ func TestStreamableClientResumption_Cancelled(t *testing.T) { }, {"POST", "123", notificationInitialized, ""}: { status: http.StatusAccepted, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, {"GET", "123", "", ""}: { header: header{ @@ -558,7 +571,11 @@ data: { "jsonrpc": "2.0", "method": "notifications/message", "params": { "level" transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - cs, err := client.Connect(ctx, transport, nil) + // Pin to 2025-11-25: the fixture's canned initialize response + // uses hardcoded id=1, which only matches when initialize is + // the first request. Under 2026-07-28 the client probes + // server/discover first. + cs, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -658,7 +675,7 @@ func TestStreamableClientTransientErrors(t *testing.T) { }, {"POST", "123", notificationInitialized, ""}: { status: http.StatusAccepted, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, {"GET", "123", "", ""}: { status: http.StatusMethodNotAllowed, @@ -686,7 +703,11 @@ func TestStreamableClientTransientErrors(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, nil) + // Pin to 2025-11-25: the fixture's canned initialize response + // uses hardcoded id=1, which only matches when initialize is + // the first request. Under 2026-07-28 the client probes + // server/discover first. + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("Connect failed: %v", err) } @@ -747,7 +768,7 @@ func TestStreamableClientRetryWithoutProgress(t *testing.T) { }, {"POST", "test-session", notificationInitialized, ""}: { status: http.StatusAccepted, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, {"GET", "test-session", "", ""}: { // Disable standalone SSE stream to simplify the test. @@ -789,7 +810,10 @@ data: {"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info" MaxRetries: maxRetries, } client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, nil) + // Pin to 2025-11-25: the fixture's canned initialize response uses + // hardcoded id=1, which only matches when initialize is the first + // request. Under 2026-07-28 the client probes server/discover first. + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("Connect failed: %v", err) } @@ -850,13 +874,13 @@ func TestStreamableClientDisableStandaloneSSE(t *testing.T) { }, {"POST", "123", notificationInitialized, ""}: { status: http.StatusAccepted, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, getRequestKey: { header: header{ "Content-Type": "text/event-stream", }, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, optional: !test.expectGETRequest, }, {"DELETE", "123", "", ""}: { @@ -873,7 +897,11 @@ func TestStreamableClientDisableStandaloneSSE(t *testing.T) { DisableStandaloneSSE: test.disableStandaloneSSE, } client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, nil) + // Pin to 2025-11-25: the fixture's canned initialize response + // uses hardcoded id=1, which only matches when initialize is + // the first request. Under 2026-07-28 the client probes + // server/discover first. + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -967,7 +995,7 @@ func TestStreamableClientOAuth_AuthorizationHeader(t *testing.T) { }, {"POST", "123", notificationInitialized, ""}: { status: http.StatusAccepted, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, {"GET", "123", "", ""}: { header: header{ @@ -991,7 +1019,10 @@ func TestStreamableClientOAuth_AuthorizationHeader(t *testing.T) { OAuthHandler: oauthHandler, } client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, nil) + // Pin to 2025-11-25: the fixture's canned initialize response uses + // hardcoded id=1, which only matches when initialize is the first + // request. Under 2026-07-28 the client probes server/discover first. + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -1253,13 +1284,6 @@ var discoverResult = &DiscoverResult{ func TestStreamableClientConnect_DiscoverSuccess(t *testing.T) { ctx := context.Background() - // Temporarily enable 2026-07-28 support in the SDK for this test - oldSupported := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260728}, supportedProtocolVersions...) - t.Cleanup(func() { - supportedProtocolVersions = oldSupported - }) - var ( gotDiscoverMu sync.Mutex gotDiscover *jsonrpc.Request @@ -1378,11 +1402,11 @@ func TestStreamableClientConnect_DiscoverMethodNotFound(t *testing.T) { }, {"POST", "fallback", notificationInitialized, ""}: { status: http.StatusAccepted, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, {"GET", "fallback", "", ""}: { header: header{"Content-Type": "text/event-stream"}, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, optional: true, }, {"DELETE", "fallback", "", ""}: {optional: true}, @@ -1394,14 +1418,16 @@ func TestStreamableClientConnect_DiscoverMethodNotFound(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, nil) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("Connect: %v", err) } defer session.Close() - if got := session.InitializeResult().ProtocolVersion; got != latestProtocolVersion { - t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (initialize fallback)", got, latestProtocolVersion) + // The fallback initialize explicitly requests protocolVersion20251125 + // (see client.go), so the server negotiates that version. + if got := session.InitializeResult().ProtocolVersion; got != protocolVersion20251125 { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (initialize fallback)", got, protocolVersion20251125) } } @@ -1443,11 +1469,11 @@ func TestStreamableClientConnect_DiscoverUnsupportedVersion(t *testing.T) { }, {"POST", "fallback", notificationInitialized, ""}: { status: http.StatusAccepted, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, {"GET", "fallback", "", ""}: { header: header{"Content-Type": "text/event-stream"}, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, optional: true, }, {"DELETE", "fallback", "", ""}: {optional: true}, @@ -1459,14 +1485,16 @@ func TestStreamableClientConnect_DiscoverUnsupportedVersion(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, nil) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("Connect: %v", err) } defer session.Close() - if got := session.InitializeResult().ProtocolVersion; got != latestProtocolVersion { - t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (initialize fallback)", got, latestProtocolVersion) + // The fallback initialize explicitly requests protocolVersion20251125 + // (see client.go), so the server negotiates that version. + if got := session.InitializeResult().ProtocolVersion; got != protocolVersion20251125 { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (initialize fallback)", got, protocolVersion20251125) } } @@ -1503,7 +1531,7 @@ func TestStreamableClientConnect_DiscoverMethodNotFoundVPre(t *testing.T) { }, {"POST", "fallback", notificationInitialized, ""}: { status: http.StatusAccepted, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, {"DELETE", "fallback", "", ""}: {optional: true}, }, @@ -1523,8 +1551,10 @@ func TestStreamableClientConnect_DiscoverMethodNotFoundVPre(t *testing.T) { } defer session.Close() - if got := session.InitializeResult().ProtocolVersion; got != latestProtocolVersion { - t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (initialize fallback)", got, latestProtocolVersion) + // The fallback initialize explicitly requests protocolVersion20251125 + // (see client.go), so the server negotiates that version. + if got := session.InitializeResult().ProtocolVersion; got != protocolVersion20251125 { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (initialize fallback)", got, protocolVersion20251125) } } @@ -1558,7 +1588,7 @@ func TestStreamableClientConnect_DiscoverUnsupportedVersionVPre(t *testing.T) { }, {"POST", "fallback", notificationInitialized, ""}: { status: http.StatusAccepted, - wantProtocolVersion: latestProtocolVersion, + wantProtocolVersion: protocolVersion20251125, }, {"DELETE", "fallback", "", ""}: {optional: true}, }, @@ -1578,8 +1608,10 @@ func TestStreamableClientConnect_DiscoverUnsupportedVersionVPre(t *testing.T) { } defer session.Close() - if got := session.InitializeResult().ProtocolVersion; got != latestProtocolVersion { - t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (initialize fallback)", got, latestProtocolVersion) + // The fallback initialize explicitly requests protocolVersion20251125 + // (see client.go), so the server negotiates that version. + if got := session.InitializeResult().ProtocolVersion; got != protocolVersion20251125 { + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (initialize fallback)", got, protocolVersion20251125) } } @@ -1590,12 +1622,6 @@ func TestStreamableClientConnect_DiscoverUnsupportedVersionVPre(t *testing.T) { func TestStreamableClientConnect_DiscoverUnsupportedVersionNegotiation(t *testing.T) { ctx := context.Background() - oldSupported := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260728}, supportedProtocolVersions...) - t.Cleanup(func() { - supportedProtocolVersions = oldSupported - }) - const unsupportedClientVersion = "2099-12-31" var discoverCalls atomic.Int32 diff --git a/mcp/streamable_example_test.go b/mcp/streamable_example_test.go index 7ff1612..66834f2 100644 --- a/mcp/streamable_example_test.go +++ b/mcp/streamable_example_test.go @@ -32,7 +32,7 @@ func ExampleStreamableHTTPHandler() { defer httpServer.Close() // The SDK is currently permissive of some missing keys in "params". - resp := mustPostMessage(`{"jsonrpc": "2.0", "id": 1, "method":"initialize", "params": {}}`, httpServer.URL) + resp := mustPostMessage(`{"jsonrpc": "2.0", "id": 1, "method":"initialize", "params": {"protocolVersion":"2025-11-25"}}`, httpServer.URL) fmt.Println(resp) // Output: // {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-11-25","serverInfo":{"name":"server","version":"v0.1.0"}}} diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 25d3625..dff5e1f 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -182,7 +182,11 @@ func TestStreamableTransports(t *testing.T) { if sid == "" { t.Fatalf("empty session ID") } - if g, w := session.mcpConn.(*streamableClientConn).initializedResult.ProtocolVersion, latestProtocolVersion; g != w { + // Stateful HTTP servers reject 2026-07-28 (which requires + // StreamableHTTPOptions.Stateless = true), so the client falls + // back to the legacy initialize handshake which explicitly + // requests 2025-11-25. + if g, w := session.mcpConn.(*streamableClientConn).initializedResult.ProtocolVersion, protocolVersion20251125; g != w { t.Fatalf("got protocol version %q, want %q", g, w) } @@ -200,7 +204,10 @@ func TestStreamableTransports(t *testing.T) { if g := session.ID(); g != sid { t.Errorf("session ID: got %q, want %q", g, sid) } - if g, w := lastHeader.Get(protocolVersionHeader), latestProtocolVersion; g != w { + // Stateful HTTP requires the legacy protocol (see the earlier + // initializeResult assertion); the client sends the matching + // MCP-Protocol-Version header. + if g, w := lastHeader.Get(protocolVersionHeader), protocolVersion20251125; g != w { t.Errorf("got protocol version header %q, want %q", g, w) } want := &CallToolResult{ @@ -332,10 +339,15 @@ func TestStreamableServerShutdown(t *testing.T) { opts.KeepAlive = 50 * time.Millisecond } client := NewClient(testImpl, &opts) + // Pin to 2025-11-25: this test relies on the legacy hanging GET + // (stateful case) or Ping-based keepalive (stateless case) for + // the client to notice the server shutdown. Both mechanisms are + // removed in 2026-07-28 (SEP-2575), so the new protocol would + // leave the client waiting forever. clientSession, err := client.Connect(ctx, &StreamableClientTransport{ Endpoint: httpServer.URL, MaxRetries: -1, // avoid slow tests during exponential retries - }, nil) + }, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -680,7 +692,12 @@ func TestServerTransportCleanup(t *testing.T) { } } client.AddReceivingMiddleware(pingMiddleware) - clientSession, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, nil) + // Pin to 2025-11-25: this test counts server-side sessions, but + // the 2026-07-28 client probes server/discover first against a + // stateful HTTP server (which rejects the new protocol), opening + // an extra HTTP connection and therefore an extra session. Pinning + // to 2025-11-25 skips the discover probe. + clientSession, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -1636,7 +1653,7 @@ func TestStreamableStateless(t *testing.T) { Logging: &LoggingCapabilities{}, Tools: &ToolCapabilities{ListChanged: true}, }, - ProtocolVersion: latestProtocolVersion, + ProtocolVersion: protocolVersion20251125, ServerInfo: &Implementation{Name: "test", Version: "v1.0.0"}, }, nil) // This version of sayHi expects @@ -1904,9 +1921,6 @@ func TestStreamableGET(t *testing.T) { // as specified in SEP-2243. func TestStreamableMcpHeaderValidation(t *testing.T) { // Temporarily register the future version so the handler accepts it. - orig := supportedProtocolVersions - supportedProtocolVersions = append(slices.Clone(orig), minVersionForStandardHeaders) - t.Cleanup(func() { supportedProtocolVersions = orig }) server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) server.AddTool( @@ -2055,9 +2069,6 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { // validation errors return a JSON-RPC error with code -32020 and // Content-Type application/json, per SEP-2243. func TestStreamableMcpHeaderValidationErrorFormat(t *testing.T) { - orig := supportedProtocolVersions - supportedProtocolVersions = append(slices.Clone(orig), minVersionForStandardHeaders) - t.Cleanup(func() { supportedProtocolVersions = orig }) server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) server.AddTool( @@ -2204,9 +2215,6 @@ func TestStreamableMcpHeaderVersionGating(t *testing.T) { // TestStreamableParamHeadersClientSetsHeaders verifies that the client sets // Mcp-Param-* headers on tool calls when the tool has x-mcp-header annotations. func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) { - orig := supportedProtocolVersions - supportedProtocolVersions = append(slices.Clone(orig), minVersionForStandardHeaders) - t.Cleanup(func() { supportedProtocolVersions = orig }) server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) server.AddTool( @@ -2306,9 +2314,6 @@ func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) { // tools with invalid x-mcp-header annotations at registration time, and // that valid tools are returned by ListTools. func TestStreamableFilterValidToolsIntegration(t *testing.T) { - orig := supportedProtocolVersions - supportedProtocolVersions = append(slices.Clone(orig), minVersionForStandardHeaders) - t.Cleanup(func() { supportedProtocolVersions = orig }) server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) noop := func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) { @@ -2624,8 +2629,12 @@ func TestStreamableSessionTimeout(t *testing.T) { defer httpServer.Close() // Connect a client to create a session. + // Pin to 2025-11-25: under 2026-07-28 the client probes server/discover + // first against a stateful HTTP server (which rejects the new + // protocol), opening an extra HTTP connection and therefore an extra + // session. Pinning to 2025-11-25 skips the discover probe. client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, nil) + session, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -2915,7 +2924,11 @@ func Test_ExportErrSessionMissing(t *testing.T) { Endpoint: ts.URL, } client := NewClient(impl, nil) - session, err := client.Connect(ctx, clientTransport, nil) + // Pin to 2025-11-25: under 2026-07-28 the client probes server/discover + // first against a stateful HTTP server (which rejects the new + // protocol), opening an extra HTTP connection and therefore an extra + // session. Pinning to 2025-11-25 skips the discover probe. + session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("Connect failed: %v", err) } @@ -3355,9 +3368,6 @@ func TestStreamableStateless_NewProtocolSession_NoFakeInit(t *testing.T) { // SEP-2575: the MCP-Protocol-Version header is mandatory for new-protocol // requests and must be a supported version. The 2026-07-28 version is // not yet in the global list, so register it for the duration of the test. - orig := supportedProtocolVersions - supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260728) - t.Cleanup(func() { supportedProtocolVersions = orig }) capture := &statelessHandlerCapture{} mcpServer := NewServer(testImpl, nil) @@ -3432,9 +3442,6 @@ func TestStreamableStateless_NewProtocolSession_NoFakeInit(t *testing.T) { func TestStreamableStateful_RejectsNewProtocol(t *testing.T) { // Make 2026-07-28 a "known" version so that the request reaches servePOST // (otherwise the early header validation at ServeHTTP rejects it). - orig := supportedProtocolVersions - supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260728) - t.Cleanup(func() { supportedProtocolVersions = orig }) server := NewServer(testImpl, nil) AddTool(server, &Tool{Name: "noop"}, @@ -3497,9 +3504,6 @@ func TestStreamableStateful_RejectsNewProtocol(t *testing.T) { // (the rejection in TestStreamableStateful_RejectsNewProtocol must not // fire on Stateless: true). func TestStreamableStateless_AcceptsNewProtocol(t *testing.T) { - orig := supportedProtocolVersions - supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260728) - t.Cleanup(func() { supportedProtocolVersions = orig }) server := NewServer(testImpl, nil) AddTool(server, &Tool{Name: "noop"}, @@ -3536,15 +3540,22 @@ func TestStreamableStateless_AcceptsNewProtocol(t *testing.T) { } // TestStreamableClientUnsupportedVersionFallback exercises the full -// SEP-2575 fallback. The client requests protocolVersion20260728, which the server does -// not advertise in supportedProtocolVersions. The server therefore rejects -// the server/discover POST at the transport-level header validation with a -// plain HTTP 400 ("Bad Request: Unsupported protocol version ..."). The -// streamable client must recognize this body, keep the connection alive, and -// successfully complete the legacy initialize handshake. -// -// TODO: once 20260630 is part of supportedProtocolVersion on server side, modify the list in the test to keep it consistent. +// SEP-2575 fallback. The client requests protocolVersion20260728, which the +// server is configured to NOT advertise in supportedProtocolVersions for the +// duration of this test. The server therefore rejects the server/discover +// POST at the transport-level header validation with a plain HTTP 400 ("Bad +// Request: Unsupported protocol version ..."). The streamable client must +// recognize this body, keep the connection alive, and successfully complete +// the legacy initialize handshake. func TestStreamableClientUnsupportedVersionFallback(t *testing.T) { + // Remove protocolVersion20260728 from the server-side supported list so + // the fallback path is exercised. + orig := supportedProtocolVersions + supportedProtocolVersions = slices.DeleteFunc(slices.Clone(orig), func(v string) bool { + return v == protocolVersion20260728 + }) + t.Cleanup(func() { supportedProtocolVersions = orig }) + ctx := context.Background() server := NewServer(testImpl, nil) @@ -3568,9 +3579,11 @@ func TestStreamableClientUnsupportedVersionFallback(t *testing.T) { if ir == nil { t.Fatal("InitializeResult is nil; expected legacy initialize to populate it") } - if ir.ProtocolVersion != latestProtocolVersion { + // The fallback initialize explicitly requests protocolVersion20251125 + // (see client.go), so the server negotiates that version. + if ir.ProtocolVersion != protocolVersion20251125 { t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback)", - ir.ProtocolVersion, latestProtocolVersion) + ir.ProtocolVersion, protocolVersion20251125) } // Verify the session is fully usable after the fallback by issuing a @@ -3587,9 +3600,6 @@ func TestStreamableClientUnsupportedVersionFallback(t *testing.T) { // the server with the new protocol version to learn which versions are // supported. func TestStreamableStateful_AcceptsDiscover(t *testing.T) { - orig := supportedProtocolVersions - supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260728) - t.Cleanup(func() { supportedProtocolVersions = orig }) server := NewServer(testImpl, nil) handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil) @@ -3666,10 +3676,6 @@ func TestStreamableStateful_AcceptsDiscover(t *testing.T) { // SEP-2575 over the streamable HTTP transport. func TestStreamableHTTP_E2E_DiscoverSuccess(t *testing.T) { ctx := context.Background() - orig := supportedProtocolVersions - supportedProtocolVersions = append([]string{protocolVersion20260728}, slices.Clone(orig)...) - t.Cleanup(func() { supportedProtocolVersions = orig }) - server := NewServer(&Implementation{Name: "e2e-server", Version: "v1"}, nil) // Register a simple tool so we can prove the session is usable end-to-end. AddTool(server, &Tool{Name: "echo", Description: "echoes its input"}, diff --git a/mcp/testdata/conformance/server/discover.txtar b/mcp/testdata/conformance/server/discover.txtar new file mode 100644 index 0000000..a2fbde7 --- /dev/null +++ b/mcp/testdata/conformance/server/discover.txtar @@ -0,0 +1,39 @@ +The client sends a discover request +carrying the required per-request _meta triple (protocolVersion, clientInfo, +clientCapabilities), and the server replies with the protocol versions it +supports, its capabilities, and its identity. + +-- client -- +{ + "jsonrpc": "2.0", + "id": 1, + "method": "server/discover", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { "name": "ExampleClient", "version": "1.0.0" }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } +} +-- server -- +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "supportedVersions": [ + "2026-07-28", + "2025-11-25", + "2025-06-18", + "2025-03-26", + "2024-11-05" + ], + "capabilities": { + "logging": {} + }, + "serverInfo": { + "name": "testServer", + "version": "v1.0.0" + } + } +} diff --git a/mcp/testdata/conformance/server/mrtr.txtar b/mcp/testdata/conformance/server/mrtr.txtar new file mode 100644 index 0000000..e0dc18c --- /dev/null +++ b/mcp/testdata/conformance/server/mrtr.txtar @@ -0,0 +1,85 @@ +Check the multi-round-trip request pattern under protocol version +2026-07-28. The server's tool handler returns an +InputRequiredResult on the first call, requesting elicitation input from the +client and providing an opaque RequestState token. The client fulfills the +input request and retries the tool call with the responses and the echoed +RequestState; the handler then returns the final result. + +-- tools -- +confirmThenGreet +-- client -- +{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { "name": "ExampleClient", "version": "1.0.0" }, + "io.modelcontextprotocol/clientCapabilities": { "elicitation": {} } + }, + "name": "confirmThenGreet", + "arguments": {} + } +} +{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { "name": "ExampleClient", "version": "1.0.0" }, + "io.modelcontextprotocol/clientCapabilities": { "elicitation": {} } + }, + "name": "confirmThenGreet", + "arguments": {}, + "requestState": "step=1", + "inputResponses": { + "who": { + "method": "elicitation/create", + "result": { "action": "accept", "content": { "name": "MCP Go" } } + } + } + } +} +-- server -- +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": null, + "requestState": "step=1", + "resultType": "input_required", + "inputRequests": { + "who": { + "method": "elicitation/create", + "params": { + "mode": "", + "message": "What is your name?", + "requestedSchema": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + } + } + } +} +{ + "jsonrpc": "2.0", + "id": 2, + "result": { + "content": [ + { + "type": "text", + "text": "hello MCP Go" + } + ], + "resultType": "complete" + } +} diff --git a/mcp/transport_example_test.go b/mcp/transport_example_test.go index fa05fc0..2a9d745 100644 --- a/mcp/transport_example_test.go +++ b/mcp/transport_example_test.go @@ -42,9 +42,8 @@ func ExampleLoggingTransport() { } // Output: - // read: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-11-25","serverInfo":{"name":"server","version":"v0.0.1"}}} - // write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-11-25","capabilities":{"roots":{"listChanged":true}}}} - // write: {"jsonrpc":"2.0","method":"notifications/initialized","params":{}} + // read: {"jsonrpc":"2.0","id":1,"result":{"supportedVersions":["2026-07-28","2025-11-25","2025-06-18","2025-03-26","2024-11-05"],"capabilities":{"logging":{}},"serverInfo":{"name":"server","version":"v0.0.1"}}} + // write: {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/clientCapabilities":{"roots":{"listChanged":true}},"io.modelcontextprotocol/clientInfo":{"name":"client","version":"v0.0.1"},"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}} } // !-loggingtransport From a817f6141278c4bc2078d44c34e2f5c774c7e204 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Wed, 24 Jun 2026 14:08:43 +0200 Subject: [PATCH 316/418] mcp: add Cacheable fields to DiscoverResult (#1022) This PR add the Cacheable fields to the DiscoverResult, as it was added later (https://github.com/modelcontextprotocol/modelcontextprotocol/commit/8e4c2322cafa0924183c587f55401d9d93a23feb) --- docs/protocol.md | 18 ++++++------------ docs/server.md | 2 +- docs/troubleshooting.md | 5 ++--- mcp/protocol.go | 1 + mcp/server.go | 6 ++++-- mcp/testdata/conformance/server/discover.txtar | 2 ++ mcp/transport_example_test.go | 2 +- 7 files changed, 17 insertions(+), 19 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index 5f043f2..408442e 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -77,19 +77,13 @@ it is the client's responsibility to end the session. func Example_lifecycle() { ctx := context.Background() - // Create a client and server. - // Wait for the client to initialize the session. + // Create a client and server. Under protocol version 2026-07-28, there + // is no dedicated initialize/initialized handshake: the session is + // implicitly live the moment the client issues its first request. client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) - server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ - InitializedHandler: func(context.Context, *mcp.InitializedRequest) { - fmt.Println("initialized!") - }, - }) + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) // Connect the server and client using in-memory transports. - // - // Connect the server first so that it's ready to receive initialization - // messages from the client. t1, t2 := mcp.NewInMemoryTransports() serverSession, err := server.Connect(ctx, t1, nil) if err != nil { @@ -108,7 +102,7 @@ func Example_lifecycle() { if err := serverSession.Wait(); err != nil { log.Fatal(err) } - // Output: initialized! + // Output: } ``` @@ -213,7 +207,7 @@ func ExampleStreamableHTTPHandler() { defer httpServer.Close() // The SDK is currently permissive of some missing keys in "params". - resp := mustPostMessage(`{"jsonrpc": "2.0", "id": 1, "method":"initialize", "params": {}}`, httpServer.URL) + resp := mustPostMessage(`{"jsonrpc": "2.0", "id": 1, "method":"initialize", "params": {"protocolVersion":"2025-11-25"}}`, httpServer.URL) fmt.Println(resp) // Output: // {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-11-25","serverInfo":{"name":"server","version":"v0.1.0"}}} diff --git a/docs/server.md b/docs/server.md index e7ee307..cddb14a 100644 --- a/docs/server.md +++ b/docs/server.md @@ -531,7 +531,7 @@ func Example_mrtr() { // uses it to fulfil any input request the tool handler returns. c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ ElicitationHandler: func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) { - return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"name": "Ada"}}, nil + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"name": "MCP Go"}}, nil }, }) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1a0c367..0e792a3 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -50,9 +50,8 @@ func ExampleLoggingTransport() { } // Output: - // read: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-11-25","serverInfo":{"name":"server","version":"v0.0.1"}}} - // write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-11-25","capabilities":{"roots":{"listChanged":true}}}} - // write: {"jsonrpc":"2.0","method":"notifications/initialized","params":{}} + // read: {"jsonrpc":"2.0","id":1,"result":{"ttlMs":0,"cacheScope":"public","supportedVersions":["2026-07-28","2025-11-25","2025-06-18","2025-03-26","2024-11-05"],"capabilities":{"logging":{}},"serverInfo":{"name":"server","version":"v0.0.1"}}} + // write: {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/clientCapabilities":{"roots":{"listChanged":true}},"io.modelcontextprotocol/clientInfo":{"name":"client","version":"v0.0.1"},"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}} } ``` diff --git a/mcp/protocol.go b/mcp/protocol.go index f3c2efc..f077c6d 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -1129,6 +1129,7 @@ func (x *DiscoverParams) SetProgressToken(t any) { setProgressToken(x, t) } type DiscoverResult struct { Meta `json:"_meta,omitempty"` + Cacheable // The versions of the Model Context Protocol that the server supports. SupportedVersions []string `json:"supportedVersions"` // The server's capabilities. diff --git a/mcp/server.go b/mcp/server.go index 4cc9ae4..e07d3c2 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -869,12 +869,14 @@ func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams] ClientInfo: req.ClientInfo(), } }) - return &DiscoverResult{ + res := &DiscoverResult{ SupportedVersions: versions, Capabilities: s.capabilities(), ServerInfo: s.impl, Instructions: s.opts.Instructions, - }, nil + } + res.setDefaultCacheableValues() + return res, nil } // filterSupportedVersions returns the subset of [supportedProtocolVersions] diff --git a/mcp/testdata/conformance/server/discover.txtar b/mcp/testdata/conformance/server/discover.txtar index a2fbde7..9b8276c 100644 --- a/mcp/testdata/conformance/server/discover.txtar +++ b/mcp/testdata/conformance/server/discover.txtar @@ -21,6 +21,8 @@ supports, its capabilities, and its identity. "jsonrpc": "2.0", "id": 1, "result": { + "ttlMs": 0, + "cacheScope": "public", "supportedVersions": [ "2026-07-28", "2025-11-25", diff --git a/mcp/transport_example_test.go b/mcp/transport_example_test.go index 2a9d745..1920233 100644 --- a/mcp/transport_example_test.go +++ b/mcp/transport_example_test.go @@ -42,7 +42,7 @@ func ExampleLoggingTransport() { } // Output: - // read: {"jsonrpc":"2.0","id":1,"result":{"supportedVersions":["2026-07-28","2025-11-25","2025-06-18","2025-03-26","2024-11-05"],"capabilities":{"logging":{}},"serverInfo":{"name":"server","version":"v0.0.1"}}} + // read: {"jsonrpc":"2.0","id":1,"result":{"ttlMs":0,"cacheScope":"public","supportedVersions":["2026-07-28","2025-11-25","2025-06-18","2025-03-26","2024-11-05"],"capabilities":{"logging":{}},"serverInfo":{"name":"server","version":"v0.0.1"}}} // write: {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/clientCapabilities":{"roots":{"listChanged":true}},"io.modelcontextprotocol/clientInfo":{"name":"client","version":"v0.0.1"},"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}} } From af893e8d1e9fe9973e7d12a229f0439d7327d97b Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Wed, 24 Jun 2026 14:28:10 +0200 Subject: [PATCH 317/418] mcp: Upgrade conformance test to latest available deployoment (#1021) Upgrade conformance test to the 0.2.0-alpha.5 version, and differentiate legacy from 20260728 servers. --- .github/workflows/conformance.yml | 54 ++++++++++++++++++--------- conformance/everything-server/main.go | 5 ++- 2 files changed, 40 insertions(+), 19 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 6221ed1..105cf2e 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -13,6 +13,9 @@ concurrency: permissions: contents: read +env: + CONFORMANCE_VERSION: "0.2.0-alpha.5" + jobs: server-conformance: runs-on: ubuntu-latest @@ -23,19 +26,34 @@ jobs: uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: "^1.26" - - name: Start everything-server - run: | - go run ./conformance/everything-server/main.go -http=":3001" & - # Wait for the server to be ready. - timeout 30 bash -c 'until curl -s http://localhost:3001/mcp; do sleep 0.5; done' - - name: "Run conformance tests" - uses: modelcontextprotocol/conformance@a2855b03582a6c0b31065ad4d9af248316ce61a3 # v0.1.15 + - name: Set up Node.js + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: - mode: server - url: http://localhost:3001/mcp - suite: active - expected-failures: ./conformance/baseline.yml node-version: 22 + - name: Build everything-server + run: go build -o everything-server ./conformance/everything-server + - name: Run conformance tests (stateful, legacy spec versions) + run: | + ./everything-server -http=":3001" -stateless=false & + SERVER_PID=$! + trap "kill -9 $SERVER_PID 2>/dev/null || true; wait $SERVER_PID 2>/dev/null || true" EXIT + timeout 30 bash -c 'until curl -s http://localhost:3001/mcp > /dev/null; do sleep 0.5; done' + npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \ + --url http://localhost:3001/mcp \ + --suite active \ + --spec-version 2025-11-25 \ + --expected-failures ./conformance/baseline.yml + - name: Run conformance tests (stateless, 2026-07-28) + run: | + ./everything-server -http=":3001" -stateless & + SERVER_PID=$! + trap "kill -9 $SERVER_PID 2>/dev/null || true; wait $SERVER_PID 2>/dev/null || true" EXIT + timeout 30 bash -c 'until curl -s http://localhost:3001/mcp > /dev/null; do sleep 0.5; done' + npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \ + --url http://localhost:3001/mcp \ + --suite active \ + --spec-version 2026-07-28 \ + --expected-failures ./conformance/baseline.yml client-conformance: runs-on: ubuntu-latest @@ -46,11 +64,13 @@ jobs: uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: "^1.26" - - name: "Run conformance tests" - uses: modelcontextprotocol/conformance@a2855b03582a6c0b31065ad4d9af248316ce61a3 # v0.1.15 + - name: Set up Node.js + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: - mode: client - command: go run ./conformance/everything-client - suite: core - expected-failures: ./conformance/baseline.yml node-version: 22 + - name: Run conformance tests + run: | + npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" client \ + --command "go run ./conformance/everything-client" \ + --suite core \ + --expected-failures ./conformance/baseline.yml diff --git a/conformance/everything-server/main.go b/conformance/everything-server/main.go index b815b56..d74dddf 100644 --- a/conformance/everything-server/main.go +++ b/conformance/everything-server/main.go @@ -27,7 +27,8 @@ import ( ) var ( - httpAddr = flag.String("http", "", "if set, use streamable HTTP at this address, instead of stdin/stdout") + httpAddr = flag.String("http", "", "if set, use streamable HTTP at this address, instead of stdin/stdout") + stateless = flag.Bool("stateless", true, "use stateless streamable HTTP mode") ) const watchedResourceURI = "test://watched-resource" @@ -60,7 +61,7 @@ func main() { if *httpAddr != "" { handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server - }, nil) + }, &mcp.StreamableHTTPOptions{Stateless: *stateless}) log.Printf("Conformance server listening at %s", *httpAddr) log.Fatal(http.ListenAndServe(*httpAddr, handler)) } else { From 22d4988a164e4bdff39f49fe1bb063b84e83c431 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Wed, 24 Jun 2026 14:39:39 +0200 Subject: [PATCH 318/418] mcp: update docs references (#1023) Update docs --- docs/client.md | 4 ++-- docs/server.md | 4 ++-- internal/docs/client.src.md | 2 +- internal/docs/server.src.md | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/client.md b/docs/client.md index b5d0032..d25bee9 100644 --- a/docs/client.md +++ b/docs/client.md @@ -4,7 +4,7 @@ 1. [Roots](#roots) 1. [Sampling](#sampling) 1. [Elicitation](#elicitation) -1. [Multi Round-Trip Requests (MRTR)](#multi-round-trip-requests-(mrtr)) +1. [Multi Round-Trip Requests](#multi-round-trip-requests) 1. [Capabilities](#capabilities) 1. [Capability inference](#capability-inference) 1. [Explicit capabilities](#explicit-capabilities) @@ -207,7 +207,7 @@ func Example_elicitation() { } ``` -## Multi Round-Trip Requests (MRTR) +## Multi Round-Trip Requests [SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322) introduces the MRTR pattern: server-to-client requests for sampling, diff --git a/docs/server.md b/docs/server.md index cddb14a..573c846 100644 --- a/docs/server.md +++ b/docs/server.md @@ -4,7 +4,7 @@ 1. [Prompts](#prompts) 1. [Resources](#resources) 1. [Tools](#tools) -1. [Multi Round-Trip Requests (MRTR)](#multi-round-trip-requests-(mrtr)) +1. [Multi Round-Trip Requests](#multi-round-trip-requests) 1. [Talking to legacy clients](#talking-to-legacy-clients) 1. [Example](#example) 1. [Cacheable list results](#cacheable-list-results) @@ -429,7 +429,7 @@ func handleRequest(w http.ResponseWriter, r *http.Request) { } ``` -## Multi Round-Trip Requests (MRTR) +## Multi Round-Trip Requests [SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322) defines a new pattern for server-to-client requests (sampling, elicitation, diff --git a/internal/docs/client.src.md b/internal/docs/client.src.md index 7caa081..e0613eb 100644 --- a/internal/docs/client.src.md +++ b/internal/docs/client.src.md @@ -84,7 +84,7 @@ pattern. %include ../../mcp/client_example_test.go elicitation - -## Multi Round-Trip Requests (MRTR) +## Multi Round-Trip Requests [SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322) introduces the MRTR pattern: server-to-client requests for sampling, diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index fa1599d..f2e3f3a 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -214,7 +214,7 @@ func handleRequest(w http.ResponseWriter, r *http.Request) { } ``` -## Multi Round-Trip Requests (MRTR) +## Multi Round-Trip Requests [SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322) defines a new pattern for server-to-client requests (sampling, elicitation, From 65ece5964304528e7568507e2b17351824ef73fc Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Thu, 25 Jun 2026 23:13:17 +0200 Subject: [PATCH 319/418] mcp: Allow registration of custom JSON-RPC methods (#956) ## Description This PR introduces support for custom (non-standard) JSON-RPC methods in the MCP Go SDK. This feature enables servers to handle arbitrary JSON-RPC methods and clients to invoke them, all while maintaining full type safety and integrating seamlessly with the SDK's existing middleware and session management. - Server-Side Registration: Added AddReceivingCustomMethod to allow servers to register handlers for custom methods. Incoming requests are automatically unmarshaled into user-defined parameter structs. - Client-Side Calling: Added AddSendingCustomMethod which returns a strongly-typed caller function that clients can use to invoke custom methods and receive strongly-typed results. - Helper Structs: Provided ParamsBase and ResultBase for users to embed in their custom structs. This allows them to easily satisfy the SDK's internal Params and Result interfaces. Fixes #954 --- examples/server/custom-method/main.go | 110 ++++++++++++++++++++ mcp/client.go | 81 ++++++++++++++- mcp/mcp_test.go | 142 ++++++++++++++++++++++++++ mcp/server.go | 71 ++++++++++++- mcp/shared.go | 30 ++++++ mcp/streamable.go | 12 ++- 6 files changed, 442 insertions(+), 4 deletions(-) create mode 100644 examples/server/custom-method/main.go diff --git a/examples/server/custom-method/main.go b/examples/server/custom-method/main.go new file mode 100644 index 0000000..6b2861f --- /dev/null +++ b/examples/server/custom-method/main.go @@ -0,0 +1,110 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +// The custom-method example demonstrates registering and calling a custom +// JSON-RPC method that is not part of the standard MCP spec. +// +// The server registers a "latin/translate" method that translates simple +// English phrases into Latin. A client connects over an in-memory transport, +// calls the custom method, and prints the result. +package main + +import ( + "context" + "fmt" + "log" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type TranslateParams struct { + mcp.ParamsBase + Text string `json:"text"` +} + +type TranslateResult struct { + mcp.ResultBase + Latin string `json:"latin"` +} + +var translations = map[string]string{ + "hello": "salve", + "goodbye": "vale", + "thank you": "gratias tibi ago", + "how are you": "quid agis", + "good morning": "bonum mane", + "good night": "bonam noctem", + "friend": "amicus", + "water": "aqua", + "love": "amor", + "war": "bellum", + "peace": "pax", + "truth": "veritas", + "light": "lux", + "time": "tempus", + "life": "vita", + "death": "mors", + "star": "stella", + "earth": "terra", + "sea": "mare", + "the die is cast": "alea iacta est", + "i came i saw i conquered": "veni vidi vici", + "seize the day": "carpe diem", +} + +func main() { + ctx := context.Background() + + server := mcp.NewServer(&mcp.Implementation{Name: "latin-server", Version: "v1.0.0"}, nil) + + if err := mcp.AddReceivingCustomMethod(server, "latin/translate", + func(ctx context.Context, ss *mcp.ServerSession, params *TranslateParams) (*TranslateResult, error) { + key := strings.ToLower(strings.TrimSpace(params.Text)) + latin, ok := translations[key] + if !ok { + latin = fmt.Sprintf("[unknown: %q — try: %s]", params.Text, knownPhrases()) + } + return &TranslateResult{Latin: latin}, nil + }); err != nil { + log.Fatal(err) + } + + ct, st := mcp.NewInMemoryTransports() + + ss, err := server.Connect(ctx, st, nil) + if err != nil { + log.Fatal(err) + } + defer ss.Close() + + client := mcp.NewClient(&mcp.Implementation{Name: "latin-client", Version: "v1.0.0"}, nil) + if err := mcp.AddSendingCustomMethod[*TranslateParams, *TranslateResult](client, "latin/translate"); err != nil { + log.Fatal(err) + } + + cs, err := client.Connect(ctx, ct, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + phrases := []string{"Hello", "Seize the day", "Peace", "Truth", "I came I saw I conquered"} + for _, phrase := range phrases { + result, err := mcp.CallCustomMethod[*TranslateParams, *TranslateResult]( + ctx, cs, "latin/translate", &TranslateParams{Text: phrase}) + if err != nil { + log.Fatalf("translate %q: %v", phrase, err) + } + fmt.Printf("%-35s → %s\n", phrase, result.Latin) + } +} + +func knownPhrases() string { + phrases := make([]string, 0, len(translations)) + for k := range translations { + phrases = append(phrases, fmt.Sprintf("%q", k)) + } + return strings.Join(phrases, ", ") +} diff --git a/mcp/client.go b/mcp/client.go index 552b3d6..0a6c906 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -10,6 +10,8 @@ import ( "fmt" "iter" "log/slog" + "maps" + "reflect" "slices" "strings" "sync" @@ -32,6 +34,11 @@ type Client struct { sessions []*ClientSession sendingMethodHandler_ MethodHandler receivingMethodHandler_ MethodHandler + // sendMethods is the list of methods this client may send to a + // server: it always contains the standard server methods (from + // serverMethodInfos) plus any custom methods registered via + // [AddSendingCustomMethod]. + sendMethods map[string]methodInfo } // NewClient creates a new [Client]. @@ -58,12 +65,16 @@ func NewClient(impl *Implementation, options *ClientOptions) *Client { opts.Logger = ensureLogger(nil) } + sendMethods := make(map[string]methodInfo, len(serverMethodInfos)) + maps.Copy(sendMethods, serverMethodInfos) + c := &Client{ impl: impl, opts: opts, roots: newFeatureSet(func(r *Root) string { return r.URI }), sendingMethodHandler_: defaultSendingMethodHandler, receivingMethodHandler_: defaultReceivingMethodHandler[*ClientSession], + sendMethods: sendMethods, } if opts.MultiRoundTrip == nil || !opts.MultiRoundTrip.Disabled { c.AddSendingMiddleware(clientMultiRoundTripMiddleware()) @@ -504,7 +515,7 @@ func injectRequestMeta[T any, P interface { Params }](cs *ClientSession, params P) P { res := cs.state.InitializeResult - if params.isNil() { + if params == nil { params = new(T) } m := params.GetMeta() @@ -1153,7 +1164,9 @@ var clientMethodInfos = map[string]methodInfo{ } func (cs *ClientSession) sendingMethodInfos() map[string]methodInfo { - return serverMethodInfos + cs.client.mu.Lock() + defer cs.client.mu.Unlock() + return cs.client.sendMethods } func (cs *ClientSession) receivingMethodInfos() map[string]methodInfo { @@ -1597,3 +1610,67 @@ func paginate[P listParams, R listResult[T], T any](ctx context.Context, params } } } + +// AddSendingCustomMethod registers a custom JSON-RPC method +// that the client may send to the server. +// +// Registration is decoupled from invocation: extensions typically call this +// during setup, while the actual call site uses [CallCustomMethod]. +// +// if err := mcp.AddSendingCustomMethod[*SearchParams, *SearchResult](c, "acme/search"); err != nil { +// return err +// } +// // ... later, anywhere a *ClientSession is available: +// result, err := mcp.CallCustomMethod[*SearchParams, *SearchResult]( +// ctx, cs, "acme/search", &SearchParams{Query: "hello"}) +// +// AddSendingCustomMethod returns an error if method is the name of a standard +// MCP method. Registering the same method twice replaces the previous +// registration. +func AddSendingCustomMethod[P paramsPtr[PT], R Result, PT any]( + c *Client, + method string, +) error { + if _, ok := serverMethodInfos[method]; ok { + return fmt.Errorf("mcp: AddSendingCustomMethod: %q shadows a standard MCP method", method) + } + + mi := methodInfo{ + newResult: func() Result { + return reflect.New(reflect.TypeFor[R]().Elem()).Interface().(R) + }, + } + + c.mu.Lock() + defer c.mu.Unlock() + c.sendMethods[method] = mi + return nil +} + +// CallCustomMethod sends a custom (non-standard) JSON-RPC method to the +// server and decodes the response into R. +// +// The method must have been registered on the session's client via +// [AddSendingCustomMethod]. +func CallCustomMethod[P paramsPtr[PT], R Result, PT any]( + ctx context.Context, + cs *ClientSession, + method string, + params P, +) (R, error) { + c := cs.client + c.mu.Lock() + _, ok := c.sendMethods[method] + c.mu.Unlock() + if !ok { + var zero R + return zero, fmt.Errorf("mcp: CallCustomMethod: %q is not registered; call AddSendingCustomMethod first", method) + } + if cs.usesNewProtocol() { + params = injectRequestMeta(cs, params) + } + return handleSend[R](ctx, method, &ClientRequest[P]{ + Session: cs, + Params: params, + }) +} diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 82a919d..c0a9cfe 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -3233,3 +3233,145 @@ func TestSubscriptionsListen_DisconnectScrubsMaps(t *testing.T) { time.Sleep(10 * time.Millisecond) } } + +func TestCustomMethods(t *testing.T) { + type searchParams struct { + ParamsBase + Query string `json:"query"` + Limit int `json:"limit,omitempty"` + } + + type searchResult struct { + ResultBase + Hits []string `json:"hits"` + Total int `json:"total"` + } + + callCustom := func(ctx context.Context, conn *jsonrpc2.Connection, method string, params, result any) error { + return conn.Call(ctx, method, params).Await(ctx, result) + } + + ctx := context.Background() + s := NewServer(testImpl, nil) + + if err := AddReceivingCustomMethod(s, "acme/search", func(ctx context.Context, ss *ServerSession, params *searchParams) (*searchResult, error) { + hits := []string{"result for " + params.Query} + return &searchResult{ + Hits: hits, + Total: len(hits), + }, nil + }); err != nil { + t.Fatal(err) + } + + ct, st := NewInMemoryTransports() + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ss.Close() }) + + c := NewClient(testImpl, nil) + if err := AddSendingCustomMethod[*searchParams, *searchResult](c, "acme/search"); err != nil { + t.Fatal(err) + } + + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cs.Close() }) + + // Test raw JSON-RPC call. + var result1 searchResult + if err := callCustom(ctx, cs.getConn(), "acme/search", &searchParams{Query: "hello", Limit: 10}, &result1); err != nil { + t.Fatal(err) + } + if len(result1.Hits) != 1 || result1.Hits[0] != "result for hello" { + t.Errorf("raw call: unexpected hits: %v", result1.Hits) + } + if result1.Total != 1 { + t.Errorf("raw call: unexpected total: %d", result1.Total) + } + + // Test the typed CallCustomMethod helper. + result2, err := CallCustomMethod[*searchParams, *searchResult]( + ctx, cs, "acme/search", &searchParams{Query: "world"}) + if err != nil { + t.Fatal(err) + } + if len(result2.Hits) != 1 || result2.Hits[0] != "result for world" { + t.Errorf("CallCustomMethod: unexpected hits: %v", result2.Hits) + } + if result2.Total != 1 { + t.Errorf("CallCustomMethod: unexpected total: %d", result2.Total) + } + + // CallCustomMethod must reject methods that were never registered. + if _, err := CallCustomMethod[*searchParams, *searchResult]( + ctx, cs, "acme/unregistered", &searchParams{Query: "x"}); err == nil { + t.Error("CallCustomMethod: expected error for unregistered method, got nil") + } +} + +func TestAddCustomMethodRejectsStandardMethods(t *testing.T) { + type params struct{ ParamsBase } + type result struct{ ResultBase } + + t.Run("server", func(t *testing.T) { + s := NewServer(testImpl, nil) + err := AddReceivingCustomMethod(s, "tools/call", + func(ctx context.Context, ss *ServerSession, p *params) (*result, error) { + return &result{}, nil + }) + if err == nil { + t.Fatal("AddReceivingCustomMethod: expected error when shadowing a standard method, got nil") + } + }) + + t.Run("client", func(t *testing.T) { + c := NewClient(testImpl, nil) + if err := AddSendingCustomMethod[*params, *result](c, "tools/call"); err == nil { + t.Fatal("AddSendingCustomMethod: expected error when shadowing a standard method, got nil") + } + }) +} + +// TestCallCustomMethodTypedNilParams exercises the typed-nil params path. +// User param types embed ParamsBase, so the inherited isNil forwarder would +// dereference a typed-nil outer if injectRequestMeta were called with it. +// CallCustomMethod must allocate a fresh value before the meta-injection step. +func TestCallCustomMethodTypedNilParams(t *testing.T) { + type pingParams struct{ ParamsBase } + type pingResult struct{ ResultBase } + + ctx := context.Background() + s := NewServer(testImpl, nil) + if err := AddReceivingCustomMethod(s, "acme/ping", + func(ctx context.Context, ss *ServerSession, p *pingParams) (*pingResult, error) { + return &pingResult{}, nil + }); err != nil { + t.Fatal(err) + } + ct, st := NewInMemoryTransports() + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ss.Close() }) + + c := NewClient(testImpl, nil) + if err := AddSendingCustomMethod[*pingParams, *pingResult](c, "acme/ping"); err != nil { + t.Fatal(err) + } + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cs.Close() }) + + var typedNil *pingParams + if _, err := CallCustomMethod[*pingParams, *pingResult](ctx, cs, "acme/ping", typedNil); err != nil { + t.Fatalf("CallCustomMethod with typed-nil params: %v", err) + } +} diff --git a/mcp/server.go b/mcp/server.go index e07d3c2..21b5a72 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -57,6 +57,11 @@ type Server struct { resourceChangeSubscriptions map[*ServerSession]jsonrpc.ID // session -> requestID for "resources/changed" resourceSubscriptions map[string]map[*ServerSession]jsonrpc.ID // uri -> session -> requestID pendingNotifications map[string]*time.Timer // notification name -> timer for pending notification send + // receiveMethods is the merged map of methods this server may receive + // from a client: it always contains the standard server methods (from + // serverMethodInfos) plus any custom methods registered via + // [AddReceivingCustomMethod]. + receiveMethods map[string]methodInfo } // ServerOptions is used to configure behavior of the server. @@ -203,6 +208,9 @@ func NewServer(impl *Implementation, options *ServerOptions) *Server { opts.Logger = ensureLogger(nil) } + receiveMethods := make(map[string]methodInfo, len(serverMethodInfos)) + maps.Copy(receiveMethods, serverMethodInfos) + s := &Server{ impl: impl, opts: opts, @@ -217,6 +225,7 @@ func NewServer(impl *Implementation, options *ServerOptions) *Server { resourceChangeSubscriptions: make(map[*ServerSession]jsonrpc.ID), resourceSubscriptions: make(map[string]map[*ServerSession]jsonrpc.ID), pendingNotifications: make(map[string]*time.Timer), + receiveMethods: receiveMethods, } s.AddReceivingMiddleware(serverMultiRoundTripMiddleware()) return s @@ -1761,7 +1770,15 @@ func initializeMethodInfo() methodInfo { func (ss *ServerSession) sendingMethodInfos() map[string]methodInfo { return clientMethodInfos } -func (ss *ServerSession) receivingMethodInfos() map[string]methodInfo { return serverMethodInfos } +func (s *Server) receivingMethodInfos() map[string]methodInfo { + s.mu.Lock() + defer s.mu.Unlock() + return s.receiveMethods +} + +func (ss *ServerSession) receivingMethodInfos() map[string]methodInfo { + return ss.server.receivingMethodInfos() +} func (ss *ServerSession) sendingMethodHandler() MethodHandler { s := ss.server @@ -2016,3 +2033,55 @@ func paginateList[P listParams, R listResult[T], T any](fs *featureSet[T], pageS *res.nextCursorPtr() = nextCursor return res, nil } + +// AddReceivingCustomMethod registers a handler for a custom (non-standard) +// JSON-RPC method on the server. +// +// When a client sends a request with the given method name, the params will be +// unmarshaled into P, the handler will be called, and the returned R will be +// marshaled as the JSON-RPC result. +// +// Custom methods go through the server's middleware chain just like standard +// MCP methods (tools/call, prompts/list, etc.). +// +// P and R must implement [Params] and [Result] respectively, which is most +// easily done by embedding [ParamsBase] and [ResultBase]: +// +// type SearchParams struct { +// mcp.ParamsBase +// Query string `json:"query"` +// } +// +// type SearchResult struct { +// mcp.ResultBase +// Hits []string `json:"hits"` +// } +// +// if err := mcp.AddReceivingCustomMethod(server, "acme/search", +// func(ctx context.Context, ss *mcp.ServerSession, params *SearchParams) (*SearchResult, error) { +// return &SearchResult{Hits: []string{"result"}}, nil +// }); err != nil { +// return err +// } +// +// AddReceivingCustomMethod returns an error if method is the name of a +// standard MCP method. Registering the same custom method twice replaces the +// previous handler. +func AddReceivingCustomMethod[P paramsPtr[T], R Result, T any]( + s *Server, + method string, + handler func(ctx context.Context, ss *ServerSession, params P) (R, error), +) error { + if _, ok := serverMethodInfos[method]; ok { + return fmt.Errorf("mcp: AddReceivingCustomMethod: %q shadows a standard MCP method", method) + } + + typed := typedServerMethodHandler[P, R](func(ctx context.Context, req *ServerRequest[P]) (R, error) { + return handler(ctx, req.Session, req.Params) + }) + + s.mu.Lock() + defer s.mu.Unlock() + s.receiveMethods[method] = newServerMethodInfo(typed, missingParamsOK) + return nil +} diff --git a/mcp/shared.go b/mcp/shared.go index 2cf37b1..6711d30 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -727,6 +727,22 @@ type Params interface { isNil() bool } +// ParamsBase can be embedded in custom parameter structs to satisfy the +// [Params] interface. It provides the required [Meta] field and the unexported +// isParams marker method. +// +// type SearchParams struct { +// mcp.ParamsBase +// Query string `json:"query"` +// } +type ParamsBase struct { + Meta `json:"_meta,omitempty"` +} + +func (*ParamsBase) isParams() {} + +func (p *ParamsBase) isNil() bool { return p == nil } + // RequestParams is a parameter (input) type for an MCP request. type RequestParams interface { Params @@ -751,6 +767,20 @@ type Result interface { SetMeta(map[string]any) } +// ResultBase can be embedded in custom result structs to satisfy the +// [Result] interface. It provides the required [Meta] field and the unexported +// isResult marker method. +// +// type SearchResult struct { +// mcp.ResultBase +// Hits []string `json:"hits"` +// } +type ResultBase struct { + Meta `json:"_meta,omitempty"` +} + +func (*ResultBase) isResult() {} + // emptyResult is returned by methods that have no result, like ping. // Those methods cannot return nil, because jsonrpc2 cannot handle nils. type emptyResult struct{} diff --git a/mcp/streamable.go b/mcp/streamable.go index b71bc77..c047c15 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -486,6 +486,7 @@ func connectStreamable(ctx context.Context, server *Server, transport *Streamabl if err != nil { return nil, err } + transport.connection.server = server transport.connection.toolLookup = server.getServerTool return s, nil } @@ -846,6 +847,7 @@ type streamableServerConn struct { logger *slog.Logger + server *Server toolLookup func(name string) (*serverTool, bool) incoming chan jsonrpc.Message // messages from the client to the server @@ -1415,7 +1417,15 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // Preemptively check that this is a valid request, so that we can fail // the HTTP request. If we didn't do this, a request with a bad method or // missing ID could be silently swallowed. - if _, err := checkRequest(jreq, serverMethodInfos); err != nil { + // Use the server's receiving method infos (which include any custom + // methods registered via AddReceivingCustomMethod) when available; + // fall back to the standard methods otherwise, e.g. in tests that + // exercise streamableServerConn directly without a server. + methodInfos := serverMethodInfos + if c.server != nil { + methodInfos = c.server.receivingMethodInfos() + } + if _, err := checkRequest(jreq, methodInfos); err != nil { if protocolVersion >= protocolVersion20260728 && errors.Is(err, jsonrpc2.ErrNotHandled) && jreq.IsCall() { writeJSONRPCError(w, http.StatusNotFound, jreq.ID, &jsonrpc.Error{ Code: jsonrpc.CodeMethodNotFound, From ede189417480ae3026a2f8821337929618896854 Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Thu, 2 Jul 2026 09:22:05 +0200 Subject: [PATCH 320/418] fix: extra wrapper for input response map (#1045) fixes #1036 --- mcp/protocol.go | 94 +++++++++------------- mcp/protocol_test.go | 37 +++++++++ mcp/testdata/conformance/server/mrtr.txtar | 5 +- 3 files changed, 78 insertions(+), 58 deletions(-) diff --git a/mcp/protocol.go b/mcp/protocol.go index f077c6d..c187741 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -119,72 +119,58 @@ type InputResponse interface{ isInputResponse() } // input-required result. type InputResponseMap map[string]InputResponse -func (m InputResponseMap) MarshalJSON() ([]byte, error) { - type wire struct { - Method string `json:"method"` - Result InputResponse `json:"result,omitempty"` - } - typeToMethod := func(v InputResponse) (string, error) { - switch v.(type) { - case *ElicitResult: - return methodElicit, nil - case *CreateMessageResult, *CreateMessageWithToolsResult: - return methodCreateMessage, nil - case *ListRootsResult: - return methodListRoots, nil - default: - return "", fmt.Errorf("unsupported type: %T", v) - } - } - converted := map[string]*wire{} - for k, v := range m { - method, err := typeToMethod(v) - if err != nil { - return nil, err - } - converted[k] = &wire{Method: method, Result: v} - } - return json.Marshal(converted) -} - func (m *InputResponseMap) UnmarshalJSON(data []byte) error { - type raw struct { - Method string `json:"method"` - Result json.RawMessage `json:"result"` - } - var rawMap map[string]*raw + var rawMap map[string]json.RawMessage if err := json.Unmarshal(data, &rawMap); err != nil { return err } result := make(InputResponseMap, len(rawMap)) for k, raw := range rawMap { - switch raw.Method { - case methodElicit: - var p ElicitResult - if err := json.Unmarshal(raw.Result, &p); err != nil { - return err - } - result[k] = &p - case methodCreateMessage: - var p CreateMessageWithToolsResult - if err := json.Unmarshal(raw.Result, &p); err != nil { - return err - } - result[k] = &p - case methodListRoots: - var p ListRootsResult - if err := json.Unmarshal(raw.Result, &p); err != nil { - return err - } - result[k] = &p - default: - return fmt.Errorf("unsupported InputResponse method: %q", raw.Method) + v, err := unmarshalInputResponse(raw) + if err != nil { + return fmt.Errorf("inputResponses[%q]: %w", k, err) } + result[k] = v } *m = result return nil } +// unmarshalInputResponse determines the concrete InputResponse type from the +// JSON structure by searching for a discriminating key in a raw message. +func unmarshalInputResponse(data json.RawMessage) (InputResponse, error) { + var probe struct { + Action json.RawMessage `json:"action"` + Role json.RawMessage `json:"role"` + Roots json.RawMessage `json:"roots"` + } + if err := json.Unmarshal(data, &probe); err != nil { + return nil, err + } + switch { + case probe.Roots != nil: + var p ListRootsResult + if err := json.Unmarshal(data, &p); err != nil { + return nil, err + } + return &p, nil + case probe.Action != nil: + var p ElicitResult + if err := json.Unmarshal(data, &p); err != nil { + return nil, err + } + return &p, nil + case probe.Role != nil: + var p CreateMessageWithToolsResult + if err := json.Unmarshal(data, &p); err != nil { + return nil, err + } + return &p, nil + default: + return nil, fmt.Errorf(`cannot determine InputResponse type: expected "action", "role", or "roots" key`) + } +} + // Optional annotations for the client. The client can use annotations to inform // how objects are used or displayed. type Annotations struct { diff --git a/mcp/protocol_test.go b/mcp/protocol_test.go index edf3623..38fd48f 100644 --- a/mcp/protocol_test.go +++ b/mcp/protocol_test.go @@ -1216,6 +1216,43 @@ func TestInputRequestMapJSON(t *testing.T) { }) } +func TestInputResponseMapJSON(t *testing.T) { + tests := []struct { + name string + value InputResponse + check func(t *testing.T, got InputResponse) + }{ + { + name: "elicit", + value: &ElicitResult{Action: "accept", Content: map[string]any{"ok": true}}, + }, + { + name: "sampling", + value: &CreateMessageWithToolsResult{Role: "assistant", Model: "test-model", Content: []Content{&TextContent{Text: "hello"}}}, + }, + { + name: "list-roots", + value: &ListRootsResult{Roots: []*Root{{URI: "file:///tmp", Name: "tmp"}}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + key := tt.name + data, err := json.Marshal(InputResponseMap{key: tt.value}) + if err != nil { + t.Fatal(err) + } + var got InputResponseMap + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(tt.value, got[key], ctrCmpOpts...); diff != "" { + t.Errorf("mismatch (-want, +got):\n%s", diff) + } + }) + } +} + func TestContentUnmarshal(t *testing.T) { // Verify that types with a Content field round-trip properly. roundtrip := func(in, out any) { diff --git a/mcp/testdata/conformance/server/mrtr.txtar b/mcp/testdata/conformance/server/mrtr.txtar index e0dc18c..7ee6dc5 100644 --- a/mcp/testdata/conformance/server/mrtr.txtar +++ b/mcp/testdata/conformance/server/mrtr.txtar @@ -36,10 +36,7 @@ confirmThenGreet "arguments": {}, "requestState": "step=1", "inputResponses": { - "who": { - "method": "elicitation/create", - "result": { "action": "accept", "content": { "name": "MCP Go" } } - } + "who": { "action": "accept", "content": { "name": "MCP Go" } } } } } From b03fae31552560224f7048b4e873acf99b792e19 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 08:41:15 +0000 Subject: [PATCH 321/418] build(deps): bump github/codeql-action/upload-sarif from 4.36.0 to 4.36.2 (#1039) Bumps [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) from 4.36.0 to 4.36.2.
Release notes

Sourced from github/codeql-action/upload-sarif's releases.

v4.36.2

  • Cache CodeQL CLI version information across Actions steps. #3943
  • Reduce requests while waiting for analysis processing by using exponential backoff when polling SARIF processing status. #3937
  • Update default CodeQL bundle version to 2.25.6. #3948

v4.36.1

No user facing changes.

Changelog

Sourced from github/codeql-action/upload-sarif's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

No user facing changes.

4.36.2 - 04 Jun 2026

  • Cache CodeQL CLI version information across Actions steps. #3943
  • Reduce requests while waiting for analysis processing by using exponential backoff when polling SARIF processing status. #3937
  • Update default CodeQL bundle version to 2.25.6. #3948

4.36.1 - 02 Jun 2026

No user facing changes.

4.36.0 - 22 May 2026

  • Breaking change: Bump the minimum required CodeQL bundle version to 2.19.4. #3894
  • Add support for SHA-256 Git object IDs. #3893
  • Update default CodeQL bundle version to 2.25.5. #3926

4.35.5 - 15 May 2026

  • We have improved how the JavaScript bundles for the CodeQL Action are generated to avoid duplication across bundles and reduce the size of the repository by around 70%. This should have no effect on the runtime behaviour of the CodeQL Action. #3899
  • For performance and accuracy reasons, improved incremental analysis will now only be enabled on a pull request when diff-informed analysis is also enabled for that run. If diff-informed analysis is unavailable (for example, because the PR diff ranges could not be computed), the action will fall back to a full analysis. #3791
  • If multiple inputs are provided for the GitHub-internal analysis-kinds input, only code-scanning will be enabled. The analysis-kinds input is experimental, for GitHub-internal use only, and may change without notice at any time. #3892
  • Added an experimental change which, when running a Code Scanning analysis for a PR with improved incremental analysis enabled, prefers CodeQL CLI versions that have a cached overlay-base database for the configured languages. This speeds up analysis for a repository when there is not yet a cached overlay-base database for the latest CLI version. We expect to roll this change out to everyone in May. #3880

4.35.4 - 07 May 2026

  • Update default CodeQL bundle version to 2.25.4. #3881

4.35.3 - 01 May 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.19.3 and earlier. These versions of CodeQL were discontinued on 9 April 2026 alongside GitHub Enterprise Server 3.15, and will be unsupported by the next minor release of the CodeQL Action. #3837
  • Configurations for private registries that use Cloudsmith or GCP OIDC are now accepted. #3850
  • Best-effort connection tests for private registries now use GET requests instead of HEAD for better compatibility with various registry implementations. For NuGet feeds, the test is now always performed against the service index. #3853
  • Fixed a bug where two diagnostics produced within the same millisecond could overwrite each other on disk, causing one of them to be lost. #3852
  • Update default CodeQL bundle version to 2.25.3. #3865

4.35.2 - 15 Apr 2026

  • The undocumented TRAP cache cleanup feature that could be enabled using the CODEQL_ACTION_CLEANUP_TRAP_CACHES environment variable is deprecated and will be removed in May 2026. If you are affected by this, we recommend disabling TRAP caching by passing the trap-caching: false input to the init Action. #3795
  • The Git version 2.36.0 requirement for improved incremental analysis now only applies to repositories that contain submodules. #3789
  • Python analysis on GHES no longer extracts the standard library, relying instead on models of the standard library. This should result in significantly faster extraction and analysis times, while the effect on alerts should be minimal. #3794
  • Fixed a bug in the validation of OIDC configurations for private registries that was added in CodeQL Action 4.33.0 / 3.33.0. #3807
  • Update default CodeQL bundle version to 2.25.2. #3823

... (truncated)

Commits
  • 8aad20d Merge pull request #3949 from github/update-v4.36.2-dcb947ce1
  • f521b08 Add additional changelog notes
  • 8aeff0f Update changelog for v4.36.2
  • dcb947c Merge pull request #3948 from github/update-bundle/codeql-bundle-v2.25.6
  • c251bce Add changelog note
  • 62953c1 Update default bundle to codeql-bundle-v2.25.6
  • 423b570 Merge pull request #3946 from github/dependabot/npm_and_yarn/npm-minor-5d507a...
  • c35d1b1 Merge pull request #3947 from github/dependabot/github_actions/dot-github/wor...
  • cb1a588 Merge pull request #3937 from github/robertbrignull/waitForProcessing_backoff
  • ba47406 Merge pull request #3943 from github/henrymercer/cache-cli-version-info
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github/codeql-action/upload-sarif&package-manager=github_actions&previous-version=4.36.0&new-version=4.36.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/scorecard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index c8035cb..d5a9351 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -73,6 +73,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: sarif_file: results.sarif From b53d2e3dc357175aefddfa8da132444fb0e98b4a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 08:44:16 +0000 Subject: [PATCH 322/418] build(deps): bump actions/setup-python from 6.2.0 to 6.3.0 (#1038) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6.2.0 to 6.3.0.
Release notes

Sourced from actions/setup-python's releases.

v6.3.0

What's Changed

Enhancement

Dependency update

Documentation

New Contributors

Full Changelog: https://github.com/actions/setup-python/compare/v6...v6.3.0

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/setup-python&package-manager=github_actions&previous-version=6.2.0&new-version=6.3.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Guglielmo Colombo --- .github/workflows/publish-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index 202734f..53cae75 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -18,7 +18,7 @@ jobs: run: | git config user.name github-actions[bot] git config user.email 41898282+github-actions[bot]@users.noreply.github.com - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: 3.x - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV From a901f97b6d2de36207222f0f832fb7a7efca7e76 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 09:09:59 +0000 Subject: [PATCH 323/418] build(deps): bump actions/checkout from 6.0.2 to 7.0.0 (#1040) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.2 to 7.0.0.
Release notes

Sourced from actions/checkout's releases.

v7.0.0

What's Changed

New Contributors

Full Changelog: https://github.com/actions/checkout/compare/v6.0.3...v7.0.0

v6.0.3

What's Changed

New Contributors

Full Changelog: https://github.com/actions/checkout/compare/v6...v6.0.3

Changelog

Sourced from actions/checkout's changelog.

Changelog

v7.0.0

v6.0.3

v6.0.2

v6.0.1

v6.0.0

v5.0.1

v5.0.0

v4.3.1

v4.3.0

v4.2.2

v4.2.1

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/checkout&package-manager=github_actions&previous-version=6.0.2&new-version=7.0.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Guglielmo Colombo --- .github/workflows/codeql.yml | 2 +- .github/workflows/conformance.yml | 4 ++-- .github/workflows/docs-check.yml | 2 +- .github/workflows/nightly.yml | 2 +- .github/workflows/publish-docs.yml | 2 +- .github/workflows/scorecard.yml | 2 +- .github/workflows/test.yml | 6 +++--- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ca0d732..ecc3a39 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -36,7 +36,7 @@ jobs: build-mode: autobuild steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Initialize CodeQL uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 105cf2e..58280ba 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: @@ -59,7 +59,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index e5bb426..3c18c84 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -20,7 +20,7 @@ jobs: with: go-version: "^1.26" - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Check docs are up-to-date run: | go generate ./... diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index d92dc93..3dd3cee 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index 53cae75..d4beb3c 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -13,7 +13,7 @@ jobs: contents: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Configure Git Credentials run: | git config user.name github-actions[bot] diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index d5a9351..563e21a 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -34,7 +34,7 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2008bfd..a708cac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: @@ -43,7 +43,7 @@ jobs: go: ["1.25", "1.26"] steps: - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: @@ -55,7 +55,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: From 224a240875caa1268e459f312ac46f15cab20c8b Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Mon, 6 Jul 2026 17:10:26 +0200 Subject: [PATCH 324/418] mcp: do not exclude Notifications from carrying InitializeParams in Meta (#1049) Fixes #1043 --- mcp/shared.go | 13 ++++--------- mcp/shared_test.go | 26 ++++---------------------- 2 files changed, 8 insertions(+), 31 deletions(-) diff --git a/mcp/shared.go b/mcp/shared.go index 6711d30..c5d81e6 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -515,11 +515,10 @@ type validatedMeta struct { // the >= 2026-07-28 protocol via the `_meta` field. // If the request has no _meta, or no protocolVersion in _meta, it returns a non-nil // validatedMeta with usesNewProtocol set to false, and a nil error. -// If the request has a protocolVersion in _meta: -// - For notifications, it returns usesNewProtocol set to true and a nil initializeParams. -// - For call requests, it validates the presence of clientInfo and clientCapabilities in _meta. -// If either is missing or invalid, it returns nil and a non-nil error. Otherwise, it returns -// usesNewProtocol set to true and the populated initializeParams. +// If the request has a protocolVersion in _meta it validates the presence of clientInfo +// and clientCapabilities in _meta. If either is missing or invalid, it returns nil and +// a non-nil error. Otherwise, it returns usesNewProtocol set to true and the populated +// initializeParams. func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) { meta := extractRequestMeta(req.Params) if meta == nil { @@ -529,10 +528,6 @@ func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) { if !ok || protocolVersion < protocolVersion20260728 { return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil } - // Notifications do not carry full client identity. - if !req.IsCall() { - return &validatedMeta{usesNewProtocol: true, initializeParams: nil}, nil - } clientInfo, ok := decodeMetaValue[*Implementation](meta, MetaKeyClientInfo) if !ok { return nil, &jsonrpc.Error{ diff --git a/mcp/shared_test.go b/mcp/shared_test.go index 08c202a..1d0ec70 100644 --- a/mcp/shared_test.go +++ b/mcp/shared_test.go @@ -18,7 +18,6 @@ func TestValidateRequestMeta(t *testing.T) { tests := []struct { name string method string - isNotification bool params any wantUsesNew bool wantLogLevel LoggingLevel @@ -84,18 +83,6 @@ func TestValidateRequestMeta(t *testing.T) { wantUsesNew: false, wantErrContains: MetaKeyClientCapabilities, }, - { - name: "notifications exempt from required fields", - method: notificationCancelled, - isNotification: true, - params: map[string]any{ - "_meta": map[string]any{ - MetaKeyProtocolVersion: protocolVersion20260728, - }, - "requestId": "r1", - }, - wantUsesNew: true, - }, { name: "malformed _meta is ignored", method: methodCallTool, @@ -141,16 +128,11 @@ func TestValidateRequestMeta(t *testing.T) { 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 + id, err := jsonrpc.MakeID("test") + if err != nil { + t.Fatal(err) } + req := &jsonrpc.Request{Method: tc.method, Params: raw, ID: id} vmeta, err := validateRequestMeta(req) usesNew := vmeta != nil && vmeta.usesNewProtocol From ec564f9ccb6e9d6d1abe31f974a552acc23e1355 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Mon, 6 Jul 2026 17:40:27 +0200 Subject: [PATCH 325/418] mcp: cap negotiated protocolVersion in legacy initialize to `protocolVersion20251125` (#1051) Fixes #1033 --- mcp/shared.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/mcp/shared.go b/mcp/shared.go index c5d81e6..0fc19c5 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -57,15 +57,13 @@ func negotiatedVersion(clientVersion string) string { // In general, prefer to use the clientVersion, but if we don't support the // client's version, use the latest version. // - // This handles the case where a new spec version is released, and the SDK - // does not support it yet. // Cap the supported versions at the legacy protocolVersion20251125, as this // method is used by the initialize method which is deprecated in // version protocolVersion20260728. - if !slices.Contains(supportedProtocolVersions, clientVersion) { - return protocolVersion20251125 + if slices.Contains(supportedProtocolVersions, clientVersion) && clientVersion < protocolVersion20260728 { + return clientVersion } - return clientVersion + return protocolVersion20251125 } // negotiateMutuallySupportedVersion returns a protocol version that is supported From d651e2d2e110a3e393f6fdd2150c8ee609c2710b Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Tue, 7 Jul 2026 11:39:18 +0200 Subject: [PATCH 326/418] mcp: make NotificationSubscriptions a mandatory field (#1050) Fixes #1037 --- mcp/client.go | 6 ++++-- mcp/mcp_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ mcp/protocol.go | 2 +- mcp/server.go | 11 +++++++++-- 4 files changed, 56 insertions(+), 5 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index 0a6c906..52c1e06 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -330,7 +330,9 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp if hc, ok := cs.mcpConn.(clientConnection); ok { hc.sessionUpdated(cs.state) } - subscribeParams := &SubscriptionsListenParams{} + subscribeParams := &SubscriptionsListenParams{ + Notifications: &NotificationSubscriptions{}, + } if c.opts.ToolListChangedHandler != nil { subscribeParams.Notifications.ToolsListChanged = true } @@ -1393,7 +1395,7 @@ func (cs *ClientSession) Subscribe(ctx context.Context, params *SubscribeParams) } return cs.subscriptionsListen(listenCtx, &SubscriptionsListenParams{ - Notifications: NotificationSubscriptions{ + Notifications: &NotificationSubscriptions{ ResourceSubscriptions: []string{uri}, }, }) diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index c0a9cfe..97be926 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -2652,6 +2652,48 @@ func TestSubscriptionsListen_NoHandlersNoListen(t *testing.T) { } } +// TestSubscriptionsListen_MissingNotifications verifies that a +// subscriptions/listen request without the required "notifications" field +// is rejected with an invalid params error. +func TestSubscriptionsListen_MissingNotifications(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + server := newSubListenServer() + _, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + defer ss.Close() + + // Invoke the server handler directly with a nil Notifications field, + // simulating a request whose params omit the required field on the wire. + // The client-side subscriptionsListen does not await the RPC response + // (the call's lifetime is the notification stream), so we assert the + // server-side behavior at the handler level. + id, err := jsonrpc.MakeID("test-1") + if err != nil { + t.Fatalf("MakeID: %v", err) + } + reqCtx := context.WithValue(ctx, idContextKey{}, id) + req := &SubscriptionsListenRequest{ + Session: ss, + Params: &SubscriptionsListenParams{}, // Notifications is nil + } + _, err = server.subscriptionsListen(reqCtx, req) + if err == nil { + t.Fatal("expected error for missing notifications field, got nil") + } + 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) + } +} + // resourceSubServer builds a server that advertises resource subscriptions // and records every Subscribe/Unsubscribe handler invocation through chans. func resourceSubServer(t *testing.T, subCh, unsubCh chan string) *Server { diff --git a/mcp/protocol.go b/mcp/protocol.go index c187741..a68ef42 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -2063,7 +2063,7 @@ type SubscriptionsListenParams struct { Meta `json:"_meta,omitempty"` // Notifications declares which notification types the client wants to // receive on this stream. - Notifications NotificationSubscriptions `json:"notifications"` + Notifications *NotificationSubscriptions `json:"notifications"` } func (x *SubscriptionsListenParams) isParams() {} diff --git a/mcp/server.go b/mcp/server.go index 21b5a72..5fa57f7 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1169,6 +1169,10 @@ func (s *Server) subscriptionsListen(ctx context.Context, req *SubscriptionsList return nil, fmt.Errorf("%w: subscriptions/listen requires a request ID", jsonrpc2.ErrInvalidRequest) } + if req.Params.Notifications == nil { + return nil, fmt.Errorf("%w: missing required 'notifications' field", jsonrpc2.ErrInvalidParams) + } + allowed := s.allowedSubscriptions(req.Params.Notifications) s.mu.Lock() if allowed.ToolsListChanged { @@ -1217,11 +1221,14 @@ func (s *Server) subscriptionsListen(ctx context.Context, req *SubscriptionsList return nil, fmt.Errorf("sending subscriptions/acknowledged: %w", err) } - <-ctx.Done() + // If there are any active subscriptions, we block until the context is cancelled. Otherwise, we return immediately. + if len(allowed.ResourceSubscriptions) > 0 || allowed.ToolsListChanged || allowed.PromptsListChanged || allowed.ResourcesListChanged { + <-ctx.Done() + } return &emptyResult{}, nil } -func (s *Server) allowedSubscriptions(want NotificationSubscriptions) NotificationSubscriptions { +func (s *Server) allowedSubscriptions(want *NotificationSubscriptions) NotificationSubscriptions { caps := s.capabilities() agreed := NotificationSubscriptions{} if want.ToolsListChanged && caps.Tools != nil && caps.Tools.ListChanged { From 6de85ec3ed6590e26085abb0b145e91373e2672f Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Tue, 7 Jul 2026 13:43:10 +0200 Subject: [PATCH 327/418] mcp: do not enforce TTL on cached tool (#1053) This PR removes the TTL enforcing on the cached tools, when used to generate the http-parameters for CallTool, according to updated [spec](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2972) > Clients **MUST** construct `Mcp-Param-*` headers using the most recently obtained `inputSchema` for the tool. --- mcp/cache.go | 12 ------------ mcp/client.go | 17 +++++++---------- 2 files changed, 7 insertions(+), 22 deletions(-) diff --git a/mcp/cache.go b/mcp/cache.go index 6aa3f61..9837337 100644 --- a/mcp/cache.go +++ b/mcp/cache.go @@ -54,18 +54,6 @@ func (mc *methodCache[R]) put(key string, result R) { } } -func (mc *methodCache[R]) forEachValid(f func(R)) { - mc.mu.Lock() - defer mc.mu.Unlock() - for key, entry := range mc.cachedValues { - if !entry.isValid() { - delete(mc.cachedValues, key) - continue - } - f(entry.result) - } -} - func (mc *methodCache[R]) invalidate() { mc.mu.Lock() defer mc.mu.Unlock() diff --git a/mcp/client.go b/mcp/client.go index 52c1e06..4c0a458 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -584,19 +584,16 @@ func (cs *ClientSession) Wait() error { // outgoing request context for transport-layer features (e.g. x-mcp-header // param annotations). func (cs *ClientSession) lookupTool(name string) *Tool { - var found *Tool - cs.toolsCache.forEachValid(func(r *ListToolsResult) { - if found != nil { - return - } - for _, t := range r.Tools { + cs.toolsCache.mu.Lock() + defer cs.toolsCache.mu.Unlock() + for _, entry := range cs.toolsCache.cachedValues { + for _, t := range entry.result.Tools { if t.Name == name { - found = t - return + return t } } - }) - return found + } + return nil } // registerElicitationWaiter registers a waiter for an elicitation complete From 4c2aece5d7f2e6185cc8867492edaeb7526fd67e Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Tue, 7 Jul 2026 17:57:21 +0200 Subject: [PATCH 328/418] mcp: add integration for server conformance tests (#1054) This PR integrates the missing tools to fully support the new protocolVersion on conformance tests. --- .github/workflows/conformance.yml | 6 +- conformance/everything-server/main.go | 580 +++++++++++++++++++++++++- 2 files changed, 562 insertions(+), 24 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 58280ba..cca700d 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -14,7 +14,7 @@ permissions: contents: read env: - CONFORMANCE_VERSION: "0.2.0-alpha.5" + CONFORMANCE_VERSION: "0.2.0-alpha.9" jobs: server-conformance: @@ -43,7 +43,7 @@ jobs: --suite active \ --spec-version 2025-11-25 \ --expected-failures ./conformance/baseline.yml - - name: Run conformance tests (stateless, 2026-07-28) + - name: Run conformance tests (stateless, 2026-07-28, full suite) run: | ./everything-server -http=":3001" -stateless & SERVER_PID=$! @@ -51,7 +51,7 @@ jobs: timeout 30 bash -c 'until curl -s http://localhost:3001/mcp > /dev/null; do sleep 0.5; done' npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \ --url http://localhost:3001/mcp \ - --suite active \ + --suite all \ --spec-version 2026-07-28 \ --expected-failures ./conformance/baseline.yml diff --git a/conformance/everything-server/main.go b/conformance/everything-server/main.go index d74dddf..c532d43 100644 --- a/conformance/everything-server/main.go +++ b/conformance/everything-server/main.go @@ -19,9 +19,11 @@ import ( "log" "net/http" "os" + "strings" "time" "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/yosida95/uritemplate/v3" ) @@ -154,14 +156,19 @@ func registerTools(server *mcp.Server) { Description: "Tests elicitation with enum schema improvements per SEP-1330", }, testElicitationEnumsHandler) + // SEP-1613 / SEP-2106: JSON Schema 2020-12 conformance test tool. + // The scenario verifies that $schema/$defs/additionalProperties (SEP-1613) + // and the broader 2020-12 vocabulary — $anchor, allOf/anyOf, if/then/else — + // (SEP-2106) survive tools/list verbatim. mcp.AddTool(server, &mcp.Tool{ Name: "json_schema_2020_12_tool", - Description: "Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613)", + Description: "Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613, SEP-2106)", InputSchema: json.RawMessage(`{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "$defs": { "address": { + "$anchor": "addressDef", "type": "object", "properties": { "street": { "type": "string" }, @@ -171,8 +178,20 @@ func registerTools(server *mcp.Server) { }, "properties": { "name": { "type": "string" }, - "address": { "$ref": "#/$defs/address" } + "address": { "$ref": "#/$defs/address" }, + "contactMethod": { "type": "string", "enum": ["phone", "email"] }, + "phone": { "type": "string" }, + "email": { "type": "string" } }, + "allOf": [ + { "anyOf": [{ "required": ["phone"] }, { "required": ["email"] }] } + ], + "if": { + "properties": { "contactMethod": { "const": "phone" } }, + "required": ["contactMethod"] + }, + "then": { "required": ["phone"] }, + "else": { "required": ["email"] }, "additionalProperties": false }`), }, jsonSchema202012Handler) @@ -181,6 +200,87 @@ func registerTools(server *mcp.Server) { Name: "test_reconnection", Description: "Tests SSE stream disconnection and client reconnection (SEP-1699). Server will close the stream mid-call and send the result after client reconnects.", }, testReconnectionHandler) + + // SEP-2243 x-mcp-header tool — arms the http-custom-header-server-validation + // conformance scenario (which skips when no tool with an x-mcp-header + // annotation is found). + mcp.AddTool(server, &mcp.Tool{ + Name: "test_x_mcp_header", + Description: "Tests SEP-2243 Mcp-Param-* server-side validation", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "region": { "type": "string", "description": "mirrored into Mcp-Param-Region", "x-mcp-header": "Region" }, + "level": { "type": "integer", "description": "non-mirrored argument" } + } + }`), + }, testXMcpHeaderHandler) + + // SEP-2575 diagnostic tools used by the stateless conformance scenario. + mcp.AddTool(server, &mcp.Tool{ + Name: "test_missing_capability", + Description: "Requires the sampling client capability; used to verify MissingRequiredClientCapabilityError (-32021) (SEP-2575)", + }, testMissingCapabilityHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_streaming_elicitation", + Description: "Streams progress notifications while a call is in flight; used to verify response streams carry only notifications, never independent JSON-RPC requests (SEP-2575)", + }, testStreamingElicitationHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_logging_tool", + Description: "Attempts to emit a log message; the framework must drop it when the client did not set _meta.../logLevel (SEP-2575)", + }, testLoggingToolHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_trigger_tool_change", + Description: "Mutates the tool list to trigger a notifications/tools/list_changed on active subscription streams (SEP-2575)", + }, makeTriggerToolChangeHandler(server)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_trigger_prompt_change", + Description: "Mutates the prompt list to trigger a notifications/prompts/list_changed on active subscription streams (SEP-2575)", + }, makeTriggerPromptChangeHandler(server)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_input_required_result_elicitation", + Description: "MRTR (SEP-2322): asks for the caller name via an in-band elicitation request", + }, testInputRequiredElicitationHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_input_required_result_sampling", + Description: "MRTR (SEP-2322): asks for an LLM completion via an in-band sampling request", + }, testInputRequiredSamplingHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_input_required_result_list_roots", + Description: "MRTR (SEP-2322): asks for the client roots via an in-band roots/list request", + }, testInputRequiredListRootsHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_input_required_result_request_state", + Description: "MRTR (SEP-2322): round-trips integrity-protected requestState alongside an elicitation request", + }, testInputRequiredRequestStateHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_input_required_result_multiple_inputs", + Description: "MRTR (SEP-2322): asks for elicitation, sampling and roots input in a single round", + }, testInputRequiredMultipleInputsHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_input_required_result_multi_round", + Description: "MRTR (SEP-2322): two elicitation rounds with evolving requestState before completing", + }, testInputRequiredMultiRoundHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_input_required_result_tampered_state", + Description: "MRTR (SEP-2322): rejects retries whose requestState fails integrity verification", + }, testInputRequiredTamperedStateHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: "test_input_required_result_capabilities", + Description: "MRTR (SEP-2322): only requests input kinds the declared client capabilities cover", + }, testInputRequiredCapabilitiesHandler) } // Tool handlers @@ -477,33 +577,466 @@ func testElicitationEnumsHandler(ctx context.Context, req *mcp.CallToolRequest, }, nil, nil } -type jsonSchemaAddress struct { - Street string `json:"street"` - City string `json:"city"` -} - -type jsonSchemaInput struct { - Name string `json:"name"` - Address *jsonSchemaAddress `json:"address"` -} - -func jsonSchema202012Handler(ctx context.Context, req *mcp.CallToolRequest, input jsonSchemaInput) (*mcp.CallToolResult, any, error) { - // Echo back the arguments received - var addressStr string - if input.Address != nil { - addressStr = fmt.Sprintf("{street: %q, city: %q}", input.Address.Street, input.Address.City) - } else { - addressStr = "nil" - } +func jsonSchema202012Handler(ctx context.Context, req *mcp.CallToolRequest, input json.RawMessage) (*mcp.CallToolResult, any, error) { return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{ - Text: fmt.Sprintf("Received: name=%q, address=%s", input.Name, addressStr), + Text: fmt.Sprintf("JSON Schema 2020-12 tool called with: %s", input), }, }, }, nil, nil } +type testXMcpHeaderInput struct { + Region string `json:"region,omitempty"` + Level int `json:"level,omitempty"` +} + +func testXMcpHeaderHandler(ctx context.Context, req *mcp.CallToolRequest, input testXMcpHeaderInput) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("region=%s", input.Region)}, + }, + }, nil, nil +} + +// testMissingCapabilityHandler requires the client to have declared the +// sampling capability. When absent, it returns a MissingRequiredClientCapabilityError +// with data.requiredCapabilities = {"sampling": {}} per SEP-2575. +func testMissingCapabilityHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + caps := req.ClientCapabilities() + if caps != nil && caps.Sampling != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "Client declared the sampling capability; tool executed."}, + }, + }, nil, nil + } + missingCapabilityData := mcp.MissingRequiredClientCapabilityData{ + RequiredCapabilities: &mcp.ClientCapabilities{ + Sampling: &mcp.SamplingCapabilities{}, + }, + } + dataBytes, err := json.Marshal(missingCapabilityData) + if err != nil { + return nil, nil, err + } + return nil, nil, &jsonrpc.Error{ + Code: mcp.CodeMissingRequiredClientCapabilities, + Message: "sampling capability required but not declared by client", + Data: dataBytes, + } +} + +// testStreamingElicitationHandler returns a plain result. Per SEP-2575, the +// response stream must carry no independent top-level JSON-RPC requests; a +// plain response trivially satisfies this. The scenario declares no +// `elicitation` capability, so this handler must not call req.Session.Elicit. +func testStreamingElicitationHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "stream observed: result frames only, no top-level requests"}, + }, + }, nil, nil +} + +// testLoggingToolHandler attempts to emit a log message. The SDK's +// ServerSession.Log gates on the per-request _meta.../logLevel, so this is a +// no-op notification-wise when the client did not opt in. +func testLoggingToolHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + req.Session.Log(ctx, &mcp.LoggingMessageParams{ + Level: "info", + Data: "test_logging_tool executed", + }) + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "Log attempted; framework gates on _meta.../logLevel."}, + }, + }, nil, nil +} + +// makeTriggerToolChangeHandler returns a handler that (re-)registers a +// no-op transient tool. Every call to Server.AddTool dispatches a +// notifications/tools/list_changed to active subscription streams (even when +// it replaces a tool with the same name), which is what the SEP-2575 +// subscription checks assert. +func makeTriggerToolChangeHandler(server *mcp.Server) mcp.ToolHandlerFor[any, any] { + return func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + mcp.AddTool(server, &mcp.Tool{ + Name: "__transient_tool_for_list_changed", + Description: "Transient tool used to trigger tools/list_changed", + }, func(context.Context, *mcp.CallToolRequest, any) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{}, nil, nil + }) + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "tools_list_changed published"}, + }, + }, nil, nil + } +} + +// makeTriggerPromptChangeHandler is the prompt analogue of +// makeTriggerToolChangeHandler. +func makeTriggerPromptChangeHandler(server *mcp.Server) mcp.ToolHandlerFor[any, any] { + return func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + server.AddPrompt(&mcp.Prompt{ + Name: "__transient_prompt_for_list_changed", + Description: "Transient prompt used to trigger prompts/list_changed", + }, func(context.Context, *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return &mcp.GetPromptResult{}, nil + }) + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "prompts_list_changed published"}, + }, + }, nil, nil + } +} + +// ============================================================================= +// SEP-2322 Multi-Round-Trip helpers +// ============================================================================= + +// requestStateTamperedSuffix is what the tampered-state conformance scenario +// appends to a valid requestState. Detecting this suffix is sufficient to +// pass the check; a production server would use a real integrity mechanism +// (HMAC / AEAD, per SEP-2322 § "Server Requirements"). +const requestStateTamperedSuffix = "-TAMPERED" + +// requestStateValid reports whether the state looks like one this server +// minted (i.e. the harness did not append the tampered marker). +func requestStateValid(state string) bool { + return state != "" && !strings.HasSuffix(state, requestStateTamperedSuffix) +} + +// acceptedContent returns the "content" field of an ElicitResult only when the +// user accepted the elicitation. Returns nil if the response is missing, of the +// wrong type, or the action is not "accept". +func acceptedContent(responses mcp.InputResponseMap, key string) map[string]any { + if responses == nil { + return nil + } + resp, ok := responses[key] + if !ok { + return nil + } + elicit, ok := resp.(*mcp.ElicitResult) + if !ok || elicit == nil || elicit.Action != "accept" { + return nil + } + return elicit.Content +} + +// ============================================================================= +// SEP-2322 Multi-Round-Trip tool handlers +// ============================================================================= + +// nameElicitSchema is the requestedSchema used by tools that elicit a name. +func nameElicitSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": {Type: "string"}, + }, + Required: []string{"name"}, + } +} + +// testInputRequiredElicitationHandler asks for the caller name via elicitation. +// Reused by the result-type, missing-input-response, ignore-extra-params, and +// validate-input scenarios: anything that does not carry an accepted +// "user_name" response is answered with a fresh InputRequiredResult. +func testInputRequiredElicitationHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + if content := acceptedContent(req.Params.InputResponses, "user_name"); content != nil { + name, _ := content["name"].(string) + if name != "" { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("Hello, %s!", name)}}, + }, nil, nil + } + } + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{ + "user_name": &mcp.ElicitParams{ + Message: "What is your name?", + RequestedSchema: nameElicitSchema(), + }, + }, + }, nil, nil +} + +// samplingResponseText extracts the first text content from a sampling +// response. The Go SDK unmarshals responses with a "role" field into +// *CreateMessageWithToolsResult (a superset of the legacy single-content +// CreateMessageResult). +func samplingResponseText(responses mcp.InputResponseMap, key string) (string, bool) { + resp, ok := responses[key].(*mcp.CreateMessageWithToolsResult) + if !ok || resp == nil { + return "", false + } + for _, c := range resp.Content { + if tc, ok := c.(*mcp.TextContent); ok { + return tc.Text, true + } + } + return "(non-text response)", true +} + +// testInputRequiredSamplingHandler asks for an LLM completion via sampling. +func testInputRequiredSamplingHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + if text, ok := samplingResponseText(req.Params.InputResponses, "capital_question"); ok { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("Sampling response: %s", text)}}, + }, nil, nil + } + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{ + "capital_question": &mcp.CreateMessageParams{ + Messages: []*mcp.SamplingMessage{ + {Role: "user", Content: &mcp.TextContent{Text: "What is the capital of France?"}}, + }, + MaxTokens: 100, + }, + }, + }, nil, nil +} + +// testInputRequiredListRootsHandler asks for the client roots. +func testInputRequiredListRootsHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + if resp, ok := req.Params.InputResponses["client_roots"].(*mcp.ListRootsResult); ok && resp != nil { + var uris []string + for _, r := range resp.Roots { + uris = append(uris, r.URI) + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Client exposed %d root(s): %s", len(resp.Roots), strings.Join(uris, ", "))}, + }, + }, nil, nil + } + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{ + "client_roots": &mcp.ListRootsParams{}, + }, + }, nil, nil +} + +// testInputRequiredRequestStateHandler round-trips an opaque requestState +// alongside an elicitation request. +func testInputRequiredRequestStateHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + if content := acceptedContent(req.Params.InputResponses, "confirm"); content != nil { + if requestStateValid(req.Params.RequestState) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "state-ok: requestState received and confirmation accepted"}}, + }, nil, nil + } + return nil, nil, &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidParams, + Message: "invalid requestState", + } + } + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{ + "confirm": &mcp.ElicitParams{ + Message: "Please confirm", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{"ok": {Type: "boolean"}}, + Required: []string{"ok"}, + }, + }, + }, + RequestState: "request_state", + }, nil, nil +} + +// testInputRequiredMultipleInputsHandler asks for elicitation, sampling, and +// roots input in a single round. +func testInputRequiredMultipleInputsHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + nameContent := acceptedContent(req.Params.InputResponses, "user_name") + name, _ := nameContent["name"].(string) + + greetingText, hasGreeting := samplingResponseText(req.Params.InputResponses, "greeting") + + var rootsCount int + rootsResp, rootsOK := req.Params.InputResponses["client_roots"].(*mcp.ListRootsResult) + if rootsOK && rootsResp != nil { + rootsCount = len(rootsResp.Roots) + } + + if name != "" && hasGreeting && rootsOK { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("%s %s — %d root(s) visible", greetingText, name, rootsCount)}, + }, + }, nil, nil + } + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{ + "user_name": &mcp.ElicitParams{ + Message: "What is your name?", + RequestedSchema: nameElicitSchema(), + }, + "greeting": &mcp.CreateMessageParams{ + Messages: []*mcp.SamplingMessage{ + {Role: "user", Content: &mcp.TextContent{Text: "Generate a greeting"}}, + }, + MaxTokens: 50, + }, + "client_roots": &mcp.ListRootsParams{}, + }, + RequestState: "multiple_inputs", + }, nil, nil +} + +// testInputRequiredMultiRoundHandler runs two elicitation rounds with +// evolving requestState before completing. The round number (and the name +// elicited in round 1) lives in the state, since the stateless server keeps +// no per-session memory. +func testInputRequiredMultiRoundHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + state := req.Params.RequestState + // State format: "round=1" or "round=2;name=". + switch { + case state == "": + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{ + "step1": &mcp.ElicitParams{ + Message: "Step 1: What is your name?", + RequestedSchema: nameElicitSchema(), + }, + }, + RequestState: "round=1", + }, nil, nil + case state == "round=1": + name, _ := acceptedContent(req.Params.InputResponses, "step1")["name"].(string) + if name == "" { + name = "unknown" + } + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{ + "step2": &mcp.ElicitParams{ + Message: "Step 2: What is your favorite color?", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{"color": {Type: "string"}}, + Required: []string{"color"}, + }, + }, + }, + RequestState: "round=2;name=" + name, + }, nil, nil + default: + color, _ := acceptedContent(req.Params.InputResponses, "step2")["color"].(string) + if color == "" { + color = "unknown" + } + name := strings.TrimPrefix(state, "round=2;name=") + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Multi-round complete: %s likes %s", name, color)}, + }, + }, nil, nil + } +} + +// testInputRequiredTamperedStateHandler rejects retries whose requestState +// looks tampered. The conformance harness tampers by appending "-TAMPERED"; +// production servers should use a real integrity mechanism (HMAC / AEAD). +func testInputRequiredTamperedStateHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + if req.Params.RequestState != "" { + if !requestStateValid(req.Params.RequestState) { + return nil, nil, &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidParams, + Message: "invalid requestState: tampered", + } + } + if acceptedContent(req.Params.InputResponses, "confirm") != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "state-ok: requestState received and confirmation accepted"}}, + }, nil, nil + } + } + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{ + "confirm": &mcp.ElicitParams{ + Message: "Please confirm", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{"ok": {Type: "boolean"}}, + Required: []string{"ok"}, + }, + }, + }, + RequestState: "tampered_state", + }, nil, nil +} + +// testInputRequiredCapabilitiesHandler only requests input kinds the client +// declared capabilities cover. Reads capabilities from the per-request +// _meta["io.modelcontextprotocol/clientCapabilities"] envelope. +func testInputRequiredCapabilitiesHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + if len(req.Params.InputResponses) > 0 { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "Capability-aware input requests fulfilled"}}, + }, nil, nil + } + caps := req.ClientCapabilities() + reqs := mcp.InputRequestMap{} + if caps != nil && caps.Elicitation != nil { + reqs["user_name"] = &mcp.ElicitParams{ + Message: "What is your name?", + RequestedSchema: nameElicitSchema(), + } + } + if caps != nil && caps.Sampling != nil { + reqs["greeting"] = &mcp.CreateMessageParams{ + Messages: []*mcp.SamplingMessage{ + {Role: "user", Content: &mcp.TextContent{Text: "Generate a short greeting"}}, + }, + MaxTokens: 50, + } + } + if caps != nil && (caps.RootsV2 != nil || caps.Roots.ListChanged) { + reqs["client_roots"] = &mcp.ListRootsParams{} + } + if len(reqs) == 0 { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "No declared client capability supports an in-band input request"}}, + }, nil, nil + } + return &mcp.CallToolResult{InputRequests: reqs}, nil, nil +} + +// testInputRequiredPromptHandler is the prompts/get analogue: elicits context +// on the first call and returns a GetPromptResult with messages on retry. +func testInputRequiredPromptHandler(ctx context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + if content := acceptedContent(req.Params.InputResponses, "user_context"); content != nil { + contextStr, _ := content["context"].(string) + return &mcp.GetPromptResult{ + Description: "A prompt with elicited context", + Messages: []*mcp.PromptMessage{ + { + Role: "user", + Content: &mcp.TextContent{Text: fmt.Sprintf("Context: %s", contextStr)}, + }, + }, + }, nil + } + return &mcp.GetPromptResult{ + InputRequests: mcp.InputRequestMap{ + "user_context": &mcp.ElicitParams{ + Message: "Please provide context for the prompt", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{"context": {Type: "string"}}, + Required: []string{"context"}, + }, + }, + }, + }, nil +} + func testReconnectionHandler(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { // Close the SSE stream to trigger client reconnection (SEP-1699) if req.Extra != nil && req.Extra.CloseSSEStream != nil { @@ -651,6 +1184,11 @@ func registerPrompts(server *mcp.Server) { Title: "Prompt With Image", Description: "A prompt that includes image content", }, promptWithImageHandler) + + server.AddPrompt(&mcp.Prompt{ + Name: "test_input_required_result_prompt", + Description: "MRTR (SEP-2322): asks for context via an in-band elicitation request", + }, testInputRequiredPromptHandler) } func simplePromptHandler(ctx context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { From 7f67c851d1a4e61ae5e749181ff5e6c75206de85 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Wed, 8 Jul 2026 10:34:41 +0200 Subject: [PATCH 329/418] mcp: enforce body size limit in Stremable connection (#1055) This PR enforces a body size limit on Streamable connection to prevent OOM attacks --- mcp/streamable.go | 39 +++++++++++++++- mcp/streamable_test.go | 102 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 1 deletion(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index c047c15..7e0dddd 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -194,8 +194,25 @@ type StreamableHTTPOptions struct { // protection := http.NewCrossOriginProtection() // protectedHandler := protection.Handler(handler) CrossOriginProtection *http.CrossOriginProtection + + // MaxRequestBodyBytes limits the number of bytes read from any incoming + // HTTP request body. Requests that exceed this limit are rejected with + // 413 Request Entity Too Large. + // + // The limit is enforced during the read, so it applies uniformly to + // requests using Content-Length, Transfer-Encoding: chunked, or HTTP/2 + // (which has no Content-Length). + // + // If zero, [DefaultMaxRequestBodyBytes] is used. + // A negative value disables the limit entirely; do not use this on + // servers exposed to untrusted clients. + MaxRequestBodyBytes int64 } +// DefaultMaxRequestBodyBytes is the default value used for +// [StreamableHTTPOptions.MaxRequestBodyBytes] when it is left at zero. +const DefaultMaxRequestBodyBytes = 4 << 20 // 4 MiB + // NewStreamableHTTPHandler returns a new [StreamableHTTPHandler]. // // The getServer function is used to create or look up servers for new @@ -216,6 +233,10 @@ func NewStreamableHTTPHandler(getServer func(*http.Request) *Server, opts *Strea h.opts.CrossOriginProtection = &http.CrossOriginProtection{} } + if h.opts.MaxRequestBodyBytes == 0 { + h.opts.MaxRequestBodyBytes = DefaultMaxRequestBodyBytes + } + return h } @@ -308,6 +329,11 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque } } + // Bound the request body to protect against OOM attacks. + if req.Body != nil && h.opts.MaxRequestBodyBytes > 0 { + req.Body = http.MaxBytesReader(w, req.Body, h.opts.MaxRequestBodyBytes) + } + // [§2.7] of the spec (2025-06-18): validate the MCP-Protocol-Version // header. If provided, it must be a supported version. If absent, the // version is unknown (the request may be an initialize for any version). @@ -368,6 +394,11 @@ func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http. info, err := h.ephemeralConnectOpts(req) if err != nil { + var mbe *http.MaxBytesError + if errors.As(err, &mbe) { + http.Error(w, fmt.Sprintf("request body exceeds %d bytes", mbe.Limit), http.StatusRequestEntityTooLarge) + return + } http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -435,7 +466,8 @@ func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (*epheme var hasInitialize, hasInitialized, usesNewProtocol, isSubscriptionsListen bool body, err := io.ReadAll(req.Body) if err != nil { - return nil, fmt.Errorf("failed to read body") + // Preserve *http.MaxBytesError so serveStateless can respond with 413. + return nil, fmt.Errorf("failed to read body: %w", err) } req.Body.Close() req.Body = io.NopCloser(bytes.NewBuffer(body)) @@ -1374,6 +1406,11 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // Read incoming messages. body, err := io.ReadAll(req.Body) if err != nil { + var mbe *http.MaxBytesError + if errors.As(err, &mbe) { + http.Error(w, fmt.Sprintf("request body exceeds %d bytes", mbe.Limit), http.StatusRequestEntityTooLarge) + return + } http.Error(w, "failed to read body", http.StatusBadRequest) return } diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index dff5e1f..c1c7943 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -3802,3 +3802,105 @@ func TestStreamableServerRejectsDuplicateInFlightRequestID(t *testing.T) { default: } } + +// TestStreamableMaxRequestBodyBytes verifies that the streamable HTTP handler +// enforces StreamableHTTPOptions.MaxRequestBodyBytes on incoming request +// bodies. The limit must apply uniformly regardless of transfer encoding: +// Content-Length, Transfer-Encoding: chunked, or HTTP/2 (which has no +// Content-Length). +func TestStreamableMaxRequestBodyBytes(t *testing.T) { + // A minimally-valid JSON-RPC initialize request. The body-size check runs + // before parsing, so exact contents don't matter for the reject cases, + // but we use a real one for the "under limit" success case. + validInit := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"v1"}}}`) + + // Build a payload of the requested size by padding a JSON object. The + // server doesn't need to parse it; the MaxBytesReader trips first. + oversizedBody := func(n int) []byte { + b := make([]byte, n) + b[0] = '{' + for i := 1; i < n-1; i++ { + b[i] = ' ' + } + b[n-1] = '}' + return b + } + + tests := []struct { + name string + limit int64 // value assigned to MaxRequestBodyBytes + body []byte + wantStatus int + wantStatusDesc string + }{ + { + name: "default limit rejects oversized", + limit: 0, // triggers DefaultMaxRequestBodyBytes (4 MiB) + body: oversizedBody(DefaultMaxRequestBodyBytes + 1), + wantStatus: http.StatusRequestEntityTooLarge, + wantStatusDesc: "413 for body > 4MiB default", + }, + { + name: "custom limit rejects oversized", + limit: 1024, + body: oversizedBody(2048), + wantStatus: http.StatusRequestEntityTooLarge, + wantStatusDesc: "413 for body > custom 1KiB limit", + }, + { + name: "chunked encoding does not bypass limit", + limit: 1024, + body: oversizedBody(4096), + wantStatus: http.StatusRequestEntityTooLarge, + wantStatusDesc: "413 for chunked body > limit (regression: Content-Length-only checks are bypassable)", + }, + { + name: "negative limit disables the check", + limit: -1, + body: oversizedBody(2 * DefaultMaxRequestBodyBytes), + wantStatus: http.StatusBadRequest, // body is not valid JSON-RPC; but the size check must not fire + wantStatusDesc: "not 413 when limit is disabled", + }, + { + name: "under limit succeeds", + limit: 64 * 1024, + body: validInit, + wantStatus: http.StatusOK, + wantStatusDesc: "200 for a valid initialize under the limit", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + server := NewServer(testImpl, nil) + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return server }, + &StreamableHTTPOptions{ + Stateless: true, + MaxRequestBodyBytes: tc.limit, + }, + ) + ts := httptest.NewServer(handler) + defer ts.Close() + + req, err := http.NewRequest(http.MethodPost, ts.URL, bytes.NewReader(tc.body)) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != tc.wantStatus { + t.Fatalf("%s: status = %d, want %d (body=%q)", + tc.wantStatusDesc, resp.StatusCode, tc.wantStatus, string(body)) + } + }) + } +} From deecf8a432d637ecb7bac36001ce6b43f6fec653 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Wed, 8 Jul 2026 15:17:41 +0200 Subject: [PATCH 330/418] mcp: block server initiated requests (SEP-2322) (#1057) This PR explicitly blocks server initiated requests in server.go --- docs/client.md | 96 ++++++++++++++++++++++++------------- mcp/client_example_test.go | 98 ++++++++++++++++++++++++-------------- mcp/elicitation_test.go | 18 +++++-- mcp/mcp_test.go | 34 +++++++++---- mcp/sampling_test.go | 10 ++-- mcp/server.go | 29 +++++++++++ mcp/server_test.go | 98 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 294 insertions(+), 89 deletions(-) diff --git a/docs/client.md b/docs/client.md index d25bee9..e262f34 100644 --- a/docs/client.md +++ b/docs/client.md @@ -45,26 +45,27 @@ pattern. func Example_roots() { ctx := context.Background() - // Create a client with a single root. + // Create a client with two roots. c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) - c.AddRoots(&mcp.Root{URI: "file://a"}) + c.AddRoots(&mcp.Root{URI: "file://a"}, &mcp.Root{URI: "file://b"}) - // Now create a server with a handler to receive notifications about roots. - rootsChanged := make(chan struct{}) - handleRootsChanged := func(ctx context.Context, req *mcp.RootsListChangedRequest) { - rootList, err := req.Session.ListRoots(ctx, nil) - if err != nil { - log.Fatal(err) + // Create a server with a tool that requests roots via the multi round-trip + // pattern (SEP-2322): server-to-client requests are no longer sent as + // standalone JSON-RPC calls on protocol version >= 2026-07-28. + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(s, &mcp.Tool{Name: "roots"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{"roots": &mcp.ListRootsParams{}}, + }, nil, nil } + rootList := req.Params.InputResponses["roots"].(*mcp.ListRootsResult) var roots []string for _, root := range rootList.Roots { roots = append(roots, root.URI) } fmt.Println(roots) - close(rootsChanged) - } - s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ - RootsListChangedHandler: handleRootsChanged, + return &mcp.CallToolResult{}, nil, nil }) // Connect the server and client... @@ -81,9 +82,11 @@ func Example_roots() { } defer clientSession.Close() - // ...and add a root. The server is notified about the change. - c.AddRoots(&mcp.Root{URI: "file://b"}) - <-rootsChanged + // ...and call the tool. The client's multi round-trip driver fulfils the + // embedded roots/list request and retries the call. + if _, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: "roots"}); err != nil { + log.Fatal(err) + } // Output: [file://a file://b] } ``` @@ -130,22 +133,35 @@ func Example_sampling() { // Connect the server and client... ct, st := mcp.NewInMemoryTransports() + // Create a server with a tool that requests sampling via the multi + // round-trip pattern (SEP-2322): server-to-client requests are no longer + // sent as standalone JSON-RPC calls on protocol version >= 2026-07-28. s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(s, &mcp.Tool{Name: "sample"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{"msg": &mcp.CreateMessageParams{}}, + }, nil, nil + } + msg := req.Params.InputResponses["msg"].(*mcp.CreateMessageWithToolsResult) + return &mcp.CallToolResult{Content: msg.Content}, nil, nil + }) session, err := s.Connect(ctx, st, nil) if err != nil { log.Fatal(err) } defer session.Close() - if _, err := c.Connect(ctx, ct, nil); err != nil { - log.Fatal(err) - } - - msg, err := session.CreateMessage(ctx, &mcp.CreateMessageParams{}) + clientSession, err := c.Connect(ctx, ct, nil) if err != nil { log.Fatal(err) } - fmt.Println(msg.Content.(*mcp.TextContent).Text) + + res, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: "sample"}) + if err != nil { + log.Fatal(err) + } + fmt.Println(res.Content[0].(*mcp.TextContent).Text) // Output: would have created a message } ``` @@ -175,7 +191,28 @@ func Example_elicitation() { ctx := context.Background() ct, st := mcp.NewInMemoryTransports() + // Create a server with a tool that requests elicitation via the multi + // round-trip pattern (SEP-2322): server-to-client requests are no longer + // sent as standalone JSON-RPC calls on protocol version >= 2026-07-28. s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(s, &mcp.Tool{Name: "ask"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{"input": &mcp.ElicitParams{ + Message: "This should fail", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "test": {Type: "string"}, + }, + }, + }}, + }, nil, nil + } + res := req.Params.InputResponses["input"].(*mcp.ElicitResult) + fmt.Println(res.Content["test"]) + return &mcp.CallToolResult{}, nil, nil + }) ss, err := s.Connect(ctx, st, nil) if err != nil { log.Fatal(err) @@ -187,22 +224,13 @@ func Example_elicitation() { return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"test": "value"}}, nil }, }) - if _, err := c.Connect(ctx, ct, nil); err != nil { - log.Fatal(err) - } - res, err := ss.Elicit(ctx, &mcp.ElicitParams{ - Message: "This should fail", - RequestedSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "test": {Type: "string"}, - }, - }, - }) + clientSession, err := c.Connect(ctx, ct, nil) if err != nil { log.Fatal(err) } - fmt.Println(res.Content["test"]) + if _, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: "ask"}); err != nil { + log.Fatal(err) + } // Output: value } ``` diff --git a/mcp/client_example_test.go b/mcp/client_example_test.go index 33b0a44..a741bbf 100644 --- a/mcp/client_example_test.go +++ b/mcp/client_example_test.go @@ -2,8 +2,6 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. -//lint:file-ignore SA1019 examples exercise deprecated SEP-2577 APIs (roots, sampling) for demonstration. - package mcp_test import ( @@ -20,26 +18,27 @@ import ( func Example_roots() { ctx := context.Background() - // Create a client with a single root. + // Create a client with two roots. c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) - c.AddRoots(&mcp.Root{URI: "file://a"}) + c.AddRoots(&mcp.Root{URI: "file://a"}, &mcp.Root{URI: "file://b"}) - // Now create a server with a handler to receive notifications about roots. - rootsChanged := make(chan struct{}) - handleRootsChanged := func(ctx context.Context, req *mcp.RootsListChangedRequest) { - rootList, err := req.Session.ListRoots(ctx, nil) - if err != nil { - log.Fatal(err) + // Create a server with a tool that requests roots via the multi round-trip + // pattern (SEP-2322): server-to-client requests are no longer sent as + // standalone JSON-RPC calls on protocol version >= 2026-07-28. + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(s, &mcp.Tool{Name: "roots"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{"roots": &mcp.ListRootsParams{}}, + }, nil, nil } + rootList := req.Params.InputResponses["roots"].(*mcp.ListRootsResult) var roots []string for _, root := range rootList.Roots { roots = append(roots, root.URI) } fmt.Println(roots) - close(rootsChanged) - } - s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ - RootsListChangedHandler: handleRootsChanged, + return &mcp.CallToolResult{}, nil, nil }) // Connect the server and client... @@ -56,9 +55,11 @@ func Example_roots() { } defer clientSession.Close() - // ...and add a root. The server is notified about the change. - c.AddRoots(&mcp.Root{URI: "file://b"}) - <-rootsChanged + // ...and call the tool. The client's multi round-trip driver fulfils the + // embedded roots/list request and retries the call. + if _, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: "roots"}); err != nil { + log.Fatal(err) + } // Output: [file://a file://b] } @@ -82,22 +83,35 @@ func Example_sampling() { // Connect the server and client... ct, st := mcp.NewInMemoryTransports() + // Create a server with a tool that requests sampling via the multi + // round-trip pattern (SEP-2322): server-to-client requests are no longer + // sent as standalone JSON-RPC calls on protocol version >= 2026-07-28. s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(s, &mcp.Tool{Name: "sample"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{"msg": &mcp.CreateMessageParams{}}, + }, nil, nil + } + msg := req.Params.InputResponses["msg"].(*mcp.CreateMessageWithToolsResult) + return &mcp.CallToolResult{Content: msg.Content}, nil, nil + }) session, err := s.Connect(ctx, st, nil) if err != nil { log.Fatal(err) } defer session.Close() - if _, err := c.Connect(ctx, ct, nil); err != nil { - log.Fatal(err) - } - - msg, err := session.CreateMessage(ctx, &mcp.CreateMessageParams{}) + clientSession, err := c.Connect(ctx, ct, nil) if err != nil { log.Fatal(err) } - fmt.Println(msg.Content.(*mcp.TextContent).Text) + + res, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: "sample"}) + if err != nil { + log.Fatal(err) + } + fmt.Println(res.Content[0].(*mcp.TextContent).Text) // Output: would have created a message } @@ -109,7 +123,28 @@ func Example_elicitation() { ctx := context.Background() ct, st := mcp.NewInMemoryTransports() + // Create a server with a tool that requests elicitation via the multi + // round-trip pattern (SEP-2322): server-to-client requests are no longer + // sent as standalone JSON-RPC calls on protocol version >= 2026-07-28. s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(s, &mcp.Tool{Name: "ask"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{"input": &mcp.ElicitParams{ + Message: "This should fail", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "test": {Type: "string"}, + }, + }, + }}, + }, nil, nil + } + res := req.Params.InputResponses["input"].(*mcp.ElicitResult) + fmt.Println(res.Content["test"]) + return &mcp.CallToolResult{}, nil, nil + }) ss, err := s.Connect(ctx, st, nil) if err != nil { log.Fatal(err) @@ -121,22 +156,13 @@ func Example_elicitation() { return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"test": "value"}}, nil }, }) - if _, err := c.Connect(ctx, ct, nil); err != nil { - log.Fatal(err) - } - res, err := ss.Elicit(ctx, &mcp.ElicitParams{ - Message: "This should fail", - RequestedSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "test": {Type: "string"}, - }, - }, - }) + clientSession, err := c.Connect(ctx, ct, nil) if err != nil { log.Fatal(err) } - fmt.Println(res.Content["test"]) + if _, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: "ask"}); err != nil { + log.Fatal(err) + } // Output: value } diff --git a/mcp/elicitation_test.go b/mcp/elicitation_test.go index cc08820..a20947f 100644 --- a/mcp/elicitation_test.go +++ b/mcp/elicitation_test.go @@ -114,7 +114,7 @@ func TestElicitationURLMode(t *testing.T) { }, ElicitationHandler: tc.handler, }) - cs, err := c.Connect(ctx, ct, nil) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -165,8 +165,18 @@ func TestElicitationCompleteNotification(t *testing.T) { }, }) - _, ss, cleanup := basicClientServerConnection(t, c, nil, nil) - defer cleanup() + ct, st := NewInMemoryTransports() + s := NewServer(testImpl, nil) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + if err != nil { + t.Fatal(err) + } + defer cs.Close() // 1. Server initiates a URL elicitation elicitID := "testElicitationID-123" @@ -245,7 +255,7 @@ func TestElicitationNoValidationWithoutAccept(t *testing.T) { return &ElicitResult{Action: tc.action, Content: tc.content}, nil }, }) - cs, err := c.Connect(ctx, ct, nil) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 97be926..a019839 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -854,7 +854,7 @@ func TestNoJSONNull(t *testing.T) { } c := NewClient(testImpl, nil) - cs, err := c.Connect(ctx, ct, nil) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1023,7 +1023,7 @@ func TestElicitationUnsupportedMethod(t *testing.T) { return &CreateMessageResult{Model: "aModel", Content: &TextContent{}}, nil }, }) - cs, err := c.Connect(ctx, ct, nil) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1072,7 +1072,7 @@ func TestElicitationSchemaValidation(t *testing.T) { return &ElicitResult{Action: "accept", Content: map[string]any{"test": "value"}}, nil }, }) - cs, err := c.Connect(ctx, ct, nil) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1558,7 +1558,7 @@ func TestElicitContentValidation(t *testing.T) { return &ElicitResult{Action: "accept", Content: map[string]any{"test": "potato"}}, nil }, }) - cs, err := c.Connect(ctx, ct, nil) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1641,7 +1641,7 @@ func TestElicitationProgressToken(t *testing.T) { return &ElicitResult{Action: "accept"}, nil }, }) - cs, err := c.Connect(ctx, ct, nil) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1683,7 +1683,7 @@ func TestElicitationCapabilityDeclaration(t *testing.T) { } defer ss.Close() - cs, err := c.Connect(ctx, ct, nil) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1720,7 +1720,7 @@ func TestElicitationCapabilityDeclaration(t *testing.T) { } defer ss.Close() - cs, err := c.Connect(ctx, ct, nil) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1757,7 +1757,7 @@ func TestElicitationDefaultValues(t *testing.T) { return &ElicitResult{Action: "accept", Content: map[string]any{"default": "response"}}, nil }, }) - cs, err := c.Connect(ctx, ct, nil) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -2079,8 +2079,22 @@ func TestSynchronousNotifications(t *testing.T) { return new(CallToolResult), nil, nil }) } - cs, ss, cleanup := basicClientServerConnection(t, client, server, addTool) - defer cleanup() + ctx := context.Background() + ct, st := NewInMemoryTransports() + addTool(server) + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ss.Close() }) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = cs.Close() + ss.Wait() + }) t.Log("from client") { diff --git a/mcp/sampling_test.go b/mcp/sampling_test.go index 79c8213..2adb0fd 100644 --- a/mcp/sampling_test.go +++ b/mcp/sampling_test.go @@ -53,7 +53,7 @@ func TestSamplingWithTools_ToolUse(t *testing.T) { } defer ss.Close() - cs, err := client.Connect(ctx, ct, nil) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -124,7 +124,7 @@ func TestSamplingWithTools_ToolResult(t *testing.T) { } defer ss.Close() - cs, err := client.Connect(ctx, ct, nil) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -288,7 +288,7 @@ func TestSamplingWithTools_ToolResultWithError(t *testing.T) { } defer ss.Close() - cs, err := client.Connect(ctx, ct, nil) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -350,7 +350,7 @@ func TestSamplingWithTools_ParallelToolCalls(t *testing.T) { } defer ss.Close() - cs, err := client.Connect(ctx, ct, nil) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -420,7 +420,7 @@ func TestCreateMessage_MultipleContentError(t *testing.T) { } defer ss.Close() - cs, err := client.Connect(ctx, ct, nil) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } diff --git a/mcp/server.go b/mcp/server.go index 5fa57f7..c59f793 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1512,6 +1512,23 @@ func (ss *ServerSession) ID() string { return "" } +// assertServerInitiatedRequestAllowed returns an error when the session is +// negotiated at protocol version >= 2026-07-28, where the spec (SEP-2322 / +// SEP-2575) forbids server-initiated JSON-RPC requests for elicitation, +// sampling, and roots: those interactions MUST be embedded as [InputRequests] +// in an [InputRequiredResult] returned from a handler for one of the multi +// round-trip methods (`tools/call`, `prompts/get`, `resources/read`). +func (ss *ServerSession) assertServerInitiatedRequestAllowed(method string) error { + if iparams := ss.InitializeParams(); iparams != nil && + iparams.ProtocolVersion >= protocolVersion20260728 { + return fmt.Errorf( + "%q cannot be sent while serving a request on protocol version %s: "+ + "return an InputRequests map instead (multi round-trip requests, SEP-2322)", + method, iparams.ProtocolVersion) + } + return nil +} + // Ping pings the client. func (ss *ServerSession) Ping(ctx context.Context, params *PingParams) error { _, err := handleSend[*emptyResult](ctx, methodPing, newServerRequest(ss, orZero[Params](params))) @@ -1529,6 +1546,9 @@ func (ss *ServerSession) ListRoots(ctx context.Context, params *ListRootsParams) if err := ss.checkInitialized(methodListRoots); err != nil { return nil, err } + if err := ss.assertServerInitiatedRequestAllowed(methodListRoots); err != nil { + return nil, err + } return handleSend[*ListRootsResult](ctx, methodListRoots, newServerRequest(ss, orZero[Params](params))) } @@ -1547,6 +1567,9 @@ func (ss *ServerSession) CreateMessage(ctx context.Context, params *CreateMessag if err := ss.checkInitialized(methodCreateMessage); err != nil { return nil, err } + if err := ss.assertServerInitiatedRequestAllowed(methodCreateMessage); err != nil { + return nil, err + } if params == nil { params = &CreateMessageParams{Messages: []*SamplingMessage{}} } @@ -1590,6 +1613,9 @@ func (ss *ServerSession) CreateMessageWithTools(ctx context.Context, params *Cre if err := ss.checkInitialized(methodCreateMessage); err != nil { return nil, err } + if err := ss.assertServerInitiatedRequestAllowed(methodCreateMessage); err != nil { + return nil, err + } if params == nil { params = &CreateMessageWithToolsParams{Messages: []*SamplingMessageV2{}} } @@ -1606,6 +1632,9 @@ func (ss *ServerSession) Elicit(ctx context.Context, params *ElicitParams) (*Eli if err := ss.checkInitialized(methodElicit); err != nil { return nil, err } + if err := ss.assertServerInitiatedRequestAllowed(methodElicit); err != nil { + return nil, err + } if params == nil { return nil, fmt.Errorf("%w: params cannot be nil", jsonrpc2.ErrInvalidParams) } diff --git a/mcp/server_test.go b/mcp/server_test.go index fc9db1f..ac303be 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -1451,3 +1451,101 @@ func TestServerSessionHandle_RejectsRemovedMethodsOnNewProtocol(t *testing.T) { }) } } + +// TestServerSession_RejectsServerInitiatedRequests verifies that +// SEP-2322 / SEP-2575 is enforced at the API surface: [ServerSession.Elicit], +// [ServerSession.CreateMessage], [ServerSession.CreateMessageWithTools], and +// [ServerSession.ListRoots] must refuse to send a server-to-client request +// when the session is negotiated at protocol version >= 2026-07-28, and must +// remain functional on pre-2026-07-28 sessions. +func TestServerSession_RejectsServerInitiated(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + call func(*ServerSession) error + }{ + { + name: "Elicit", + call: func(ss *ServerSession) error { + _, err := ss.Elicit(ctx, &ElicitParams{Message: "hi"}) + return err + }, + }, + { + name: "CreateMessage", + call: func(ss *ServerSession) error { + _, err := ss.CreateMessage(ctx, &CreateMessageParams{}) + return err + }, + }, + { + name: "CreateMessageWithTools", + call: func(ss *ServerSession) error { + _, err := ss.CreateMessageWithTools(ctx, &CreateMessageWithToolsParams{}) + return err + }, + }, + { + name: "ListRoots", + call: func(ss *ServerSession) error { + _, err := ss.ListRoots(ctx, nil) + return err + }, + }, + } + + // Cover both branches of the era gate: modern must reject, legacy must let + // the call proceed to the wire (where it either succeeds or fails on + // something unrelated to the guard, such as missing client capabilities). + for _, protoVer := range []string{protocolVersion20260728, protocolVersion20251125} { + for _, tc := range tests { + t.Run(fmt.Sprintf("%s/%s", protoVer, tc.name), func(t *testing.T) { + ct, st := NewInMemoryTransports() + s := NewServer(testImpl, nil) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ss.Close() }) + + // Client advertises every server-to-client capability so a + // legacy-era call is not rejected for capability reasons + // instead of era reasons. + c := NewClient(testImpl, &ClientOptions{ + ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "cancel"}, nil + }, + CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) { + return &CreateMessageResult{Model: "m", Role: "assistant", Content: &TextContent{Text: "ok"}}, nil + }, + }) + c.AddRoots(&Root{URI: "file:///workspace"}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protoVer}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cs.Close() }) + + gotErr := tc.call(ss) + modern := protoVer >= protocolVersion20260728 + if modern { + if gotErr == nil { + t.Fatalf("%s on %s: got nil error, want era-gate rejection", tc.name, protoVer) + } + wantSubstr := "cannot be sent while serving a request on protocol version " + protoVer + if !strings.Contains(gotErr.Error(), wantSubstr) { + t.Errorf("%s on %s: error %q does not contain %q", tc.name, protoVer, gotErr.Error(), wantSubstr) + } + if !strings.Contains(gotErr.Error(), "multi round-trip requests") { + t.Errorf("%s on %s: error %q does not steer to MRTR", tc.name, protoVer, gotErr.Error()) + } + } else { + if gotErr != nil { + t.Errorf("%s on %s: got error %v, want nil (era gate must not fire on legacy)", tc.name, protoVer, gotErr) + } + } + }) + } + } +} From 1af1a087ef656e0b4ad1009994c877d581f9a999 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Thu, 9 Jul 2026 10:46:31 +0200 Subject: [PATCH 331/418] ci: update dependabot rules (#1059) --- .github/dependabot.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 262a757..f9fbfe1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,3 +5,11 @@ updates: directory: "/" schedule: interval: "monthly" + # Group all github/codeql-action/* sub-paths (init, analyze, ...) into a + # single PR so they are always bumped together. Bumping them independently + # leaves the workflow with mismatched versions across steps, which + # github/codeql-action rejects at runtime. + groups: + codeql-action: + patterns: + - "github/codeql-action/*" From 1dcf38f76ff16b0c2a13b467dadb90d33c0c532a Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Thu, 9 Jul 2026 10:48:07 +0200 Subject: [PATCH 332/418] mcp: add conformance tests on client (#1063) This PR integrates the latest conformance tests on client --- .github/workflows/conformance.yml | 4 +- conformance/baseline.yml | 13 +- conformance/everything-client/main.go | 219 +++++++++++++++++++++++++- 3 files changed, 225 insertions(+), 11 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index cca700d..feb5000 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -68,9 +68,9 @@ jobs: uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version: 22 - - name: Run conformance tests + - name: Run conformance tests (full suite) run: | npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" client \ --command "go run ./conformance/everything-client" \ - --suite core \ + --suite all \ --expected-failures ./conformance/baseline.yml diff --git a/conformance/baseline.yml b/conformance/baseline.yml index 9d7bef8..49f68b6 100644 --- a/conformance/baseline.yml +++ b/conformance/baseline.yml @@ -1,2 +1,13 @@ server: [] # All tests pass! -client: [] # All tests pass! + +client: + # OAuth mechanisms not yet implemented by the Go MCP SDK. + - auth/client-credentials-basic + - auth/client-credentials-jwt + - auth/enterprise-managed-authorization + + # Upstream conformance mock inconsistency: the scenario's hand-rolled + # server/discover handler advertises "2026-07-28" but delegates every + # other method to a pinned @modelcontextprotocol/sdk that rejects that + # version at the HTTP layer. + - json-schema-ref-no-deref diff --git a/conformance/everything-client/main.go b/conformance/everything-client/main.go index 55a55ad..3776522 100644 --- a/conformance/everything-client/main.go +++ b/conformance/everything-client/main.go @@ -45,32 +45,43 @@ func registerScenario(name string, handler scenarioHandler) { func init() { registerScenario("initialize", runBasicClient) registerScenario("tools_call", runToolsCallClient) + registerScenario("request-metadata", runRequestMetadataClient) registerScenario("elicitation-sep1034-client-defaults", runElicitationDefaultsClient) registerScenario("sse-retry", runSSERetryClient) + registerScenario("json-schema-ref-no-deref", runJSONSchemaRefNoDerefClient) + registerScenario("sep-2322-client-request-state", runMrtrClient) + registerScenario("http-standard-headers", runHTTPStandardHeadersClient) + registerScenario("http-custom-headers", runHTTPCustomHeadersClient) + registerScenario("http-invalid-tool-headers", runHTTPInvalidToolHeadersClient) authScenarios := []string{ "auth/2025-03-26-oauth-metadata-backcompat", "auth/2025-03-26-oauth-endpoint-fallback", + "auth/authorization-server-migration", "auth/basic-cimd", + "auth/iss-normalized", + "auth/iss-not-advertised", + "auth/iss-supported", + "auth/iss-supported-missing", + "auth/iss-unexpected", + "auth/iss-wrong-issuer", "auth/metadata-default", + "auth/metadata-issuer-mismatch", "auth/metadata-var1", "auth/metadata-var2", "auth/metadata-var3", + "auth/offline-access-not-supported", + "auth/offline-access-scope", "auth/pre-registration", "auth/resource-mismatch", - "auth/scope-from-www-authenticate", "auth/scope-from-scopes-supported", + "auth/scope-from-www-authenticate", "auth/scope-omitted-when-undefined", - "auth/scope-step-up", "auth/scope-retry-limit", + "auth/scope-step-up", "auth/token-endpoint-auth-basic", - "auth/token-endpoint-auth-post", "auth/token-endpoint-auth-none", - "auth/iss-supported", - "auth/iss-not-advertised", - "auth/iss-supported-missing", - "auth/iss-wrong-issuer", - "auth/iss-unexpected", + "auth/token-endpoint-auth-post", } for _, scenario := range authScenarios { registerScenario(scenario, runAuthClient) @@ -293,6 +304,198 @@ func runAuthClient(ctx context.Context, serverURL string, configCtx map[string]a return nil } +// ============================================================================ +// request-metadata scenario (SEP-2575) +// ============================================================================ + +// runRequestMetadataClient exercises the SEP-2575 wire-level negotiation: +// every request must carry the MCP-Protocol-Version header and the per-request +// _meta envelope, and the client must retry with a supported version when its +// first choice is rejected with -32022. The Go SDK's Connect() drives +// server/discover unconditionally for 2026-07-28, which is exactly that +// mechanism. +func runRequestMetadataClient(ctx context.Context, serverURL string, _ map[string]any) error { + session, err := connectToServer(ctx, serverURL) + if err != nil { + return err + } + defer session.Close() + return nil +} + +// ============================================================================ +// json-schema-ref-no-deref scenario (SEP-2106) +// ============================================================================ + +// runJSONSchemaRefNoDerefClient asserts that listTools does not dereference +// network $ref URLs in tool schemas. The Go SDK never fetches external refs; +// a plain connect → listTools is sufficient — if the SDK ever regressed and +// tried to GET the canary URL the conformance referee would record the +// failure. +func runJSONSchemaRefNoDerefClient(ctx context.Context, serverURL string, _ map[string]any) error { + session, err := connectToServer(ctx, serverURL) + if err != nil { + return err + } + defer session.Close() + + _, _ = session.ListTools(ctx, nil) + return nil +} + +// ============================================================================ +// SEP-2322 multi-round-trip client scenario +// ============================================================================ + +// runMrtrClient drives the SEP-2322 client-request-state scenario. The +// SDK's built-in client-side MRTR middleware transparently fulfills embedded +// elicitation requests via ElicitationHandler and echoes requestState. +func runMrtrClient(ctx context.Context, serverURL string, _ map[string]any) error { + elicitationHandler := func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{ + Action: "accept", + Content: map[string]any{"confirmed": true}, + }, nil + } + session, err := connectToServer(ctx, serverURL, + withClientOptions(&mcp.ClientOptions{ + Capabilities: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{}}, + ElicitationHandler: elicitationHandler, + }), + ) + if err != nil { + return err + } + defer session.Close() + + toolNames := []string{ + "test_mrtr_echo_state", + "test_mrtr_no_state", + "test_mrtr_unrelated", + "test_mrtr_no_result_type", + } + for _, name := range toolNames { + if _, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: name, + Arguments: map[string]any{}, + }); err != nil { + log.Printf("CallTool(%q) rejected locally (expected for some cases): %v", name, err) + } + } + return nil +} + +// ============================================================================ +// SEP-2243 HTTP header scenarios +// ============================================================================ + +// runHTTPStandardHeadersClient (SEP-2243 standard headers) drives tools, +// resources and prompts once each so the referee sees Mcp-Method / Mcp-Name +// headers on every verb the SDK supports. +func runHTTPStandardHeadersClient(ctx context.Context, serverURL string, _ map[string]any) error { + session, err := connectToServer(ctx, serverURL) + if err != nil { + return err + } + defer session.Close() + + if tools, err := session.ListTools(ctx, nil); err == nil && len(tools.Tools) > 0 { + _, _ = session.CallTool(ctx, &mcp.CallToolParams{ + Name: tools.Tools[0].Name, + Arguments: map[string]any{}, + }) + } + if res, err := session.ListResources(ctx, nil); err == nil && len(res.Resources) > 0 { + _, _ = session.ReadResource(ctx, &mcp.ReadResourceParams{URI: res.Resources[0].URI}) + } + if prompts, err := session.ListPrompts(ctx, nil); err == nil && len(prompts.Prompts) > 0 { + _, _ = session.GetPrompt(ctx, &mcp.GetPromptParams{ + Name: prompts.Prompts[0].Name, + Arguments: map[string]string{}, + }) + } + return nil +} + +// runHTTPCustomHeadersClient (SEP-2243 custom headers) lists tools (so the +// SDK caches inputSchema and its x-mcp-header annotations), then makes the +// runner-supplied tool calls so the referee validates the Mcp-Param-* +// headers. +func runHTTPCustomHeadersClient(ctx context.Context, serverURL string, configCtx map[string]any) error { + session, err := connectToServer(ctx, serverURL) + if err != nil { + return err + } + defer session.Close() + + if _, err := session.ListTools(ctx, nil); err != nil { + return fmt.Errorf("session.ListTools(): %v", err) + } + for _, call := range readToolCallsContext(configCtx) { + if _, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: call.Name, + Arguments: call.Arguments, + }); err != nil { + log.Printf("CallTool(%q): %v", call.Name, err) + } + } + return nil +} + +// runHTTPInvalidToolHeadersClient (SEP-2243 invalid-tool filtering) lists +// tools — a correct SDK leaves only the valid ones — then calls each survivor +// so the referee records SUCCESS for every excluded tool never called. +func runHTTPInvalidToolHeadersClient(ctx context.Context, serverURL string, _ map[string]any) error { + session, err := connectToServer(ctx, serverURL) + if err != nil { + return err + } + defer session.Close() + + tools, err := session.ListTools(ctx, nil) + if err != nil { + return fmt.Errorf("session.ListTools(): %v", err) + } + for _, tool := range tools.Tools { + _, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: tool.Name, + Arguments: map[string]any{"region": "us-west1"}, + }) + if err != nil { + log.Printf("call %q rejected: %v", tool.Name, err) + } + } + return nil +} + +type toolCall struct { + Name string + Arguments map[string]any +} + +// readToolCallsContext parses the `toolCalls` array from MCP_CONFORMANCE_CONTEXT. +// Format: {"toolCalls": [{"name":"...", "arguments":{...}}, ...]}. +func readToolCallsContext(configCtx map[string]any) []toolCall { + raw, ok := configCtx["toolCalls"].([]any) + if !ok { + return nil + } + out := make([]toolCall, 0, len(raw)) + for _, item := range raw { + obj, ok := item.(map[string]any) + if !ok { + continue + } + name, _ := obj["name"].(string) + args, _ := obj["arguments"].(map[string]any) + if args == nil { + args = map[string]any{} + } + out = append(out, toolCall{Name: name, Arguments: args}) + } + return out +} + // ============================================================================ // Main entry point // ============================================================================ From acbc1099d10d7157a37176fff089ea394f13606f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:51:14 +0000 Subject: [PATCH 333/418] build(deps): bump actions/cache from 5.0.5 to 6.1.0 (#1065) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/cache](https://github.com/actions/cache) from 5.0.5 to 6.1.0.
Release notes

Sourced from actions/cache's releases.

v6.1.0

What's Changed

Full Changelog: https://github.com/actions/cache/compare/v6...v6.1.0

v6.0.0

What's Changed

Full Changelog: https://github.com/actions/cache/compare/v5...v6.0.0

v5.1.0

What's Changed

Full Changelog: https://github.com/actions/cache/compare/v5...v5.1.0

Changelog

Sourced from actions/cache's changelog.

Releases

How to prepare a release

[!NOTE] Relevant for maintainers with write access only.

  1. Switch to a new branch from main.
  2. Run npm test to ensure all tests are passing.
  3. Update the version in https://github.com/actions/cache/blob/main/package.json.
  4. Run npm run build to update the compiled files.
  5. Update this https://github.com/actions/cache/blob/main/RELEASES.md with the new version and changes in the ## Changelog section.
  6. Run licensed cache to update the license report.
  7. Run licensed status and resolve any warnings by updating the https://github.com/actions/cache/blob/main/.licensed.yml file with the exceptions.
  8. Commit your changes and push your branch upstream.
  9. Open a pull request against main and get it reviewed and merged.
  10. Draft a new release https://github.com/actions/cache/releases use the same version number used in package.json
    1. Create a new tag with the version number.
    2. Auto generate release notes and update them to match the changes you made in RELEASES.md.
    3. Toggle the set as the latest release option.
    4. Publish the release.
  11. Navigate to https://github.com/actions/cache/actions/workflows/release-new-action-version.yml
    1. There should be a workflow run queued with the same version number.
    2. Approve the run to publish the new version and update the major tags for this action.

Changelog

6.1.0

6.0.0

  • Updated @actions/cache to ^6.0.1, @actions/core to ^3.0.1, @actions/exec to ^3.0.0, @actions/io to ^3.0.2
  • Migrated to ESM module system
  • Upgraded Jest to v30 and test infrastructure to be ESM compatible

5.0.4

  • Bump minimatch to v3.1.5 (fixes ReDoS via globstar patterns)
  • Bump undici to v6.24.1 (WebSocket decompression bomb protection, header validation fixes)
  • Bump fast-xml-parser to v5.5.6

5.0.3

5.0.2

... (truncated)

Commits
  • 55cc834 Merge pull request #1768 from jasongin/readonly-cache
  • d8cd72f Bump @​actions/cache to v6.1.0 - handle cache write error due to RO token
  • 2c8a9bd Merge pull request #1760 from actions/samirat/esm_migration_and_package_update
  • e9b91fd Prettier fixes
  • e4884b8 Rebuild dist
  • 10baf01 Fixed licenses
  • e39b386 Fix test mock return order
  • b692820 PR feedback
  • 6074912 Rebuild dist bundles as ESM to match type:module
  • 5a912e8 Fix lint and jest issues
  • Additional commits viewable in compare view

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index d4beb3c..e64faa2 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -22,7 +22,7 @@ jobs: with: python-version: 3.x - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: mkdocs-material-${{ env.cache_id }} path: .cache From 703b0f487546576c2865b510f81bb80f4e1a309c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:58:21 +0000 Subject: [PATCH 334/418] build(deps): bump actions/setup-go from 6.4.0 to 6.5.0 (#1066) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [//]: # (dependabot-start) ⚠️ **Dependabot is rebasing this PR** ⚠️ Rebasing might not happen immediately, so don't worry if this takes some time. Note: if you make any changes to this PR yourself, they will take precedence over the rebase. --- [//]: # (dependabot-end) Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6.4.0 to 6.5.0.
Release notes

Sourced from actions/setup-go's releases.

v6.5.0

What's Changed

Dependency update

New Contributors

Full Changelog: https://github.com/actions/setup-go/compare/v6...v6.5.0

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/setup-go&package-manager=github_actions&previous-version=6.4.0&new-version=6.5.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/conformance.yml | 4 ++-- .github/workflows/docs-check.yml | 2 +- .github/workflows/nightly.yml | 2 +- .github/workflows/test.yml | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index feb5000..77276d7 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -23,7 +23,7 @@ jobs: - name: Check out code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: "^1.26" - name: Set up Node.js @@ -61,7 +61,7 @@ jobs: - name: Check out code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: "^1.26" - name: Set up Node.js diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index 3c18c84..6e6972e 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: "^1.26" - name: Check out code diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 3dd3cee..21d0420 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -25,7 +25,7 @@ jobs: - name: Check out code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: "^1.26" - name: Set up Node.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a708cac..32ef66a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: - name: Check out code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: "^1.26" - name: Check formatting @@ -45,7 +45,7 @@ jobs: - name: Check out code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: ${{ matrix.go }} - name: Test @@ -57,7 +57,7 @@ jobs: - name: Check out code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: "1.26" - name: Test with -race From b426fbf6153b1be855a79a6d616bee0796ec75be Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:00:10 +0000 Subject: [PATCH 335/418] build(deps): bump the codeql-action group with 3 updates (#1064) Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action). Updates `github/codeql-action/init` from 4.36.0 to 4.37.0
Release notes

Sourced from github/codeql-action/init's releases.

v4.37.0

  • Update default CodeQL bundle version to 2.26.0. #3995
  • In addition to the existing input format, the config-file input for the codeql-action/init step will soon support a new [owner/]repo[@ref][:path] format. All components except the repository name are optional. If omitted, owner defaults to the same owner as the repository the analysis is running for, ref to main, and path to .github/codeql-action.yaml. Support for this format ships in this version of the CodeQL Action, but will only be enabled over the coming weeks. #3973

v4.36.3

No user facing changes.

v4.36.2

  • Cache CodeQL CLI version information across Actions steps. #3943
  • Reduce requests while waiting for analysis processing by using exponential backoff when polling SARIF processing status. #3937
  • Update default CodeQL bundle version to 2.25.6. #3948

v4.36.1

No user facing changes.

Changelog

Sourced from github/codeql-action/init's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

No user facing changes.

4.37.0 - 08 Jul 2026

  • Update default CodeQL bundle version to 2.26.0. #3995
  • In addition to the existing input format, the config-file input for the codeql-action/init step will soon support a new [owner/]repo[@ref][:path] format. All components except the repository name are optional. If omitted, owner defaults to the same owner as the repository the analysis is running for, ref to main, and path to .github/codeql-action.yaml. Support for this format ships in this version of the CodeQL Action, but will only be enabled over the coming weeks. #3973

4.36.3 - 01 Jul 2026

No user facing changes.

4.36.2 - 04 Jun 2026

  • Cache CodeQL CLI version information across Actions steps. #3943
  • Reduce requests while waiting for analysis processing by using exponential backoff when polling SARIF processing status. #3937
  • Update default CodeQL bundle version to 2.25.6. #3948

4.36.1 - 02 Jun 2026

No user facing changes.

4.36.0 - 22 May 2026

  • Breaking change: Bump the minimum required CodeQL bundle version to 2.19.4. #3894
  • Add support for SHA-256 Git object IDs. #3893
  • Update default CodeQL bundle version to 2.25.5. #3926

4.35.5 - 15 May 2026

  • We have improved how the JavaScript bundles for the CodeQL Action are generated to avoid duplication across bundles and reduce the size of the repository by around 70%. This should have no effect on the runtime behaviour of the CodeQL Action. #3899
  • For performance and accuracy reasons, improved incremental analysis will now only be enabled on a pull request when diff-informed analysis is also enabled for that run. If diff-informed analysis is unavailable (for example, because the PR diff ranges could not be computed), the action will fall back to a full analysis. #3791
  • If multiple inputs are provided for the GitHub-internal analysis-kinds input, only code-scanning will be enabled. The analysis-kinds input is experimental, for GitHub-internal use only, and may change without notice at any time. #3892
  • Added an experimental change which, when running a Code Scanning analysis for a PR with improved incremental analysis enabled, prefers CodeQL CLI versions that have a cached overlay-base database for the configured languages. This speeds up analysis for a repository when there is not yet a cached overlay-base database for the latest CLI version. We expect to roll this change out to everyone in May. #3880

4.35.4 - 07 May 2026

  • Update default CodeQL bundle version to 2.25.4. #3881

4.35.3 - 01 May 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.19.3 and earlier. These versions of CodeQL were discontinued on 9 April 2026 alongside GitHub Enterprise Server 3.15, and will be unsupported by the next minor release of the CodeQL Action. #3837
  • Configurations for private registries that use Cloudsmith or GCP OIDC are now accepted. #3850
  • Best-effort connection tests for private registries now use GET requests instead of HEAD for better compatibility with various registry implementations. For NuGet feeds, the test is now always performed against the service index. #3853
  • Fixed a bug where two diagnostics produced within the same millisecond could overwrite each other on disk, causing one of them to be lost. #3852

... (truncated)

Commits
  • 99df26d Merge pull request #3996 from github/update-v4.37.0-c7c896d71
  • 31c2707 Add changenote for #3973
  • 72df218 Update changelog for v4.37.0
  • c7c896d Merge pull request #3995 from github/update-bundle/codeql-bundle-v2.26.0
  • 3f34ff0 Add changelog note
  • 43bec09 Update default bundle to codeql-bundle-v2.26.0
  • f58f0d1 Merge pull request #3973 from github/mbg/repo-props/config-file-shorthands
  • 7dc37cb Merge remote-tracking branch 'origin/main' into mbg/repo-props/config-file-sh...
  • 8e22350 Thread ActionState to initConfig
  • 69c9e8c Mark some status-report imports as type-only to avoid circular dependencies
  • Additional commits viewable in compare view

Updates `github/codeql-action/analyze` from 4.36.0 to 4.37.0
Release notes

Sourced from github/codeql-action/analyze's releases.

v4.37.0

  • Update default CodeQL bundle version to 2.26.0. #3995
  • In addition to the existing input format, the config-file input for the codeql-action/init step will soon support a new [owner/]repo[@ref][:path] format. All components except the repository name are optional. If omitted, owner defaults to the same owner as the repository the analysis is running for, ref to main, and path to .github/codeql-action.yaml. Support for this format ships in this version of the CodeQL Action, but will only be enabled over the coming weeks. #3973

v4.36.3

No user facing changes.

v4.36.2

  • Cache CodeQL CLI version information across Actions steps. #3943
  • Reduce requests while waiting for analysis processing by using exponential backoff when polling SARIF processing status. #3937
  • Update default CodeQL bundle version to 2.25.6. #3948

v4.36.1

No user facing changes.

Changelog

Sourced from github/codeql-action/analyze's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

No user facing changes.

4.37.0 - 08 Jul 2026

  • Update default CodeQL bundle version to 2.26.0. #3995
  • In addition to the existing input format, the config-file input for the codeql-action/init step will soon support a new [owner/]repo[@ref][:path] format. All components except the repository name are optional. If omitted, owner defaults to the same owner as the repository the analysis is running for, ref to main, and path to .github/codeql-action.yaml. Support for this format ships in this version of the CodeQL Action, but will only be enabled over the coming weeks. #3973

4.36.3 - 01 Jul 2026

No user facing changes.

4.36.2 - 04 Jun 2026

  • Cache CodeQL CLI version information across Actions steps. #3943
  • Reduce requests while waiting for analysis processing by using exponential backoff when polling SARIF processing status. #3937
  • Update default CodeQL bundle version to 2.25.6. #3948

4.36.1 - 02 Jun 2026

No user facing changes.

4.36.0 - 22 May 2026

  • Breaking change: Bump the minimum required CodeQL bundle version to 2.19.4. #3894
  • Add support for SHA-256 Git object IDs. #3893
  • Update default CodeQL bundle version to 2.25.5. #3926

4.35.5 - 15 May 2026

  • We have improved how the JavaScript bundles for the CodeQL Action are generated to avoid duplication across bundles and reduce the size of the repository by around 70%. This should have no effect on the runtime behaviour of the CodeQL Action. #3899
  • For performance and accuracy reasons, improved incremental analysis will now only be enabled on a pull request when diff-informed analysis is also enabled for that run. If diff-informed analysis is unavailable (for example, because the PR diff ranges could not be computed), the action will fall back to a full analysis. #3791
  • If multiple inputs are provided for the GitHub-internal analysis-kinds input, only code-scanning will be enabled. The analysis-kinds input is experimental, for GitHub-internal use only, and may change without notice at any time. #3892
  • Added an experimental change which, when running a Code Scanning analysis for a PR with improved incremental analysis enabled, prefers CodeQL CLI versions that have a cached overlay-base database for the configured languages. This speeds up analysis for a repository when there is not yet a cached overlay-base database for the latest CLI version. We expect to roll this change out to everyone in May. #3880

4.35.4 - 07 May 2026

  • Update default CodeQL bundle version to 2.25.4. #3881

4.35.3 - 01 May 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.19.3 and earlier. These versions of CodeQL were discontinued on 9 April 2026 alongside GitHub Enterprise Server 3.15, and will be unsupported by the next minor release of the CodeQL Action. #3837
  • Configurations for private registries that use Cloudsmith or GCP OIDC are now accepted. #3850
  • Best-effort connection tests for private registries now use GET requests instead of HEAD for better compatibility with various registry implementations. For NuGet feeds, the test is now always performed against the service index. #3853
  • Fixed a bug where two diagnostics produced within the same millisecond could overwrite each other on disk, causing one of them to be lost. #3852

... (truncated)

Commits
  • 99df26d Merge pull request #3996 from github/update-v4.37.0-c7c896d71
  • 31c2707 Add changenote for #3973
  • 72df218 Update changelog for v4.37.0
  • c7c896d Merge pull request #3995 from github/update-bundle/codeql-bundle-v2.26.0
  • 3f34ff0 Add changelog note
  • 43bec09 Update default bundle to codeql-bundle-v2.26.0
  • f58f0d1 Merge pull request #3973 from github/mbg/repo-props/config-file-shorthands
  • 7dc37cb Merge remote-tracking branch 'origin/main' into mbg/repo-props/config-file-sh...
  • 8e22350 Thread ActionState to initConfig
  • 69c9e8c Mark some status-report imports as type-only to avoid circular dependencies
  • Additional commits viewable in compare view

Updates `github/codeql-action/upload-sarif` from 4.36.2 to 4.37.0
Release notes

Sourced from github/codeql-action/upload-sarif's releases.

v4.37.0

  • Update default CodeQL bundle version to 2.26.0. #3995
  • In addition to the existing input format, the config-file input for the codeql-action/init step will soon support a new [owner/]repo[@ref][:path] format. All components except the repository name are optional. If omitted, owner defaults to the same owner as the repository the analysis is running for, ref to main, and path to .github/codeql-action.yaml. Support for this format ships in this version of the CodeQL Action, but will only be enabled over the coming weeks. #3973

v4.36.3

No user facing changes.

Changelog

Sourced from github/codeql-action/upload-sarif's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

No user facing changes.

4.37.0 - 08 Jul 2026

  • Update default CodeQL bundle version to 2.26.0. #3995
  • In addition to the existing input format, the config-file input for the codeql-action/init step will soon support a new [owner/]repo[@ref][:path] format. All components except the repository name are optional. If omitted, owner defaults to the same owner as the repository the analysis is running for, ref to main, and path to .github/codeql-action.yaml. Support for this format ships in this version of the CodeQL Action, but will only be enabled over the coming weeks. #3973

4.36.3 - 01 Jul 2026

No user facing changes.

4.36.2 - 04 Jun 2026

  • Cache CodeQL CLI version information across Actions steps. #3943
  • Reduce requests while waiting for analysis processing by using exponential backoff when polling SARIF processing status. #3937
  • Update default CodeQL bundle version to 2.25.6. #3948

4.36.1 - 02 Jun 2026

No user facing changes.

4.36.0 - 22 May 2026

  • Breaking change: Bump the minimum required CodeQL bundle version to 2.19.4. #3894
  • Add support for SHA-256 Git object IDs. #3893
  • Update default CodeQL bundle version to 2.25.5. #3926

4.35.5 - 15 May 2026

  • We have improved how the JavaScript bundles for the CodeQL Action are generated to avoid duplication across bundles and reduce the size of the repository by around 70%. This should have no effect on the runtime behaviour of the CodeQL Action. #3899
  • For performance and accuracy reasons, improved incremental analysis will now only be enabled on a pull request when diff-informed analysis is also enabled for that run. If diff-informed analysis is unavailable (for example, because the PR diff ranges could not be computed), the action will fall back to a full analysis. #3791
  • If multiple inputs are provided for the GitHub-internal analysis-kinds input, only code-scanning will be enabled. The analysis-kinds input is experimental, for GitHub-internal use only, and may change without notice at any time. #3892
  • Added an experimental change which, when running a Code Scanning analysis for a PR with improved incremental analysis enabled, prefers CodeQL CLI versions that have a cached overlay-base database for the configured languages. This speeds up analysis for a repository when there is not yet a cached overlay-base database for the latest CLI version. We expect to roll this change out to everyone in May. #3880

4.35.4 - 07 May 2026

  • Update default CodeQL bundle version to 2.25.4. #3881

4.35.3 - 01 May 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.19.3 and earlier. These versions of CodeQL were discontinued on 9 April 2026 alongside GitHub Enterprise Server 3.15, and will be unsupported by the next minor release of the CodeQL Action. #3837
  • Configurations for private registries that use Cloudsmith or GCP OIDC are now accepted. #3850
  • Best-effort connection tests for private registries now use GET requests instead of HEAD for better compatibility with various registry implementations. For NuGet feeds, the test is now always performed against the service index. #3853
  • Fixed a bug where two diagnostics produced within the same millisecond could overwrite each other on disk, causing one of them to be lost. #3852

... (truncated)

Commits
  • 99df26d Merge pull request #3996 from github/update-v4.37.0-c7c896d71
  • 31c2707 Add changenote for #3973
  • 72df218 Update changelog for v4.37.0
  • c7c896d Merge pull request #3995 from github/update-bundle/codeql-bundle-v2.26.0
  • 3f34ff0 Add changelog note
  • 43bec09 Update default bundle to codeql-bundle-v2.26.0
  • f58f0d1 Merge pull request #3973 from github/mbg/repo-props/config-file-shorthands
  • 7dc37cb Merge remote-tracking branch 'origin/main' into mbg/repo-props/config-file-sh...
  • 8e22350 Thread ActionState to initConfig
  • 69c9e8c Mark some status-report imports as type-only to avoid circular dependencies
  • Additional commits viewable in compare view

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Guglielmo Colombo --- .github/workflows/codeql.yml | 4 ++-- .github/workflows/scorecard.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ecc3a39..d65e6ab 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -38,11 +38,11 @@ jobs: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Initialize CodeQL - uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 563e21a..4eca0b9 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -73,6 +73,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: sarif_file: results.sarif From ce393acb951e15d0c766deab4e06c3cb0e8dfc8f Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Fri, 10 Jul 2026 11:26:29 +0200 Subject: [PATCH 336/418] mcp: do not ApplyDefault when `res.Content==nil` (#1069) Fixes #1062 --- mcp/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp/server.go b/mcp/server.go index c59f793..bc58fd1 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1671,7 +1671,7 @@ func (ss *ServerSession) Elicit(ctx context.Context, params *ElicitParams) (*Eli return nil, err } - if res.Action != "accept" { + if res.Action != "accept" || res.Content == nil { return res, nil } From 40f034322f9da2169833476d8056732d6dc21545 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Mon, 13 Jul 2026 11:49:15 +0200 Subject: [PATCH 337/418] mcp: infer elicit mode from ElicitParams (#1078) Fixes #1072 --- mcp/protocol.go | 18 ++++++++++++++++++ mcp/server.go | 10 +--------- mcp/testdata/conformance/server/mrtr.txtar | 2 +- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/mcp/protocol.go b/mcp/protocol.go index a68ef42..6ae470b 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -63,6 +63,9 @@ func (m InputRequestMap) MarshalJSON() ([]byte, error) { if err != nil { return nil, err } + if ep, ok := v.(*ElicitParams); ok { + v = ep.inferElicitMode() + } converted[k] = &wire{Method: method, Params: v} } return json.Marshal(converted) @@ -2126,6 +2129,21 @@ func (x *ElicitParams) isNil() bool { return x == nil } func (x *ElicitParams) GetProgressToken() any { return getProgressToken(x) } func (x *ElicitParams) SetProgressToken(t any) { setProgressToken(x, t) } +// inferElicitMode returns x with Mode populated by inference if it was empty. +// Mode is inferred as "url" when URL or ElicitationID is set, otherwise "form". +func (x *ElicitParams) inferElicitMode() *ElicitParams { + if x == nil || x.Mode != "" { + return x + } + x2 := *x + if x.URL != "" || x.ElicitationID != "" { + x2.Mode = "url" + } else { + x2.Mode = "form" + } + return &x2 +} + // The client's response to an elicitation/create request from the server. type ElicitResult struct { // This property is reserved by the protocol to allow clients and servers to diff --git a/mcp/server.go b/mcp/server.go index bc58fd1..5ea1d8e 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1639,15 +1639,7 @@ func (ss *ServerSession) Elicit(ctx context.Context, params *ElicitParams) (*Eli return nil, fmt.Errorf("%w: params cannot be nil", jsonrpc2.ErrInvalidParams) } - if params.Mode == "" { - params2 := *params - if params.URL != "" || params.ElicitationID != "" { - params2.Mode = "url" - } else { - params2.Mode = "form" - } - params = ¶ms2 - } + params = params.inferElicitMode() if iparams := ss.InitializeParams(); iparams == nil || iparams.Capabilities == nil || iparams.Capabilities.Elicitation == nil { return nil, fmt.Errorf("client does not support elicitation") diff --git a/mcp/testdata/conformance/server/mrtr.txtar b/mcp/testdata/conformance/server/mrtr.txtar index 7ee6dc5..4cf366c 100644 --- a/mcp/testdata/conformance/server/mrtr.txtar +++ b/mcp/testdata/conformance/server/mrtr.txtar @@ -52,7 +52,7 @@ confirmThenGreet "who": { "method": "elicitation/create", "params": { - "mode": "", + "mode": "form", "message": "What is your name?", "requestedSchema": { "type": "object", From dd79ca3d4442ff18641ace609023737d193dd3e7 Mon Sep 17 00:00:00 2001 From: Yashas Gunderia <68075205+ychampion@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:21:19 +0530 Subject: [PATCH 338/418] mcp: include resultType on new-protocol responses (#1060) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `resultType: "complete"` to non-MRTR server result responses for 2026-07-28 requests. - Cover `server/discover`, list methods, and completion responses, and update expected conformance/example output. ## Why Fixes #1032. The 2026-07-28 schema requires `resultType` on result objects, but only the MRTR-capable results were setting it. ## Validation - `go test ./mcp -run 'Test(ServerSessionHandle|CompleteResult|ClientConnectDiscover|InMemory_E2E_Discover)' -count=1` — passed\n- `go test ./mcp -count=1` — passed\n- `go test ./...` — passed\n- `go vet ./...` — passed --------- Co-authored-by: ychampion Co-authored-by: Guglielmo Colombo --- mcp/protocol.go | 24 +++ mcp/protocol_test.go | 3 +- mcp/server.go | 9 +- mcp/server_test.go | 142 ++++++++++++++++++ .../conformance/server/discover.txtar | 1 + mcp/transport_example_test.go | 2 +- 6 files changed, 178 insertions(+), 3 deletions(-) diff --git a/mcp/protocol.go b/mcp/protocol.go index 6ae470b..dc760a5 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -28,6 +28,24 @@ const ( resultTypeInputRequired resultType = "input_required" ) +type completeResultWithType struct { + ResultType resultType `json:"resultType,omitempty"` +} + +func (r *completeResultWithType) setResultType(rt resultType) { r.ResultType = rt } +func (*completeResultWithType) isCompleteResult() {} + +type completeResultResponse interface { + setResultType(resultType) + isCompleteResult() +} + +func setCompleteResultType(res Result) { + if r, ok := res.(completeResultResponse); ok { + r.setResultType(resultTypeComplete) + } +} + // InputRequest is a type for parameters that a server can include in the response // to request input from client (SEP-2322). Implementations are [*ElicitParams], // [*CreateMessageParams], and [*ListRootsParams]. @@ -640,6 +658,7 @@ type CompletionResultDetails struct { // The server's response to a completion/complete request type CompleteResult struct { + completeResultWithType // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` @@ -1117,6 +1136,7 @@ func (x *DiscoverParams) GetProgressToken() any { return getProgressToken(x) } func (x *DiscoverParams) SetProgressToken(t any) { setProgressToken(x, t) } type DiscoverResult struct { + completeResultWithType Meta `json:"_meta,omitempty"` Cacheable // The versions of the Model Context Protocol that the server supports. @@ -1180,6 +1200,7 @@ func (c *Cacheable) setDefaultCacheableValues() { // The server's response to a prompts/list request from the client. type ListPromptsResult struct { + completeResultWithType // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` @@ -1210,6 +1231,7 @@ func (x *ListResourceTemplatesParams) cursorPtr() *string { return &x.Cursor // The server's response to a resources/templates/list request from the client. type ListResourceTemplatesResult struct { + completeResultWithType // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` @@ -1240,6 +1262,7 @@ func (x *ListResourcesParams) cursorPtr() *string { return &x.Cursor } // The server's response to a resources/list request from the client. type ListResourcesResult struct { + completeResultWithType // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` @@ -1306,6 +1329,7 @@ func (x *ListToolsParams) cursorPtr() *string { return &x.Cursor } // The server's response to a tools/list request from the client. type ListToolsResult struct { + completeResultWithType // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. Meta `json:"_meta,omitempty"` diff --git a/mcp/protocol_test.go b/mcp/protocol_test.go index 38fd48f..fe496de 100644 --- a/mcp/protocol_test.go +++ b/mcp/protocol_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" ) func TestParamsMeta(t *testing.T) { @@ -491,7 +492,7 @@ func TestCompleteResult(t *testing.T) { if err := json.Unmarshal([]byte(test.in), &got); err != nil { t.Fatalf("json.Unmarshal(CompleteResult) failed: %v", err) } - if diff := cmp.Diff(test.want, got); diff != "" { + if diff := cmp.Diff(test.want, got, cmpopts.IgnoreUnexported(CompleteResult{})); diff != "" { t.Errorf("CompleteResult unmarshal mismatch (-want +got):\n%s", diff) } }) diff --git a/mcp/server.go b/mcp/server.go index 5ea1d8e..58e0bb1 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1893,7 +1893,14 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, if validatedMeta.usesNewProtocol { ss.setLevel(ctx, &SetLoggingLevelParams{Level: validatedMeta.logLevel}) } - return handleReceive(ctx, ss, req) + res, err := handleReceive(ctx, ss, req) + if err != nil { + return nil, err + } + if validatedMeta.usesNewProtocol { + setCompleteResultType(res) + } + return res, nil } // InitializeParams returns the InitializeParams provided during the client's diff --git a/mcp/server_test.go b/mcp/server_test.go index ac303be..d6ecfed 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -1399,6 +1399,148 @@ func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) { }) } +func TestServerSessionHandle_SetsResultTypeOnNewProtocol(t *testing.T) { + server := NewServer(testImpl, &ServerOptions{ + CompletionHandler: func(context.Context, *CompleteRequest) (*CompleteResult, error) { + return &CompleteResult{ + Completion: CompletionResultDetails{ + Values: []string{"go"}, + }, + }, nil + }, + }) + AddTool(server, &Tool{Name: "tool"}, func(context.Context, *CallToolRequest, struct{}) (*CallToolResult, any, error) { + return &CallToolResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Continue?"}}, + }, nil, nil + }) + server.AddPrompt(&Prompt{Name: "prompt"}, func(context.Context, *GetPromptRequest) (*GetPromptResult, error) { + return &GetPromptResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Continue?"}}, + }, nil + }) + server.AddResource(&Resource{URI: "test://resource", Name: "resource"}, func(context.Context, *ReadResourceRequest) (*ReadResourceResult, error) { + return &ReadResourceResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Continue?"}}, + }, nil + }) + newProtocolParams := func(fields map[string]any) map[string]any { + params := map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260728, + MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, + MetaKeyClientCapabilities: map[string]any{}, + }, + } + for k, v := range fields { + params[k] = v + } + return params + } + + tests := []struct { + name string + method string + params map[string]any + want resultType + }{ + { + name: "discover", + method: methodDiscover, + params: newProtocolParams(nil), + want: resultTypeComplete, + }, + { + name: "tools list", + method: methodListTools, + params: newProtocolParams(nil), + want: resultTypeComplete, + }, + { + name: "prompts list", + method: methodListPrompts, + params: newProtocolParams(nil), + want: resultTypeComplete, + }, + { + name: "resources list", + method: methodListResources, + params: newProtocolParams(nil), + want: resultTypeComplete, + }, + { + name: "resource templates list", + method: methodListResourceTemplates, + params: newProtocolParams(nil), + want: resultTypeComplete, + }, + { + name: "complete", + method: methodComplete, + params: newProtocolParams(map[string]any{ + "argument": map[string]any{ + "name": "language", + "value": "g", + }, + "ref": map[string]any{ + "type": "ref/prompt", + "name": "code_review", + }, + }), + want: resultTypeComplete, + }, + { + name: "tool input required", + method: methodCallTool, + params: newProtocolParams(map[string]any{"name": "tool", "arguments": map[string]any{}}), + want: resultTypeInputRequired, + }, + { + name: "prompt input required", + method: methodGetPrompt, + params: newProtocolParams(map[string]any{"name": "prompt"}), + want: resultTypeInputRequired, + }, + { + name: "resource input required", + method: methodReadResource, + params: newProtocolParams(map[string]any{"uri": "test://resource"}), + want: resultTypeInputRequired, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ss := &ServerSession{server: server} + id, err := jsonrpc.MakeID("test") + if err != nil { + t.Fatal(err) + } + result, err := ss.handle(context.Background(), &jsonrpc.Request{ + ID: id, + Method: tc.method, + Params: mustMarshal(tc.params), + }) + if err != nil { + t.Fatal(err) + } + data, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + var got struct { + ResultType string `json:"resultType"` + } + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if got.ResultType != string(tc.want) { + t.Fatalf("resultType = %q, want %q; response = %s", got.ResultType, tc.want, 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 diff --git a/mcp/testdata/conformance/server/discover.txtar b/mcp/testdata/conformance/server/discover.txtar index 9b8276c..9552eda 100644 --- a/mcp/testdata/conformance/server/discover.txtar +++ b/mcp/testdata/conformance/server/discover.txtar @@ -21,6 +21,7 @@ supports, its capabilities, and its identity. "jsonrpc": "2.0", "id": 1, "result": { + "resultType": "complete", "ttlMs": 0, "cacheScope": "public", "supportedVersions": [ diff --git a/mcp/transport_example_test.go b/mcp/transport_example_test.go index 1920233..1d97a81 100644 --- a/mcp/transport_example_test.go +++ b/mcp/transport_example_test.go @@ -42,7 +42,7 @@ func ExampleLoggingTransport() { } // Output: - // read: {"jsonrpc":"2.0","id":1,"result":{"ttlMs":0,"cacheScope":"public","supportedVersions":["2026-07-28","2025-11-25","2025-06-18","2025-03-26","2024-11-05"],"capabilities":{"logging":{}},"serverInfo":{"name":"server","version":"v0.0.1"}}} + // read: {"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","ttlMs":0,"cacheScope":"public","supportedVersions":["2026-07-28","2025-11-25","2025-06-18","2025-03-26","2024-11-05"],"capabilities":{"logging":{}},"serverInfo":{"name":"server","version":"v0.0.1"}}} // write: {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/clientCapabilities":{"roots":{"listChanged":true}},"io.modelcontextprotocol/clientInfo":{"name":"client","version":"v0.0.1"},"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}} } From c79a30ae0a1d200aa15dff36fc16c0a5048454c8 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Mon, 13 Jul 2026 14:14:21 +0200 Subject: [PATCH 339/418] mcp: add param check for CompleteResult (#1080) Fixes #1071 --- docs/mcpgodebug.md | 6 ++++++ internal/docs/mcpgodebug.src.md | 6 ++++++ mcp/server.go | 17 +++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index 7082518..847e2e7 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -50,6 +50,12 @@ Options listed below were added and will be removed in the 1.9.0 version of the restoring the previous behavior. The default behavior was changed so that the client always attempts to surface the underlying JSON-RPC error. +- `disablecompleteparamsvalidation` added. If set to `1`, `Server.complete` + will not validate that the required `ref` and `argument.name` fields on + `CompleteParams` are present, restoring the previous behavior of dispatching + the request to the completion handler unconditionally. The default behavior + was changed to reject malformed requests with `-32602` (Invalid Params). + ### 1.6.1 Options listed below were added and will be removed in the 1.8.0 version of the SDK. diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index 1f15072..5c9c9d9 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -49,6 +49,12 @@ Options listed below were added and will be removed in the 1.9.0 version of the restoring the previous behavior. The default behavior was changed so that the client always attempts to surface the underlying JSON-RPC error. +- `disablecompleteparamsvalidation` added. If set to `1`, `Server.complete` + will not validate that the required `ref` and `argument.name` fields on + `CompleteParams` are present, restoring the previous behavior of dispatching + the request to the completion handler unconditionally. The default behavior + was changed to reject malformed requests with `-32602` (Invalid Params). + ### 1.6.1 Options listed below were added and will be removed in the 1.8.0 version of the SDK. diff --git a/mcp/server.go b/mcp/server.go index 58e0bb1..14adfad 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -27,6 +27,7 @@ import ( "github.com/google/jsonschema-go/jsonschema" internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + "github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug" "github.com/modelcontextprotocol/go-sdk/internal/util" "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/yosida95/uritemplate/v3" @@ -661,7 +662,23 @@ func (s *Server) capabilities() *ServerCapabilities { return caps } +// disablecompleteparamsvalidation is a compatibility parameter that restores +// the previous behavior of [Server.complete], where required fields on +// [CompleteParams] were not validated before dispatching to the completion +// handler. See the documentation for the mcpgodebug package for instructions +// how to enable it. +// The option will be removed in a future version of the SDK. +var disablecompleteparamsvalidation = mcpgodebug.Value("disablecompleteparamsvalidation") + func (s *Server) complete(ctx context.Context, req *CompleteRequest) (*CompleteResult, error) { + if disablecompleteparamsvalidation != "1" { + if req.Params.Ref == nil { + return nil, fmt.Errorf("%w: missing required 'ref' field", jsonrpc2.ErrInvalidParams) + } + if req.Params.Argument.Name == "" { + return nil, fmt.Errorf("%w: missing required 'argument.name' field", jsonrpc2.ErrInvalidParams) + } + } if s.opts.CompletionHandler == nil { return nil, jsonrpc2.ErrMethodNotFound } From beb3d1ab7a680c827e79fd7817b36e818696ddc0 Mon Sep 17 00:00:00 2001 From: Scott Leggett Date: Mon, 13 Jul 2026 21:10:05 +0800 Subject: [PATCH 340/418] auth: support OAuth2 session persistence (#1058) This change adds NewTokenSource and InitialTokenSource fields to AuthorizationCodeHandlerConfig. NewTokenSource enables wrapping the underlying token source to intercept token refreshes. InitialTokenSource enables configuring the token source used by the AuthorizationCodeHandler on initialization. Together these features facilitate persisting OAuth2 sessions across restarts. Includes a package example demonstrating the persistence pattern. Fixes: #901. See #901 for the previous discussion behind this design. --------- Co-authored-by: Guglielmo Colombo Co-authored-by: Jonathan Amsterdam --- auth/auth_example_test.go | 222 ++++++++++++++++++++++++++++++++ auth/authorization_code.go | 55 +++++++- auth/authorization_code_test.go | 89 +++++++++++++ 3 files changed, 362 insertions(+), 4 deletions(-) create mode 100644 auth/auth_example_test.go diff --git a/auth/auth_example_test.go b/auth/auth_example_test.go new file mode 100644 index 0000000..bf97eb8 --- /dev/null +++ b/auth/auth_example_test.go @@ -0,0 +1,222 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package auth_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "sync" + + "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/modelcontextprotocol/go-sdk/oauthex" + "golang.org/x/oauth2" +) + +// savingTokenSource is an oauth2.TokenSource that passes the oauth2 config and +// token to the given saver function each time the access token value changes. +type savingTokenSource struct { + mu sync.Mutex + src oauth2.TokenSource + saver func(*oauth2.Config, *oauth2.Token) error + config *oauth2.Config + accessToken string +} + +func (s *savingTokenSource) Token() (*oauth2.Token, error) { + s.mu.Lock() + defer s.mu.Unlock() + tok, err := s.src.Token() + if err != nil { + return nil, err + } + if s.accessToken != tok.AccessToken { + s.accessToken = tok.AccessToken + // This saver implementation always returns nil. + _ = s.saver(s.config, tok) + } + return tok, nil +} + +// NewSavingTokenSource persists OAuth 2.0 sessions by intercepting token +// changes from the wrapped oauth2.TokenSource. When this wrapper detects an +// access token change, it calls the provided session saver with the +// oauth2.Config and the new oauth2.Token. +// +// initial is an optional access token the caller may already hold (such as a +// token loaded from storage). It initialises the wrapper's state so that if +// the first wrapped.Token() call returns the same token, does not trigger a +// redundant call to saver(). Pass nil when there is no existing token, in +// which case the first token produced by wrapped.Token() is saved. +func NewSavingTokenSource(wrapped oauth2.TokenSource, config *oauth2.Config, initial *oauth2.Token, saver func(*oauth2.Config, *oauth2.Token) error) oauth2.TokenSource { + if wrapped == nil { + return nil + } + if saver == nil { + return wrapped + } + var accessToken string + if initial != nil { + accessToken = initial.AccessToken + } + return &savingTokenSource{ + src: wrapped, + saver: saver, + config: config, + accessToken: accessToken, + } +} + +// sessionStore is an in-memory OAuth 2.0 session store used to demonstrate +// persistence. A production implementation would persist the session to disk or +// a secret store. +type sessionStore struct { + config *oauth2.Config + token *oauth2.Token +} + +// save persists the given OAuth 2.0 config and token. +func (s *sessionStore) save(config *oauth2.Config, token *oauth2.Token) error { + fmt.Printf("Saving token: %s\n", token.AccessToken) + s.config = config + s.token = token + return nil +} + +// restore loads a previously persisted OAuth 2.0 session, if one exists. +func (s *sessionStore) restore() (*oauth2.Config, *oauth2.Token, error) { + if s.config != nil && s.token != nil { + fmt.Println("Restoring session.") + } else { + fmt.Println("No session found to restore.") + } + return s.config, s.token, nil +} + +// newMockAuthServer returns an httptest.Server that simulates both an MCP +// resource server requiring authorization and its OAuth authorization server. +func newMockAuthServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/oauth-authorization-server": + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"issuer": "http://%s", "authorization_endpoint": "http://%s/auth", "token_endpoint": "http://%s/token", "code_challenge_methods_supported": ["S256"]}`, r.Host, r.Host, r.Host) + case "/token": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token": "mock-token", "token_type": "bearer"}`)) + default: + // The mock MCP endpoint returns 401 until the client presents a valid + // bearer token. + if r.Header.Get("Authorization") == "Bearer mock-token" { + // A real server would return a valid MCP message here. The empty + // response causes Connect to return an error after authorization, + // which is ignored for this example which demonstrates token + // persistence only. + w.WriteHeader(http.StatusOK) + return + } + w.Header().Set("WWW-Authenticate", "Bearer") + w.WriteHeader(http.StatusUnauthorized) + } + })) +} + +// This example shows how OAuth2 session persistence might be implemented. It +// connects twice using a shared in-memory session store: the first connection +// authorizes and saves the session, and the second restores and reuses it. +func Example_persistence() { + // Simulate an MCP server that requires authorization and an OAuth server. + mockServer := newMockAuthServer() + defer mockServer.Close() + + // store persists the OAuth2 session across both connections. + store := &sessionStore{} + + // connect performs a single client connection, restoring any saved session + // beforehand and saving any new session acquired during authorization. + connect := func() { + // Load the OAuth2 session if available, and use it. + var initialTS oauth2.TokenSource + cfg, tok, err := store.restore() + if err == nil && cfg != nil && tok != nil { + initialTS = NewSavingTokenSource( + cfg.TokenSource(context.Background(), tok), + cfg, tok, store.save, + ) + } + + // Configure and initialize the AuthorizationCodeHandler with a + // NewTokenSource that saves the session when the access token changes, + // and the InitialTokenSource set to the session loaded via restore(). + config := &auth.AuthorizationCodeHandlerConfig{ + RedirectURL: "http://localhost/callback", + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "example", + }, + Client: mockServer.Client(), + AuthorizationCodeFetcher: func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) { + fmt.Println("No token source found. Transport is calling Authorize()...") + // Extract the generated state from the authorization URL + u, _ := url.Parse(args.URL) + state := u.Query().Get("state") + return &auth.AuthorizationResult{Code: "mock-code", State: state}, nil + }, + NewTokenSource: func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (oauth2.TokenSource, error) { + // This save implementation always returns nil. + _ = store.save(cfg, token) + return NewSavingTokenSource( + cfg.TokenSource(ctx, token), cfg, token, store.save, + ), nil + }, + InitialTokenSource: initialTS, + } + handler, err := auth.NewAuthorizationCodeHandler(config) + if err != nil { + fmt.Printf("Error creating handler: %v\n", err) + return + } + + // Set the constructed handler on a transport. + transport := &mcp.StreamableClientTransport{ + Endpoint: mockServer.URL + "/sse", + OAuthHandler: handler, + } + + // Create a client and attempt to connect using the configured + // transport. The transport will automatically: + // 1. Call TokenSource() to check for an existing session. This will + // return InitialTokenSource, if set. + // 2. Try the MCP endpoint, and encounter a 401 response from the mock + // server. + // 3. Call Authorize() to perform the OAuth flow, which calls + // NewTokenSource. In this example NewTokenSource saves the newly + // acquired session. + // 4. Retry the MCP endpoint with a valid bearer token and get a 200. + client := mcp.NewClient( + &mcp.Implementation{Name: "example", Version: "1.0.0"}, nil, + ) + // Response ignored: this example asserts authorization only. + _, _ = client.Connect(context.Background(), transport, nil) + } + + // The first connection has no saved session, so it authorizes and saves. + fmt.Println("--- First connect ---") + connect() + // The second connection restores the saved session and reuses it, so no + // further authorization or save occurs. + fmt.Println("--- Second connect ---") + connect() + + // Output: + // --- First connect --- + // No session found to restore. + // No token source found. Transport is calling Authorize()... + // Saving token: mock-token + // --- Second connect --- + // Restoring session. +} diff --git a/auth/authorization_code.go b/auth/authorization_code.go index 963884d..d897d84 100644 --- a/auth/authorization_code.go +++ b/auth/authorization_code.go @@ -14,6 +14,7 @@ import ( "net/url" "slices" "strings" + "sync" "github.com/modelcontextprotocol/go-sdk/internal/authutil" "github.com/modelcontextprotocol/go-sdk/internal/util" @@ -126,6 +127,24 @@ type AuthorizationCodeHandlerConfig struct { // https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#server-side-request-forgery-ssrf // If not provided, http.DefaultClient will be used. Client *http.Client + + // NewTokenSource is an optional function that can be set to construct the + // token source that will be used by the [AuthorizationCodeHandler]. If + // non-nil, it is called after the authorization code is successfully + // exchanged for a token in [AuthorizationCodeHandler.Authorize] + // to obtain the [oauth2.TokenSource] returned by + // [AuthorizationCodeHandler.TokenSource]. Implementations must use the + // provided context, which is properly configured for constructing a + // TokenSource. The default is to call [oauth2.Config.TokenSource]. + NewTokenSource func(context.Context, *oauth2.Config, *oauth2.Token) (oauth2.TokenSource, error) + + // InitialTokenSource is an optional field that can be set to inject the + // token source that will be used by the [AuthorizationCodeHandler]. If + // non-nil, it is set as the token source that will be returned by + // [AuthorizationCodeHandler.TokenSource] during handler initialization. + // The default is nil, which means no token source has been set initially, + // and will trigger a call to [AuthorizationCodeHandler.Authorize]. + InitialTokenSource oauth2.TokenSource } // AuthorizationCodeHandler is an implementation of [OAuthHandler] that uses @@ -133,6 +152,9 @@ type AuthorizationCodeHandlerConfig struct { type AuthorizationCodeHandler struct { config *AuthorizationCodeHandlerConfig + // mu protects concurrent access to tokenSource and grantedScopes. + mu sync.RWMutex + // tokenSource is the token source to use for authorization. tokenSource oauth2.TokenSource @@ -143,6 +165,8 @@ type AuthorizationCodeHandler struct { var _ OAuthHandler = (*AuthorizationCodeHandler)(nil) func (h *AuthorizationCodeHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) { + h.mu.RLock() + defer h.mu.RUnlock() return h.tokenSource, nil } @@ -199,6 +223,7 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho } return &AuthorizationCodeHandler{ config: config, + tokenSource: config.InitialTokenSource, grantedScopes: make(map[string][]string), }, nil } @@ -304,7 +329,10 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ // Accumulate scopes: union previously granted scopes with the newly // challenged scopes so that step-up authorization does not lose // permissions granted in earlier rounds (SEP-2350). - requestedScopes = authutil.UnionScopes(h.grantedScopes[asm.Issuer], requestedScopes) + h.mu.RLock() + granted := h.grantedScopes[asm.Issuer] + h.mu.RUnlock() + requestedScopes = authutil.UnionScopes(granted, requestedScopes) cfg := &oauth2.Config{ ClientID: resolvedClientConfig.clientID, @@ -615,23 +643,42 @@ func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context // completes. Use a background context that still carries the configured HTTP // client so refreshes keep working for the life of the token source. refreshCtx := context.WithValue(context.Background(), oauth2.HTTPClient, h.config.Client) - h.tokenSource = cfg.TokenSource(refreshCtx, token) + var ts oauth2.TokenSource + if h.config.NewTokenSource == nil { + ts = cfg.TokenSource(refreshCtx, token) + } else { + var err error + ts, err = h.config.NewTokenSource(refreshCtx, cfg, token) + if err != nil { + return fmt.Errorf("constructing token source failed: %w", err) + } + } + h.mu.Lock() + h.tokenSource = ts + h.mu.Unlock() return nil } // updateGrantedScopes updates the granted scopes based on the token source and requested scopes. func (h *AuthorizationCodeHandler) updateGrantedScopes(issuer string, requestedScopes []string) error { - if h.tokenSource == nil { + h.mu.RLock() + ts := h.tokenSource + h.mu.RUnlock() + + if ts == nil { return nil } - tok, err := h.tokenSource.Token() + tok, err := ts.Token() if err != nil { return err } + + h.mu.Lock() if tokenScopes := authutil.ScopesFromToken(tok); tokenScopes == nil { h.grantedScopes[issuer] = requestedScopes } else { h.grantedScopes[issuer] = tokenScopes } + h.mu.Unlock() return nil } diff --git a/auth/authorization_code_test.go b/auth/authorization_code_test.go index 15ca69f..9833597 100644 --- a/auth/authorization_code_test.go +++ b/auth/authorization_code_test.go @@ -1233,3 +1233,92 @@ func validConfig() *AuthorizationCodeHandlerConfig { }, } } + +func TestNewTokenSource(t *testing.T) { + // mock the /token endpoint to successfully return an access token on code exchange + mockTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/token" { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"access_token": "test_token", "token_type": "bearer"}`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer mockTS.Close() + + // configure the handler and set NewTokenSource + var called bool + handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{ + RedirectURL: "http://localhost/callback", + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "test_client", + }, + AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + u, _ := url.Parse(args.URL) + return &AuthorizationResult{ + Code: "test_code", + State: u.Query().Get("state"), + }, nil + }, + NewTokenSource: func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (oauth2.TokenSource, error) { + called = true + return oauth2.StaticTokenSource(token), nil + }, + Client: mockTS.Client(), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Simulate a 401 response from a resource server. + // The WWW-Authenticate: Bearer header triggers the authorization logic. + req := httptest.NewRequest(http.MethodGet, mockTS.URL, nil) + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: http.NoBody, + Request: req, + } + resp.Header.Set("WWW-Authenticate", "Bearer") + + // Authorize and confirm NewTokenSource was called. + err = handler.Authorize(t.Context(), req, resp) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !called { + t.Error("expected NewTokenSource to be called") + } +} + +func TestInitialTokenSource(t *testing.T) { + handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{ + RedirectURL: "http://localhost:12345/callback", + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "test_client_id", + }, + AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + return nil, nil + }, + InitialTokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "set_token"}), + }) + if err != nil { + t.Fatalf("NewAuthorizationCodeHandler failed: %v", err) + } + + ts, err := handler.TokenSource(t.Context()) + if err != nil { + t.Fatalf("failed to get token source: %v", err) + } + if ts == nil { + t.Fatal("expected token source to be non-nil") + } + + tok, err := ts.Token() + if err != nil { + t.Fatalf("failed to get Token: %v", err) + } + if tok.AccessToken != "set_token" { + t.Errorf("expected access token 'set_token', got '%s'", tok.AccessToken) + } +} From 8f547833311ddb387fa2b959585d23323a0f2af8 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Mon, 13 Jul 2026 16:47:53 +0200 Subject: [PATCH 341/418] docs: update troubleshoot.md to include resultType (#1081) This PR adds the resultType in the troubleshoot doc after PR #1060 --- docs/troubleshooting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 0e792a3..922fbc9 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -50,7 +50,7 @@ func ExampleLoggingTransport() { } // Output: - // read: {"jsonrpc":"2.0","id":1,"result":{"ttlMs":0,"cacheScope":"public","supportedVersions":["2026-07-28","2025-11-25","2025-06-18","2025-03-26","2024-11-05"],"capabilities":{"logging":{}},"serverInfo":{"name":"server","version":"v0.0.1"}}} + // read: {"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","ttlMs":0,"cacheScope":"public","supportedVersions":["2026-07-28","2025-11-25","2025-06-18","2025-03-26","2024-11-05"],"capabilities":{"logging":{}},"serverInfo":{"name":"server","version":"v0.0.1"}}} // write: {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/clientCapabilities":{"roots":{"listChanged":true}},"io.modelcontextprotocol/clientInfo":{"name":"client","version":"v0.0.1"},"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}} } ``` From 7fde7d34522f1ef9d48bf69866cea7d4ed10deff Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Tue, 14 Jul 2026 12:13:40 +0200 Subject: [PATCH 342/418] mcp: reject calls to methodDiscover for `<20260728` requests (#1084) This PR adds a gate for requests towards methodDiscover if it is coming from a client that does not support the 20260728 protocol version --- mcp/server.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mcp/server.go b/mcp/server.go index 14adfad..3e6d95a 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1883,6 +1883,12 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, // In case of methodDiscover call the state.initializeParams is populated // within the discover handle function to make sure the method is supported // when the user is probing a pre-2026-07-28 server. + if !validatedMeta.usesNewProtocol { + return nil, &jsonrpc.Error{ + Code: jsonrpc.CodeMethodNotFound, + Message: fmt.Sprintf("%q is only supported in protocol version >= %s", req.Method, protocolVersion20260728), + } + } default: if !initialized && !validatedMeta.usesNewProtocol { ss.server.opts.Logger.Error("method invalid during initialization", "method", req.Method) From 0a8953d71c0350fad60965dc268d016b1ca01ed8 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Tue, 14 Jul 2026 16:24:47 +0200 Subject: [PATCH 343/418] ci: fix nightly runs (#1086) This PR fixes the failing nightly runs https://github.com/modelcontextprotocol/go-sdk/actions/workflows/nightly.yml --- scripts/server-conformance.sh | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/server-conformance.sh b/scripts/server-conformance.sh index f31d8df..e8fe6e1 100755 --- a/scripts/server-conformance.sh +++ b/scripts/server-conformance.sh @@ -69,9 +69,14 @@ fi # Build the conformance server. go build -o "$WORKDIR/conformance-server" ./conformance/everything-server -# Start the server in the background +# Start the server in the background. +# -stateless=false pins the server to the stateful transport so that +# server-initiated sampling/elicitation scenarios (which are not supported on +# stateless streamable HTTP) work against the current @latest conformance +# suite. Drop this flag once the 0.2.x line is promoted to the @latest +# dist-tag on npm and the stateless leg becomes viable. echo "Starting conformance server on localhost:$PORT..." -"$WORKDIR/conformance-server" -http="localhost:$PORT" & +"$WORKDIR/conformance-server" -http="localhost:$PORT" -stateless=false & SERVER_PID=$! echo "Server pid is $SERVER_PID" @@ -90,11 +95,13 @@ if [ -n "$CONFORMANCE_REPO" ]; then (cd "$WORKDIR" && \ npm --prefix "$CONFORMANCE_REPO" run start -- \ server --url "http://localhost:$PORT" \ + --spec-version 2025-11-25 \ ${RESULT_DIR:+--output-dir "$RESULT_DIR"}) || FINAL_EXIT_CODE=$? else (cd "$WORKDIR" && \ npx @modelcontextprotocol/conformance@latest \ server --url "http://localhost:$PORT" \ + --spec-version 2025-11-25 \ ${RESULT_DIR:+--output-dir "$RESULT_DIR"}) || FINAL_EXIT_CODE=$? fi From 9576afc114e59ad30b6070f9ffc5982a81bf6984 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Tue, 14 Jul 2026 16:26:18 +0200 Subject: [PATCH 344/418] mcp: wrap unmarshalParams with jsonrpc error (#1087) Fixes #976 --- docs/mcpgodebug.md | 7 +++++++ internal/docs/mcpgodebug.src.md | 7 +++++++ mcp/shared.go | 24 +++++++++++++++++++++++- mcp/shared_test.go | 26 ++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 1 deletion(-) diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index 847e2e7..45b9c7d 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -50,6 +50,13 @@ Options listed below were added and will be removed in the 1.9.0 version of the restoring the previous behavior. The default behavior was changed so that the client always attempts to surface the underlying JSON-RPC error. +- `nowrapinvalidparams` added. If set to `1`, the server will not wrap + params-decoding failures with `jsonrpc2.ErrInvalidParams`, so wire responses + carry the zero-value error code `0` instead of `-32602` ("invalid params"), + restoring the previous behavior. The default behavior was changed so that + the server always reports params-decoding failures with the standard + `-32602` code. + - `disablecompleteparamsvalidation` added. If set to `1`, `Server.complete` will not validate that the required `ref` and `argument.name` fields on `CompleteParams` are present, restoring the previous behavior of dispatching diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index 5c9c9d9..e3ca770 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -49,6 +49,13 @@ Options listed below were added and will be removed in the 1.9.0 version of the restoring the previous behavior. The default behavior was changed so that the client always attempts to surface the underlying JSON-RPC error. +- `nowrapinvalidparams` added. If set to `1`, the server will not wrap + params-decoding failures with `jsonrpc2.ErrInvalidParams`, so wire responses + carry the zero-value error code `0` instead of `-32602` ("invalid params"), + restoring the previous behavior. The default behavior was changed so that + the server always reports params-decoding failures with the standard + `-32602` code. + - `disablecompleteparamsvalidation` added. If set to `1`, `Server.complete` will not validate that the required `ref` and `argument.name` fields on `CompleteParams` are present, restoring the previous behavior of dispatching diff --git a/mcp/shared.go b/mcp/shared.go index 0fc19c5..6e8419a 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -26,9 +26,21 @@ import ( "github.com/modelcontextprotocol/go-sdk/auth" internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + "github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug" "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) +// nowrapinvalidparams is a compatibility parameter that restores the previous +// behavior of [methodInfo.unmarshalParams]. When unset (the default), a +// params-decoding failure is wrapped with [jsonrpc2.ErrInvalidParams] so the +// wire response carries error code -32602 ("invalid params") rather than the +// zero-value code 0. See: +// https://github.com/modelcontextprotocol/go-sdk/issues/976#issuecomment-4829124838. +// +// See the documentation for the mcpgodebug package for instructions how to enable it. +// The option will be removed in a future version of the SDK. +var nowrapinvalidparams = mcpgodebug.Value("nowrapinvalidparams") + const ( // latestProtocolVersion is the latest protocol version that this version of // the SDK supports. @@ -304,7 +316,17 @@ func newMethodInfo[P paramsPtr[T], R Result, T any](flags methodFlags) methodInf var p P if m != nil { if err := internaljson.Unmarshal(m, &p); err != nil { - return nil, fmt.Errorf("unmarshaling %q into a %T: %w", m, p, err) + // Legacy behavior: pre-fix versions surfaced this as a + // plain wrapped error, which caused the wire response to + // carry code 0 instead of -32602. Restore via + // MCPGODEBUG=nowrapinvalidparams=1. + if nowrapinvalidparams == "1" { + return nil, fmt.Errorf("unmarshaling %q into a %T: %w", m, p, err) + } + // Wrap jsonrpc2.ErrInvalidParams so toWireError surfaces + // code -32602 ("invalid params") while preserving the + // descriptive message. + return nil, fmt.Errorf("%w: unmarshaling %q into a %T: %w", jsonrpc2.ErrInvalidParams, m, p, err) } } // We must check missingParamsOK here, in addition to checkRequest, to diff --git a/mcp/shared_test.go b/mcp/shared_test.go index 1d0ec70..caf5dd6 100644 --- a/mcp/shared_test.go +++ b/mcp/shared_test.go @@ -512,3 +512,29 @@ func TestImplementationDescriptionJSON(t *testing.T) { // } // }) // } + +// TestUnmarshalParamsInvalidParamsCode is a regression test for +// https://github.com/modelcontextprotocol/go-sdk/issues/976#issuecomment-4829124838. +// Malformed params for a registered method must surface as a JSON-RPC error +// whose code is CodeInvalidParams (-32602) rather than the zero-value 0. +func TestUnmarshalParamsInvalidParamsCode(t *testing.T) { + info, ok := serverMethodInfos[methodPing] + if !ok { + t.Fatalf("no methodInfo for %q", methodPing) + } + // Array where a struct is expected. + _, err := info.unmarshalParams(json.RawMessage(`["a","b"]`)) + if err == nil { + t.Fatal("unmarshalParams returned nil error for malformed params") + } + var jerr *jsonrpc.Error + if !errors.As(err, &jerr) { + t.Fatalf("expected *jsonrpc.Error in chain, got %T: %v", err, err) + } + if jerr.Code != jsonrpc.CodeInvalidParams { + t.Errorf("error code = %d, want %d (CodeInvalidParams)", jerr.Code, jsonrpc.CodeInvalidParams) + } + if !strings.Contains(err.Error(), "unmarshaling") { + t.Errorf("error message = %q, want it to mention unmarshaling", err.Error()) + } +} From 8baa4ccebe96f3645a59f93017df2c57d08cb18c Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Wed, 15 Jul 2026 14:37:19 +0200 Subject: [PATCH 345/418] mcp: add return type to `subscriptions/listen` (#1088) This PR adds the expected return type to `subscriptions/listen` rpc --- mcp/client.go | 2 +- mcp/protocol.go | 12 ++++++++++++ mcp/server.go | 6 ++++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index 4c0a458..522f873 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -1443,7 +1443,7 @@ func (cs *ClientSession) cancelAllResourceSubscriptions() { // usual handlers registered in [ClientOptions]. func (cs *ClientSession) subscriptionsListen(ctx context.Context, params *SubscriptionsListenParams) error { params = injectRequestMeta(cs, params) - _, err := handleSend[*emptyResult](ctx, methodSubscriptionsListen, newClientRequest(cs, orZero[Params](params))) + _, err := handleSend[*SubscriptionsListenResult](ctx, methodSubscriptionsListen, newClientRequest(cs, orZero[Params](params))) return err } diff --git a/mcp/protocol.go b/mcp/protocol.go index dc760a5..4096786 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -2109,6 +2109,18 @@ type SubscriptionsAcknowledgedParams struct { func (x *SubscriptionsAcknowledgedParams) isParams() {} func (x *SubscriptionsAcknowledgedParams) isNil() bool { return x == nil } +// SubscriptionsListenResult is the response to a "subscriptions/listen" +// request, signalling that the subscription has ended gracefully (for example, +// during server shutdown). Because the listen stream is long-lived, this +// result is sent only when the server tears the subscription down; an abrupt +// transport close carries no response. +type SubscriptionsListenResult struct { + completeResultWithType + Meta `json:"_meta"` +} + +func (*SubscriptionsListenResult) isResult() {} + // TODO(jba): add CompleteRequest and related types. // A request from the server to elicit additional information from the user via the client. diff --git a/mcp/server.go b/mcp/server.go index 3e6d95a..34cbf93 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1180,7 +1180,7 @@ func (s *Server) unsubscribe(ctx context.Context, req *UnsubscribeRequest) (*emp return &emptyResult{}, nil } -func (s *Server) subscriptionsListen(ctx context.Context, req *SubscriptionsListenRequest) (*emptyResult, error) { +func (s *Server) subscriptionsListen(ctx context.Context, req *SubscriptionsListenRequest) (*SubscriptionsListenResult, error) { requestID, ok := ctx.Value(idContextKey{}).(jsonrpc.ID) if !ok || !requestID.IsValid() { return nil, fmt.Errorf("%w: subscriptions/listen requires a request ID", jsonrpc2.ErrInvalidRequest) @@ -1242,7 +1242,9 @@ func (s *Server) subscriptionsListen(ctx context.Context, req *SubscriptionsList if len(allowed.ResourceSubscriptions) > 0 || allowed.ToolsListChanged || allowed.PromptsListChanged || allowed.ResourcesListChanged { <-ctx.Done() } - return &emptyResult{}, nil + return &SubscriptionsListenResult{ + Meta: Meta{MetaKeySubscriptionID: requestID.Raw()}, + }, nil } func (s *Server) allowedSubscriptions(want *NotificationSubscriptions) NotificationSubscriptions { From fa548d74010d04bf1d7c037179de7dec6dc473e5 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Thu, 16 Jul 2026 11:11:22 +0200 Subject: [PATCH 346/418] docs: update docs (#1089) --- docs/client.md | 8 ++++++-- docs/protocol.md | 17 +++++++++++++++++ docs/server.md | 7 ++++++- internal/docs/client.src.md | 8 ++++++-- internal/docs/protocol.src.md | 16 ++++++++++++++++ internal/docs/server.src.md | 7 ++++++- 6 files changed, 57 insertions(+), 6 deletions(-) diff --git a/docs/client.md b/docs/client.md index e262f34..13169d6 100644 --- a/docs/client.md +++ b/docs/client.md @@ -257,8 +257,12 @@ default. The middleware: The middleware is enabled by default. To opt out, set [`ClientOptions.MultiRoundTrip.Disabled = true`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#MultiRoundTripOptions); -the client will then surface `InputRequiredResult` values directly to the -caller and your code must fulfil the requests manually. +the client will then surface input-required results directly to the caller +(the returned `CallToolResult`, `GetPromptResult`, or `ReadResourceResult` +will report `NeedsInput() == true` and expose the server's `InputRequests` +and opaque `RequestState`). Your code must fulfil each request and re-issue +the original call with `InputResponses` set and `RequestState` echoed +back. For legacy (`<= 2025-11-25`) servers, the SDK transparently sends server requests on the legacy server-initiated channel; the MRTR machinery is a diff --git a/docs/protocol.md b/docs/protocol.md index 408442e..f973ae3 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -4,6 +4,7 @@ 1. [Lifecycle](#lifecycle) 1. [Discovery (`server/discover`)](#discovery-(server/discover)) 1. [Per-request `_meta` keys](#per-request-meta-keys) + 1. [Subscriptions (`subscriptions/listen`)](#subscriptions-(subscriptions/listen)) 1. [Transports](#transports) 1. [Stdio Transport](#stdio-transport) 1. [Streamable Transport](#streamable-transport) @@ -141,6 +142,22 @@ Server-side handlers can read them through `ServerRequest[P].ProtocolVersion()`, `ServerRequest[P].ClientInfo()`, and `ServerRequest[P].ClientCapabilities()`. +### Subscriptions (`subscriptions/listen`) + +Introduced in `2026-07-28` by +[SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), +`subscriptions/listen` replaces the legacy `resources/subscribe` RPC and +the GET-based SSE endpoint with a single long-lived request that +multiplexes every kind of server-to-client change notification a client +opts in to. On the wire the client sends one `subscriptions/listen` +request whose `notifications` field enumerates what it wants +(`toolsListChanged`, `promptsListChanged`, `resourcesListChanged`, and/or +a list of resource URIs in `resourceSubscriptions`); the server replies +first with a `notifications/subscriptions/acknowledged` notification +reporting the honored subset, then streams every change notification on +the same request, and finally closes with a `SubscriptionsListenResult` +when it tears the subscription down. + ## Transports A diff --git a/docs/server.md b/docs/server.md index 573c846..9f0f870 100644 --- a/docs/server.md +++ b/docs/server.md @@ -147,7 +147,12 @@ and to unsubscribe. Set [`ClientOptions.ResourceUpdatedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ResourceUpdatedHandler) -to be notified of changes to subscribed resources. +to be notified of changes to subscribed resources. On `2026-07-28` and +later sessions the SDK delivers these notifications over a +`subscriptions/listen` stream instead of the legacy `resources/subscribe` +RPC; see [Subscriptions +(`subscriptions/listen`)](protocol.md#subscriptions-subscriptionslisten) +for the wire-level details. **Server-side**: Use diff --git a/internal/docs/client.src.md b/internal/docs/client.src.md index e0613eb..6d14ee9 100644 --- a/internal/docs/client.src.md +++ b/internal/docs/client.src.md @@ -106,8 +106,12 @@ default. The middleware: The middleware is enabled by default. To opt out, set [`ClientOptions.MultiRoundTrip.Disabled = true`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#MultiRoundTripOptions); -the client will then surface `InputRequiredResult` values directly to the -caller and your code must fulfil the requests manually. +the client will then surface input-required results directly to the caller +(the returned `CallToolResult`, `GetPromptResult`, or `ReadResourceResult` +will report `NeedsInput() == true` and expose the server's `InputRequests` +and opaque `RequestState`). Your code must fulfil each request and re-issue +the original call with `InputResponses` set and `RequestState` echoed +back. For legacy (`<= 2025-11-25`) servers, the SDK transparently sends server requests on the legacy server-initiated channel; the MRTR machinery is a diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index 5bdbac3..d80db04 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -88,6 +88,22 @@ Server-side handlers can read them through `ServerRequest[P].ProtocolVersion()`, `ServerRequest[P].ClientInfo()`, and `ServerRequest[P].ClientCapabilities()`. +### Subscriptions (`subscriptions/listen`) + +Introduced in `2026-07-28` by +[SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), +`subscriptions/listen` replaces the legacy `resources/subscribe` RPC and +the GET-based SSE endpoint with a single long-lived request that +multiplexes every kind of server-to-client change notification a client +opts in to. On the wire the client sends one `subscriptions/listen` +request whose `notifications` field enumerates what it wants +(`toolsListChanged`, `promptsListChanged`, `resourcesListChanged`, and/or +a list of resource URIs in `resourceSubscriptions`); the server replies +first with a `notifications/subscriptions/acknowledged` notification +reporting the honored subset, then streams every change notification on +the same request, and finally closes with a `SubscriptionsListenResult` +when it tears the subscription down. + ## Transports A diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index f2e3f3a..756bba3 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -65,7 +65,12 @@ and to unsubscribe. Set [`ClientOptions.ResourceUpdatedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ResourceUpdatedHandler) -to be notified of changes to subscribed resources. +to be notified of changes to subscribed resources. On `2026-07-28` and +later sessions the SDK delivers these notifications over a +`subscriptions/listen` stream instead of the legacy `resources/subscribe` +RPC; see [Subscriptions +(`subscriptions/listen`)](protocol.md#subscriptions-subscriptionslisten) +for the wire-level details. **Server-side**: Use From 5cb83392106eb7366db52131f7d76e11748d282d Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Thu, 16 Jul 2026 16:10:12 +0200 Subject: [PATCH 347/418] mcp: update return meta in DiscoverResult (SEP-2575) (#1097) This PR implements the latest changes introduced by https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002 --- conformance/baseline.yml | 20 +++++++++- docs/protocol.md | 38 +++++++++++++------ docs/troubleshooting.md | 2 +- internal/docs/protocol.src.md | 37 ++++++++++++------ mcp/client.go | 17 ++++++--- mcp/client_test.go | 12 ++++-- mcp/content_nil_test.go | 10 ++++- mcp/mcp_test.go | 10 ++++- mcp/protocol.go | 4 +- mcp/server.go | 38 ++++++++++++++++--- mcp/shared.go | 21 ++++++---- mcp/shared_test.go | 13 +++++++ mcp/streamable_client_test.go | 4 +- mcp/streamable_test.go | 16 ++++++-- .../conformance/server/discover.txtar | 18 +++++---- mcp/testdata/conformance/server/mrtr.txtar | 12 ++++++ mcp/transport_example_test.go | 2 +- 17 files changed, 208 insertions(+), 66 deletions(-) diff --git a/conformance/baseline.yml b/conformance/baseline.yml index 49f68b6..b516fc9 100644 --- a/conformance/baseline.yml +++ b/conformance/baseline.yml @@ -1,7 +1,21 @@ -server: [] # All tests pass! +server: + # The `server-stateless` scenario ships with the `@modelcontextprotocol/conformance@0.2.0-alpha.9` + # suite pinned by the workflow, which still enforces the pre-PR-3002 + # (https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002) spec: + # - `clientInfo` in the per-request `_meta` is required (MUST). + # - `serverInfo` is a body field on `DiscoverResult`. + # PR #3002 relaxes `clientInfo` to SHOULD and moves `serverInfo` into every + # response's `_meta`. The Go SDK follows the updated spec, so the following + # checks in the `server-stateless` scenario fail against the pinned suite: + # - sep-2575-request-meta-invalid-missing-client-info + # - sep-2575-http-server-meta-invalid-400 + # - sep-2575-server-implements-discover + # Remove this entry when the conformance suite is bumped to a release that + # incorporates PR #3002. + - server-stateless client: - # OAuth mechanisms not yet implemented by the Go MCP SDK. + # OAuth mechanisms not implemented by the Go MCP SDK. - auth/client-credentials-basic - auth/client-credentials-jwt - auth/enterprise-managed-authorization @@ -10,4 +24,6 @@ client: # server/discover handler advertises "2026-07-28" but delegates every # other method to a pinned @modelcontextprotocol/sdk that rejects that # version at the HTTP layer. + # Remove this entry when the conformance suite is bumped to a release that + # fixes this issue. - json-schema-ref-no-deref diff --git a/docs/protocol.md b/docs/protocol.md index f973ae3..35bf93b 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -4,6 +4,7 @@ 1. [Lifecycle](#lifecycle) 1. [Discovery (`server/discover`)](#discovery-(server/discover)) 1. [Per-request `_meta` keys](#per-request-meta-keys) + 1. [Per-response `_meta` keys](#per-response-meta-keys) 1. [Subscriptions (`subscriptions/listen`)](#subscriptions-(subscriptions/listen)) 1. [Transports](#transports) 1. [Stdio Transport](#stdio-transport) @@ -40,8 +41,10 @@ transparently based on the negotiated protocol version: - A **stateless** model introduced in `2026-07-28` by [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), in which there is no `initialize`/`notifications/initialized` handshake, and - each request carries its protocol version, client identity, and client - capabilities in `_meta`. + each request carries its protocol version and client capabilities in + `_meta`. Clients SHOULD also include their identity (`clientInfo`) on every + request, and servers SHOULD include their identity (`serverInfo`) on every + response. In both models, the SDK exposes the same API: @@ -130,17 +133,28 @@ When the negotiated protocol version is `2026-07-28` or later, every request carries these keys inside its `_meta` map (constants live in [`mcp/protocol.go`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#pkg-constants)): -| Constant | Wire key | Type | -|---|---|---| -| `MetaKeyProtocolVersion` | `io.modelcontextprotocol/protocolVersion` | `string` | -| `MetaKeyClientInfo` | `io.modelcontextprotocol/clientInfo` | `*Implementation` | -| `MetaKeyClientCapabilities` | `io.modelcontextprotocol/clientCapabilities` | `*ClientCapabilities` | -| `MetaKeyLogLevel` | `io.modelcontextprotocol/logLevel` | `LoggingLevel` (deprecated by SEP-2577) | +| Constant | Wire key | Type | Required | +|---|---|---|---| +| `MetaKeyProtocolVersion` | `io.modelcontextprotocol/protocolVersion` | `string` | Yes | +| `MetaKeyClientCapabilities` | `io.modelcontextprotocol/clientCapabilities` | `*ClientCapabilities` | Yes | +| `MetaKeyClientInfo` | `io.modelcontextprotocol/clientInfo` | `*Implementation` | No | +| `MetaKeyLogLevel` | `io.modelcontextprotocol/logLevel` | `LoggingLevel` (deprecated by SEP-2577) | No | -The client populates these keys automatically on every outgoing request. -Server-side handlers can read them -through `ServerRequest[P].ProtocolVersion()`, `ServerRequest[P].ClientInfo()`, -and `ServerRequest[P].ClientCapabilities()`. +The client populates the required keys automatically on every outgoing +request, and populates `clientInfo` when configured with an `*Implementation` +(the default). Server-side handlers can read them through +`ServerRequest[P].ProtocolVersion()`, `ServerRequest[P].ClientInfo()`, and +`ServerRequest[P].ClientCapabilities()`. + +### Per-response `_meta` keys + +Under the same protocol version, servers SHOULD identify themselves on every +response. The SDK populates this +key automatically on every outgoing response: + +| Constant | Wire key | Type | Required | +|---|---|---|---| +| `MetaKeyServerInfo` | `io.modelcontextprotocol/serverInfo` | `*Implementation` | No | ### Subscriptions (`subscriptions/listen`) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 922fbc9..950eef7 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -50,7 +50,7 @@ func ExampleLoggingTransport() { } // Output: - // read: {"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","ttlMs":0,"cacheScope":"public","supportedVersions":["2026-07-28","2025-11-25","2025-06-18","2025-03-26","2024-11-05"],"capabilities":{"logging":{}},"serverInfo":{"name":"server","version":"v0.0.1"}}} + // read: {"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"server","version":"v0.0.1"}},"ttlMs":0,"cacheScope":"public","supportedVersions":["2026-07-28","2025-11-25","2025-06-18","2025-03-26","2024-11-05"],"capabilities":{"logging":{}}}} // write: {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/clientCapabilities":{"roots":{"listChanged":true}},"io.modelcontextprotocol/clientInfo":{"name":"client","version":"v0.0.1"},"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}} } ``` diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index d80db04..dee819b 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -17,8 +17,10 @@ transparently based on the negotiated protocol version: - A **stateless** model introduced in `2026-07-28` by [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), in which there is no `initialize`/`notifications/initialized` handshake, and - each request carries its protocol version, client identity, and client - capabilities in `_meta`. + each request carries its protocol version and client capabilities in + `_meta`. Clients SHOULD also include their identity (`clientInfo`) on every + request, and servers SHOULD include their identity (`serverInfo`) on every + response. In both models, the SDK exposes the same API: @@ -76,17 +78,28 @@ When the negotiated protocol version is `2026-07-28` or later, every request carries these keys inside its `_meta` map (constants live in [`mcp/protocol.go`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#pkg-constants)): -| Constant | Wire key | Type | -|---|---|---| -| `MetaKeyProtocolVersion` | `io.modelcontextprotocol/protocolVersion` | `string` | -| `MetaKeyClientInfo` | `io.modelcontextprotocol/clientInfo` | `*Implementation` | -| `MetaKeyClientCapabilities` | `io.modelcontextprotocol/clientCapabilities` | `*ClientCapabilities` | -| `MetaKeyLogLevel` | `io.modelcontextprotocol/logLevel` | `LoggingLevel` (deprecated by SEP-2577) | +| Constant | Wire key | Type | Required | +|---|---|---|---| +| `MetaKeyProtocolVersion` | `io.modelcontextprotocol/protocolVersion` | `string` | Yes | +| `MetaKeyClientCapabilities` | `io.modelcontextprotocol/clientCapabilities` | `*ClientCapabilities` | Yes | +| `MetaKeyClientInfo` | `io.modelcontextprotocol/clientInfo` | `*Implementation` | No | +| `MetaKeyLogLevel` | `io.modelcontextprotocol/logLevel` | `LoggingLevel` (deprecated by SEP-2577) | No | -The client populates these keys automatically on every outgoing request. -Server-side handlers can read them -through `ServerRequest[P].ProtocolVersion()`, `ServerRequest[P].ClientInfo()`, -and `ServerRequest[P].ClientCapabilities()`. +The client populates the required keys automatically on every outgoing +request, and populates `clientInfo` when configured with an `*Implementation` +(the default). Server-side handlers can read them through +`ServerRequest[P].ProtocolVersion()`, `ServerRequest[P].ClientInfo()`, and +`ServerRequest[P].ClientCapabilities()`. + +### Per-response `_meta` keys + +Under the same protocol version, servers SHOULD identify themselves on every +response. The SDK populates this +key automatically on every outgoing response: + +| Constant | Wire key | Type | Required | +|---|---|---|---| +| `MetaKeyServerInfo` | `io.modelcontextprotocol/serverInfo` | `*Implementation` | No | ### Subscriptions (`subscriptions/listen`) diff --git a/mcp/client.go b/mcp/client.go index 522f873..6f1073a 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -443,11 +443,15 @@ func (c *Client) discover(ctx context.Context, cs *ClientSession) (*InitializeRe } } + var serverInfo *Implementation + if v, ok := decodeMetaValue[*Implementation](res.GetMeta(), MetaKeyServerInfo); ok { + serverInfo = v + } return &InitializeResult{ Capabilities: res.Capabilities, Instructions: res.Instructions, ProtocolVersion: negotiated, - ServerInfo: res.ServerInfo, + ServerInfo: serverInfo, }, nil } @@ -509,9 +513,12 @@ func (cs *ClientSession) usesNewProtocol() bool { return res != nil && res.ProtocolVersion >= protocolVersion20260728 } -// injectRequestMeta populates the SEP-2575 per-request `_meta` triple -// (protocolVersion, clientInfo, clientCapabilities) on the given outgoing -// request params. Keys already present in params.Meta are not overwritten. +// injectRequestMeta populates the SEP-2575 per-request `_meta` fields +// (protocolVersion, optional clientInfo, clientCapabilities) on the given +// outgoing request params. Keys already present in params.Meta are not +// overwritten. Per PR modelcontextprotocol/modelcontextprotocol#3002 +// clientInfo is SHOULD (not MUST), and is omitted when the client has no +// [Implementation] configured. func injectRequestMeta[T any, P interface { *T Params @@ -527,7 +534,7 @@ func injectRequestMeta[T any, P interface { if _, ok := m[MetaKeyProtocolVersion]; !ok { m[MetaKeyProtocolVersion] = res.ProtocolVersion } - if _, ok := m[MetaKeyClientInfo]; !ok { + if _, ok := m[MetaKeyClientInfo]; !ok && cs.client.impl != nil { m[MetaKeyClientInfo] = cs.client.impl } if _, ok := m[MetaKeyClientCapabilities]; !ok { diff --git a/mcp/client_test.go b/mcp/client_test.go index fe1d3a2..0fe39c0 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -662,11 +662,13 @@ func TestClientConnectDiscover(t *testing.T) { name: "discover success skips initialize", discoverHandler: func() (Result, error) { return &DiscoverResult{ + Meta: Meta{ + MetaKeyServerInfo: &Implementation{Name: "discoverServer", Version: "v1.0.0"}, + }, SupportedVersions: []string{protocolVersion20260728}, Capabilities: &ServerCapabilities{ Tools: &ToolCapabilities{ListChanged: true}, }, - ServerInfo: &Implementation{Name: "discoverServer", Version: "v1.0.0"}, }, nil }, wantInitialize: false, @@ -695,9 +697,11 @@ func TestClientConnectDiscover(t *testing.T) { name: "no overlapping supported version falls back to initialize", discoverHandler: func() (Result, error) { return &DiscoverResult{ + Meta: Meta{ + MetaKeyServerInfo: &Implementation{Name: "discoverServer", Version: "v1.0.0"}, + }, SupportedVersions: []string{otherVersionsOnly}, Capabilities: &ServerCapabilities{}, - ServerInfo: &Implementation{Name: "discoverServer", Version: "v1.0.0"}, }, nil }, wantInitialize: true, @@ -885,9 +889,11 @@ func TestInMemory_E2E_DiscoverFallback_NoOverlap(t *testing.T) { return func(ctx context.Context, method string, req Request) (Result, error) { if method == methodDiscover { return &DiscoverResult{ + Meta: Meta{ + MetaKeyServerInfo: &Implementation{Name: "vpre-like-server", Version: "v1"}, + }, SupportedVersions: []string{protocolVersion20251125}, Capabilities: &ServerCapabilities{}, - ServerInfo: &Implementation{Name: "vpre-like-server", Version: "v1"}, }, nil } return next(ctx, method, req) diff --git a/mcp/content_nil_test.go b/mcp/content_nil_test.go index 86bb86a..f759986 100644 --- a/mcp/content_nil_test.go +++ b/mcp/content_nil_test.go @@ -224,4 +224,12 @@ func TestContentUnmarshalNilWithInvalidContent(t *testing.T) { } } -var ctrCmpOpts = []cmp.Option{cmpopts.IgnoreUnexported(mcp.CallToolResult{}, mcp.GetPromptResult{}, mcp.ReadResourceResult{})} +var ctrCmpOpts = []cmp.Option{ + cmpopts.IgnoreUnexported(mcp.CallToolResult{}, mcp.GetPromptResult{}, mcp.ReadResourceResult{}), + // Server responses under the >= 2026-07-28 protocol carry an auto-populated + // [mcp.MetaKeyServerInfo] entry; tests that compare result bodies against + // hand-crafted expected values should ignore it. + cmpopts.IgnoreFields(mcp.CallToolResult{}, "Meta"), + cmpopts.IgnoreFields(mcp.GetPromptResult{}, "Meta"), + cmpopts.IgnoreFields(mcp.ReadResourceResult{}, "Meta"), +} diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index a019839..06526c4 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -2475,7 +2475,15 @@ func TestSetErrorPreservesContent(t *testing.T) { } } -var ctrCmpOpts = []cmp.Option{cmpopts.IgnoreUnexported(CallToolResult{}, GetPromptResult{}, ReadResourceResult{})} +var ctrCmpOpts = []cmp.Option{ + cmpopts.IgnoreUnexported(CallToolResult{}, GetPromptResult{}, ReadResourceResult{}), + // Server responses under the >= 2026-07-28 protocol carry an auto-populated + // [MetaKeyServerInfo] entry; tests that compare result bodies against + // hand-crafted expected values should ignore it. + cmpopts.IgnoreFields(CallToolResult{}, "Meta"), + cmpopts.IgnoreFields(GetPromptResult{}, "Meta"), + cmpopts.IgnoreFields(ReadResourceResult{}, "Meta"), +} // runSubscriptionsListenTest exercises the SEP-2575 auto-listen flow end-to-end // against the supplied transport pair. It captures every notification and the diff --git a/mcp/protocol.go b/mcp/protocol.go index 4096786..47cb9cb 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -1143,8 +1143,6 @@ type DiscoverResult struct { SupportedVersions []string `json:"supportedVersions"` // The server's capabilities. Capabilities *ServerCapabilities `json:"capabilities"` - // Information about the server implementation. - ServerInfo *Implementation `json:"serverInfo"` // Instructions describing how to use the server and its features. Instructions string `json:"instructions,omitempty"` } @@ -2365,6 +2363,8 @@ const ( MetaKeyProtocolVersion = "io.modelcontextprotocol/protocolVersion" // MetaKeyClientInfo carries the client's [Implementation]. MetaKeyClientInfo = "io.modelcontextprotocol/clientInfo" + // MetaKeyServerInfo carries the server's [Implementation] on responses. + MetaKeyServerInfo = "io.modelcontextprotocol/serverInfo" // MetaKeyClientCapabilities carries the client's [ClientCapabilities]. MetaKeyClientCapabilities = "io.modelcontextprotocol/clientCapabilities" // MetaKeyLogLevel identifies the desired log level for the request. diff --git a/mcp/server.go b/mcp/server.go index 34cbf93..b580548 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -888,17 +888,21 @@ func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams] if versions == nil { versions = slices.Clone(supportedProtocolVersions) } + // Read the request-scoped identity/capabilities before acquiring the + // session lock: these accessors may fall back to Session.InitializeParams + // (which also locks Session.mu), so calling them from inside updateState + // would self-deadlock. + init := &InitializeParams{ + ProtocolVersion: req.ProtocolVersion(), + Capabilities: req.ClientCapabilities(), + ClientInfo: req.ClientInfo(), + } req.Session.updateState(func(state *ServerSessionState) { - state.InitializeParams = &InitializeParams{ - ProtocolVersion: req.ProtocolVersion(), - Capabilities: req.ClientCapabilities(), - ClientInfo: req.ClientInfo(), - } + state.InitializeParams = init }) res := &DiscoverResult{ SupportedVersions: versions, Capabilities: s.capabilities(), - ServerInfo: s.impl, Instructions: s.opts.Instructions, } res.setDefaultCacheableValues() @@ -1924,10 +1928,32 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, } if validatedMeta.usesNewProtocol { setCompleteResultType(res) + annotateServerInfo(res, ss.server.impl) } return res, nil } +// annotateServerInfo sets [MetaKeyServerInfo] on the result's `_meta` unless +// it is already present. Per SEP-2575, servers should identify themselves in +// each result's `_meta`. +func annotateServerInfo(res Result, impl *Implementation) { + if res == nil || impl == nil { + return + } + if _, isEmpty := res.(*emptyResult); isEmpty { + return + } + m := res.GetMeta() + if m == nil { + m = map[string]any{} + } + if _, ok := m[MetaKeyServerInfo]; ok { + return + } + m[MetaKeyServerInfo] = impl + res.SetMeta(m) +} + // InitializeParams returns the InitializeParams provided during the client's // initial connection. func (ss *ServerSession) InitializeParams() *InitializeParams { diff --git a/mcp/shared.go b/mcp/shared.go index 6e8419a..5069a47 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -535,9 +535,10 @@ type validatedMeta struct { // the >= 2026-07-28 protocol via the `_meta` field. // If the request has no _meta, or no protocolVersion in _meta, it returns a non-nil // validatedMeta with usesNewProtocol set to false, and a nil error. -// If the request has a protocolVersion in _meta it validates the presence of clientInfo -// and clientCapabilities in _meta. If either is missing or invalid, it returns nil and -// a non-nil error. Otherwise, it returns usesNewProtocol set to true and the populated +// If the request has a protocolVersion in _meta it validates the presence of +// clientCapabilities in _meta. If it is missing or invalid, it returns nil and +// 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 // initializeParams. func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) { meta := extractRequestMeta(req.Params) @@ -548,11 +549,15 @@ func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) { if !ok || protocolVersion < protocolVersion20260728 { return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil } - clientInfo, ok := decodeMetaValue[*Implementation](meta, MetaKeyClientInfo) - if !ok { - return nil, &jsonrpc.Error{ - Code: jsonrpc.CodeInvalidParams, - Message: fmt.Sprintf("missing or invalid _meta field %q", MetaKeyClientInfo), + var clientInfo *Implementation + if _, present := meta[MetaKeyClientInfo]; present { + var ok bool + clientInfo, ok = decodeMetaValue[*Implementation](meta, MetaKeyClientInfo) + if !ok { + return nil, &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidParams, + Message: fmt.Sprintf("invalid _meta field %q", MetaKeyClientInfo), + } } } capabilities, ok := decodeMetaValue[*clientCapabilitiesV2](meta, MetaKeyClientCapabilities) diff --git a/mcp/shared_test.go b/mcp/shared_test.go index caf5dd6..71a071d 100644 --- a/mcp/shared_test.go +++ b/mcp/shared_test.go @@ -67,6 +67,19 @@ func TestValidateRequestMeta(t *testing.T) { }, "name": "x", }, + wantUsesNew: true, + }, + { + name: "new protocol invalid clientInfo", + method: methodCallTool, + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260728, + MetaKeyClientInfo: "not an object", + MetaKeyClientCapabilities: map[string]any{}, + }, + "name": "x", + }, wantUsesNew: false, wantErrContains: MetaKeyClientInfo, }, diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 59bf1bd..b3f68cb 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -1265,11 +1265,13 @@ func TestStreamableClientOAuth_RetrieveError(t *testing.T) { // discoverResult is the canned successful DiscoverResult returned by // fakeStreamableServer setups in the tests below. var discoverResult = &DiscoverResult{ + Meta: Meta{ + MetaKeyServerInfo: &Implementation{Name: "discoverServer", Version: "v1.0.0"}, + }, SupportedVersions: []string{protocolVersion20260728}, Capabilities: &ServerCapabilities{ Tools: &ToolCapabilities{ListChanged: true}, }, - ServerInfo: &Implementation{Name: "discoverServer", Version: "v1.0.0"}, Instructions: "test discover", } diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index c1c7943..85ee331 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -794,6 +794,15 @@ func completeCallToolResult() *CallToolResult { return r } +// completeCallToolResultWithServerInfo returns a completeCallToolResult +// annotated with the auto-populated [MetaKeyServerInfo] entry that servers +// add to every new-protocol response. +func completeCallToolResultWithServerInfo(impl *Implementation) *CallToolResult { + r := completeCallToolResult() + r.Meta = Meta{MetaKeyServerInfo: impl} + return r +} + func resp(id int64, result any, err error) *jsonrpc.Response { return &jsonrpc.Response{ ID: jsonrpc2.Int64ID(id), @@ -1958,6 +1967,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { MetaKeyClientInfo: map[string]any{"name": "testClient", "version": "v1.0.0"}, MetaKeyClientCapabilities: map[string]any{}, } + serverImpl := &Implementation{Name: "testServer", Version: "v1.0.0"} testStreamableHandler(t, handler, []streamableRequest{ { @@ -1969,7 +1979,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { }, messages: []jsonrpc.Message{req(2, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})}, wantStatusCode: http.StatusOK, - wantMessages: []jsonrpc.Message{resp(2, completeCallToolResult(), nil)}, + wantMessages: []jsonrpc.Message{resp(2, completeCallToolResultWithServerInfo(serverImpl), nil)}, }, { method: "POST", @@ -2013,7 +2023,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { }, messages: []jsonrpc.Message{req(6, "tools/call", &CallToolParams{Meta: testMeta, Name: "my-tool"})}, wantStatusCode: http.StatusOK, - wantMessages: []jsonrpc.Message{resp(6, completeCallToolResult(), nil)}, + wantMessages: []jsonrpc.Message{resp(6, completeCallToolResultWithServerInfo(serverImpl), nil)}, }, { method: "POST", @@ -2029,7 +2039,7 @@ func TestStreamableMcpHeaderValidation(t *testing.T) { Arguments: map[string]any{"region": "us-west1", "query": "SELECT 1"}, })}, wantStatusCode: http.StatusOK, - wantMessages: []jsonrpc.Message{resp(7, completeCallToolResult(), nil)}, + wantMessages: []jsonrpc.Message{resp(7, completeCallToolResultWithServerInfo(serverImpl), nil)}, }, { method: "POST", diff --git a/mcp/testdata/conformance/server/discover.txtar b/mcp/testdata/conformance/server/discover.txtar index 9552eda..9159563 100644 --- a/mcp/testdata/conformance/server/discover.txtar +++ b/mcp/testdata/conformance/server/discover.txtar @@ -1,7 +1,7 @@ -The client sends a discover request -carrying the required per-request _meta triple (protocolVersion, clientInfo, -clientCapabilities), and the server replies with the protocol versions it -supports, its capabilities, and its identity. +The client sends a discover request carrying the per-request _meta +(protocolVersion, clientInfo, clientCapabilities), and the server +replies with the protocol versions it supports, its capabilities, and its +identity via `_meta.io.modelcontextprotocol/serverInfo`. -- client -- { @@ -22,6 +22,12 @@ supports, its capabilities, and its identity. "id": 1, "result": { "resultType": "complete", + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "testServer", + "version": "v1.0.0" + } + }, "ttlMs": 0, "cacheScope": "public", "supportedVersions": [ @@ -33,10 +39,6 @@ supports, its capabilities, and its identity. ], "capabilities": { "logging": {} - }, - "serverInfo": { - "name": "testServer", - "version": "v1.0.0" } } } diff --git a/mcp/testdata/conformance/server/mrtr.txtar b/mcp/testdata/conformance/server/mrtr.txtar index 4cf366c..046a79b 100644 --- a/mcp/testdata/conformance/server/mrtr.txtar +++ b/mcp/testdata/conformance/server/mrtr.txtar @@ -45,6 +45,12 @@ confirmThenGreet "jsonrpc": "2.0", "id": 1, "result": { + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "testServer", + "version": "v1.0.0" + } + }, "content": null, "requestState": "step=1", "resultType": "input_required", @@ -71,6 +77,12 @@ confirmThenGreet "jsonrpc": "2.0", "id": 2, "result": { + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "testServer", + "version": "v1.0.0" + } + }, "content": [ { "type": "text", diff --git a/mcp/transport_example_test.go b/mcp/transport_example_test.go index 1d97a81..e264133 100644 --- a/mcp/transport_example_test.go +++ b/mcp/transport_example_test.go @@ -42,7 +42,7 @@ func ExampleLoggingTransport() { } // Output: - // read: {"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","ttlMs":0,"cacheScope":"public","supportedVersions":["2026-07-28","2025-11-25","2025-06-18","2025-03-26","2024-11-05"],"capabilities":{"logging":{}},"serverInfo":{"name":"server","version":"v0.0.1"}}} + // read: {"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"server","version":"v0.0.1"}},"ttlMs":0,"cacheScope":"public","supportedVersions":["2026-07-28","2025-11-25","2025-06-18","2025-03-26","2024-11-05"],"capabilities":{"logging":{}}}} // write: {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/clientCapabilities":{"roots":{"listChanged":true}},"io.modelcontextprotocol/clientInfo":{"name":"client","version":"v0.0.1"},"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}} } From 827f90ba0c13edb546028df42fadc9f1211a4ff2 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Fri, 17 Jul 2026 13:40:39 +0200 Subject: [PATCH 348/418] mcp: allow opt-in to cancel handler ctx on aborted POST (#1099) Fixes #1093 --- mcp/streamable.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 7e0dddd..37adfda 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -207,6 +207,17 @@ type StreamableHTTPOptions struct { // A negative value disables the limit entirely; do not use this on // servers exposed to untrusted clients. MaxRequestBodyBytes int64 + + // PropagateRequestCancellation, when true, ties the in-flight handler + // context to the originating HTTP request's context. Only applies to + // requests using the >= 2026-07-28 protocol, where the POST is the whole + // request lifecycle. + // The handler context cancels whenever the HTTP request context does as the + // response can no longer be delivered, so cancelling the handler is safe. + // + // Requests using older protocol versions (including those routed through + // the allowsessionsinstateless compatibility path) are unaffected. + PropagateRequestCancellation bool } // DefaultMaxRequestBodyBytes is the default value used for @@ -417,7 +428,7 @@ func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http. EventStore: h.opts.EventStore, jsonResponse: h.opts.JSONResponse, logger: h.opts.Logger, - shouldPropagateCancellation: info.isSubscriptionsListen && info.usesNewProtocol, + shouldPropagateCancellation: info.usesNewProtocol && (info.isSubscriptionsListen || h.opts.PropagateRequestCancellation), } session, err := connectStreamable(req.Context(), server, transport, info.opts) From 2781b8f7e35bb7d064d44dd2193d7d77179b8630 Mon Sep 17 00:00:00 2001 From: Jan Steinke Date: Fri, 24 Jul 2026 09:21:59 +0200 Subject: [PATCH 349/418] mcp: derive Mcp-Protocol-Version header from the outgoing message (#1107) `streamableClientConn.setMCPHeaders` sets `Mcp-Protocol-Version` only from `c.initializedResult`, which is populated exclusively through the unexported `clientConnection.sessionUpdated` hook. `Client.Connect` reaches that hook via a type assertion on the `Connection` returned by the `Transport`: ```go if hc, ok := cs.mcpConn.(clientConnection); ok { hc.sessionUpdated(cs.state) } ``` That assertion silently fails whenever the `Connection` is wrapped by another type that exposes only the exported `Connection` interface. `c.initializedResult` then stays `nil` for the life of the session, and every `SEP-2575` (>= `2026-07-28`) request sent through that connection is missing the header. Servers that validate the header per spec reject the request with `400 Bad Request`, closing the connection. Every SEP-2575 request already carries its own `_meta.protocolVersion` field via `injectRequestMeta`. `setMCPHeaders` now reads the protocol version from the outgoing message first, falling back to `c.initializedResult` and then the request context. This is currently breaking the GitHub MCP with the 1.7-pre releases of this module. Fixes #1109 Co-authored-by: eu.anthropic.claude-sonnet-5 via Crush --- mcp/streamable.go | 34 +++++++++++++---- mcp/streamable_client_test.go | 70 +++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 7 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 37adfda..1f07ea6 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -2249,7 +2249,7 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") - if err := c.setMCPHeaders(req); err != nil { + if err := c.setMCPHeaders(req, msg); err != nil { // Failure to set headers means that the request was not sent. // Wrap with ErrRejected so the jsonrpc2 connection doesn't set writeErr // and permanently break the connection. @@ -2373,7 +2373,7 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e return nil } -func (c *streamableClientConn) setMCPHeaders(req *http.Request) error { +func (c *streamableClientConn) setMCPHeaders(req *http.Request, msg jsonrpc.Message) error { c.mu.Lock() defer c.mu.Unlock() @@ -2403,10 +2403,13 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request) error { } } } - if c.initializedResult != nil { + switch { + case protocolVersionFromMessage(msg) != "": + req.Header.Set(protocolVersionHeader, protocolVersionFromMessage(msg)) + case c.initializedResult != nil: req.Header.Set(protocolVersionHeader, c.initializedResult.ProtocolVersion) - } else if v := protocolVersionFromContext(req.Context()); v != "" { - req.Header.Set(protocolVersionHeader, v) + case protocolVersionFromContext(req.Context()) != "": + req.Header.Set(protocolVersionHeader, protocolVersionFromContext(req.Context())) } if c.sessionID != "" { req.Header.Set(sessionIDHeader, c.sessionID) @@ -2415,6 +2418,23 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request) error { return nil } +// protocolVersionFromMessage recovers the SEP-2575 `_meta.protocolVersion` +// value from an outgoing JSON-RPC request, if present. It returns "" for +// notifications, responses, requests without a `_meta.protocolVersion`, or a +// nil msg. +func protocolVersionFromMessage(msg jsonrpc.Message) string { + req, ok := msg.(*jsonrpc.Request) + if !ok { + return "" + } + meta := extractRequestMeta(req.Params) + if meta == nil { + return "" + } + v, _ := meta[MetaKeyProtocolVersion].(string) + return v +} + func (c *streamableClientConn) handleJSON(requestSummary string, resp *http.Response) { body, err := io.ReadAll(resp.Body) resp.Body.Close() @@ -2676,7 +2696,7 @@ func (c *streamableClientConn) connectSSE(ctx context.Context, lastEventID strin if err != nil { return nil, err } - if err := c.setMCPHeaders(req); err != nil { + if err := c.setMCPHeaders(req, nil); err != nil { return nil, err } if lastEventID != "" { @@ -2712,7 +2732,7 @@ func (c *streamableClientConn) Close() error { if err != nil { c.closeErr = err } else { - if err := c.setMCPHeaders(req); err != nil { + if err := c.setMCPHeaders(req, nil); err != nil { c.closeErr = err } else if resp, err := c.client.Do(req); err != nil { c.closeErr = err diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index b3f68cb..a92f2e4 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -1364,6 +1364,76 @@ func TestStreamableClientConnect_DiscoverSuccess(t *testing.T) { } } +// TestStreamableClientConnSetMCPHeaders_ProtocolVersion covers +// streamableClientConn.setMCPHeaders' selection of the Mcp-Protocol-Version +// header value. +// +// Ordinarily initializedResult is populated by sessionUpdated, called +// through a type assertion to the unexported clientConnection interface +// (see Client.Connect). That assertion silently fails, leaving +// initializedResult nil for the life of the session, whenever the +// Connection returned by a Transport is wrapped by another type exposing +// only the exported Connection interface (a real pattern for transports +// that intercept traffic, e.g. to filter notifications): Go does not +// promote unexported interface methods across an embedded interface +// boundary. Every SEP-2575 (>= 2026-07-28) request already carries its own +// `_meta.protocolVersion` field, so setMCPHeaders falls back to reading it +// from the outgoing message when initializedResult is unset. +func TestStreamableClientConnSetMCPHeaders_ProtocolVersion(t *testing.T) { + tests := []struct { + name string + initializedResult *InitializeResult + msg jsonrpc.Message + want string + }{ + { + name: "message meta wins when initializedResult unset", + initializedResult: nil, + msg: req(1, methodListTools, &ListToolsParams{Meta: Meta{MetaKeyProtocolVersion: protocolVersion20260728}}), + want: protocolVersion20260728, + }, + { + name: "initializedResult used when message has no meta", + initializedResult: &InitializeResult{ProtocolVersion: protocolVersion20251125}, + msg: req(1, methodListTools, &ListToolsParams{}), + want: protocolVersion20251125, + }, + { + name: "initializedResult used for nil message (GET/DELETE)", + initializedResult: &InitializeResult{ProtocolVersion: protocolVersion20251125}, + msg: nil, + want: protocolVersion20251125, + }, + { + name: "message meta preferred over stale initializedResult", + initializedResult: &InitializeResult{ProtocolVersion: protocolVersion20251125}, + msg: req(1, methodListTools, &ListToolsParams{Meta: Meta{MetaKeyProtocolVersion: protocolVersion20260728}}), + want: protocolVersion20260728, + }, + { + name: "no header when neither source is set", + initializedResult: nil, + msg: req(1, methodListTools, &ListToolsParams{}), + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conn := &streamableClientConn{initializedResult: tt.initializedResult} + httpReq, err := http.NewRequest(http.MethodPost, "http://test.invalid", nil) + if err != nil { + t.Fatal(err) + } + if err := conn.setMCPHeaders(httpReq, tt.msg); err != nil { + t.Fatalf("setMCPHeaders: %v", err) + } + if got := httpReq.Header.Get(protocolVersionHeader); got != tt.want { + t.Errorf("Mcp-Protocol-Version header = %q, want %q", got, tt.want) + } + }) + } +} + // TestStreamableClientConnect_DiscoverMethodNotFound verifies that Client.Connect // falls back to the legacy initialize handshake when the server responds to // server/discover with a JSON-RPC "Method not found" error. From e761950d9795d120ce9b00e5ba87b93c30a7be90 Mon Sep 17 00:00:00 2001 From: George McCarron Date: Fri, 24 Jul 2026 09:07:06 +0100 Subject: [PATCH 350/418] mcp: avoid double-copy of payload in writeEvent (#1106) `writeEvent` built the SSE frame with `fmt.Fprintf(&b, "data: %s\n\n", string(evt.Data))`, which copies the payload into a fresh string and then again into the buffer, growing it via repeated `bytes.growSlice` doublings. For large MCP responses (e.g. ~162KB `tools/list`) under a burst of concurrent writes this ~3x amplifies allocations and peak heap. Write the payload directly into a pre-grown buffer instead. Output framing is byte-identical. Benchmarks (162KB payload): 517KB->172KB per op, 9->4 allocs/op, ~3x faster. Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Yaroslav --- mcp/event.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mcp/event.go b/mcp/event.go index 41ff4ae..bbb3e6c 100644 --- a/mcp/event.go +++ b/mcp/event.go @@ -52,7 +52,12 @@ func writeEvent(w http.ResponseWriter, evt Event) (int, error) { if evt.Retry != "" { fmt.Fprintf(&b, "retry: %s\n", evt.Retry) } - fmt.Fprintf(&b, "data: %s\n\n", string(evt.Data)) + // Write the payload directly into a pre-grown buffer to avoid the extra + // copy from string(evt.Data) and repeated buffer regrowths. + b.Grow(len("data: \n\n") + len(evt.Data)) + b.WriteString("data: ") + b.Write(evt.Data) + b.WriteString("\n\n") n, err := w.Write(b.Bytes()) rc := http.NewResponseController(w) // Ignore returned error as flushing is best-effort. From cc4db253471754d1d899ee6a54e92c406f19e11c Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Mon, 27 Jul 2026 17:19:11 +0200 Subject: [PATCH 351/418] chore: update conformance (#1114) Update to the latest conformance tests version. --- .github/workflows/conformance.yml | 2 +- conformance/baseline.yml | 31 ++++++++----------------------- 2 files changed, 9 insertions(+), 24 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 77276d7..be34c09 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -14,7 +14,7 @@ permissions: contents: read env: - CONFORMANCE_VERSION: "0.2.0-alpha.9" + CONFORMANCE_VERSION: "0.2.0-alpha.10" jobs: server-conformance: diff --git a/conformance/baseline.yml b/conformance/baseline.yml index b516fc9..4867fe9 100644 --- a/conformance/baseline.yml +++ b/conformance/baseline.yml @@ -1,29 +1,14 @@ -server: - # The `server-stateless` scenario ships with the `@modelcontextprotocol/conformance@0.2.0-alpha.9` - # suite pinned by the workflow, which still enforces the pre-PR-3002 - # (https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3002) spec: - # - `clientInfo` in the per-request `_meta` is required (MUST). - # - `serverInfo` is a body field on `DiscoverResult`. - # PR #3002 relaxes `clientInfo` to SHOULD and moves `serverInfo` into every - # response's `_meta`. The Go SDK follows the updated spec, so the following - # checks in the `server-stateless` scenario fail against the pinned suite: - # - sep-2575-request-meta-invalid-missing-client-info - # - sep-2575-http-server-meta-invalid-400 - # - sep-2575-server-implements-discover - # Remove this entry when the conformance suite is bumped to a release that - # incorporates PR #3002. - - server-stateless - client: # OAuth mechanisms not implemented by the Go MCP SDK. - auth/client-credentials-basic - auth/client-credentials-jwt - auth/enterprise-managed-authorization - # Upstream conformance mock inconsistency: the scenario's hand-rolled - # server/discover handler advertises "2026-07-28" but delegates every - # other method to a pinned @modelcontextprotocol/sdk that rejects that - # version at the HTTP layer. - # Remove this entry when the conformance suite is bumped to a release that - # fixes this issue. - - json-schema-ref-no-deref + # SEP-1932 (DPoP Sender-Constrained Tokens) client-side support not yet + # implemented by the Go MCP SDK. + - auth/dpop + - auth/dpop-nonce + + # SEP-1933 (Workload Identity Federation JWT-bearer) client-side support + # not yet implemented by the Go MCP SDK. + - auth/wif-jwt-bearer From bc72835f62eb94d0fb484439f886b6885b075f36 Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Mon, 27 Jul 2026 17:20:53 +0200 Subject: [PATCH 352/418] mcp: fix potential nil-pointer dereference on message send (#1115) --- mcp/streamable.go | 13 ++++++------- mcp/streamable_client_test.go | 6 ++++++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 1f07ea6..f642db1 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -2403,13 +2403,12 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request, msg jsonrpc.Mess } } } - switch { - case protocolVersionFromMessage(msg) != "": - req.Header.Set(protocolVersionHeader, protocolVersionFromMessage(msg)) - case c.initializedResult != nil: + if pv := protocolVersionFromMessage(msg); pv != "" { + req.Header.Set(protocolVersionHeader, pv) + } else if pv := protocolVersionFromContext(req.Context()); pv != "" { + req.Header.Set(protocolVersionHeader, pv) + } else if c.initializedResult != nil { req.Header.Set(protocolVersionHeader, c.initializedResult.ProtocolVersion) - case protocolVersionFromContext(req.Context()) != "": - req.Header.Set(protocolVersionHeader, protocolVersionFromContext(req.Context())) } if c.sessionID != "" { req.Header.Set(sessionIDHeader, c.sessionID) @@ -2424,7 +2423,7 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request, msg jsonrpc.Mess // nil msg. func protocolVersionFromMessage(msg jsonrpc.Message) string { req, ok := msg.(*jsonrpc.Request) - if !ok { + if !ok || req == nil { return "" } meta := extractRequestMeta(req.Params) diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index a92f2e4..c8ab066 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -1386,6 +1386,12 @@ func TestStreamableClientConnSetMCPHeaders_ProtocolVersion(t *testing.T) { msg jsonrpc.Message want string }{ + { + name: "nil checked", + initializedResult: nil, + msg: (*jsonrpc.Request)(nil), + want: "", + }, { name: "message meta wins when initializedResult unset", initializedResult: nil, From 7743b71b1cd5d617d16d59c958a0656d5c29df74 Mon Sep 17 00:00:00 2001 From: n0liu Date: Thu, 30 Jul 2026 16:06:14 +0800 Subject: [PATCH 353/418] mcp: fix ListTools panic and null normalization on malformed tools (#1120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1119. `ClientSession.ListTools` post-processes the server response through `filterValidTools`, which has two problems on malformed input: 1. **Nil-pointer panic on `"tools":[null]`.** A `nil` `*Tool` element reaches `validateParamHeaderAnnotations(tool)`, which dereferences `tool.InputSchema`, crashing the client on untrusted server input. 2. **`"tools":null` is normalized into a non-nil empty slice.** `make([]*Tool, 0, len(tools))` is always non-nil, so a nil `Tools` slice from a malformed response is silently laundered into valid-looking data. Reproduction: ```go filterValidTools(nil, []*Tool{nil}) // panics: nil pointer dereference filterValidTools(nil, nil) // returns non-nil slice (len 0), want nil ``` Fix (`mcp/streamable_headers.go`): - Skip (log and exclude) nil tool elements instead of dereferencing them, so a malformed `"tools":[null]` can no longer crash the client. This is consistent with the function’s existing contract of excluding invalid tools. - Return early with `nil` for a nil input slice so a `"tools":null` response is preserved as nil rather than normalized into an empty slice. Added `TestFilterValidToolsNilHandling` covering both defects plus the mixed valid/nil case. `go test ./mcp/` passes; `gofmt` and `go vet` are clean. Note on scope: this keeps the change minimal and within `filterValidTools`’s existing signature. If maintainers would rather surface a hard protocol error for a malformed `"tools":null` / `"tools":[null]` response at the `ListTools` level (rather than tolerating and filtering it), I’m happy to follow up with that larger change. --- mcp/streamable_headers.go | 6 ++++++ mcp/streamable_headers_test.go | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/mcp/streamable_headers.go b/mcp/streamable_headers.go index 3989c01..7d06399 100644 --- a/mcp/streamable_headers.go +++ b/mcp/streamable_headers.go @@ -262,6 +262,12 @@ func filterValidTools(logger *slog.Logger, tools []*Tool) []*Tool { logger = ensureLogger(logger) result := make([]*Tool, 0, len(tools)) for _, tool := range tools { + // A nil tool (e.g. from a malformed "tools":[null] response) is + // invalid; exclude it instead of dereferencing it. + if tool == nil { + logger.Error("excluding nil tool from tools/list") + continue + } if err := validateParamHeaderAnnotations(tool); err != nil { logger.Error("excluding tool from tools/list", "tool", tool.Name, "error", err) continue diff --git a/mcp/streamable_headers_test.go b/mcp/streamable_headers_test.go index 52d5244..def7ad1 100644 --- a/mcp/streamable_headers_test.go +++ b/mcp/streamable_headers_test.go @@ -969,6 +969,15 @@ func TestFilterValidTools(t *testing.T) { t.Errorf("filterValidTools returned [%s, %s, %s, %s], want [valid, plain, nested-valid, valid-jsonschema]", result[0].Name, result[1].Name, result[2].Name, result[3].Name) } + + // Regression for #1119: a malformed "tools":[null] response must not panic; + // nil tools are treated as invalid and excluded. + if got := filterValidTools(nil, []*Tool{nil}); len(got) != 0 { + t.Errorf("filterValidTools([nil]) returned %d tools, want 0", len(got)) + } + if got := filterValidTools(nil, []*Tool{nil, valid, nil}); len(got) != 1 || got[0].Name != "valid" { + t.Errorf("filterValidTools([nil, valid, nil]) = %d tools, want [valid]", len(got)) + } } func TestSetStandardHeadersWithParamHeaders(t *testing.T) { From 0c004ee48a11d6752eed40b1b7a5cdee58a55acd Mon Sep 17 00:00:00 2001 From: Thomas Bechtold Date: Fri, 31 Jul 2026 10:35:49 +0200 Subject: [PATCH 354/418] auth: add ScopeFilter hook to AuthorizationCodeHandlerConfig (#1044) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The authorization-code handler requests every scope advertised in the protected resource's metadata `scopes_supported` (or named in the `WWW-Authenticate` challenge), and offers the caller no way to adjust that set. Some servers advertise a scope a client should *not* request: - Google's Gmail MCP server advertises `gmail.metadata` in `scopes_supported`. - The Gmail API refuses the search `q` parameter on any token that carries `gmail.metadata` — **even when `gmail.readonly` is also granted** (a long-standing documented Gmail restriction, see googleapis/google-api-python-client#582). Because the handler requests the full advertised set, `gmail.metadata` ends up in the grant and breaks search, with no client-side remedy. ## Change Add an optional `ScopeFilter func(discovered []string) []string` callback to `AuthorizationCodeHandlerConfig`. When non-nil, it receives the discovered scopes and returns the set to request, giving the client full control to narrow, extend, or reorder them. It runs **before** the `offline_access` (SEP-2207) and step-up union (SEP-2350) logic, so refresh-token support and scope accumulation are unaffected. (Started as a static `Scopes []string` allowlist; switched to a callback per review feedback for more flexibility.) ## Test `TestAuthorize_ScopeFilter` drives a full authorize flow and asserts the requested `scope` parameter for a nil filter (unchanged), a filter that drops a scope, and one that replaces the set entirely. `go test ./auth/...` (incl. `-race`), `go vet`, and `gofmt` are clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Guglielmo Colombo --- auth/authorization_code.go | 15 +++++ auth/authorization_code_test.go | 101 ++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/auth/authorization_code.go b/auth/authorization_code.go index d897d84..1673a2e 100644 --- a/auth/authorization_code.go +++ b/auth/authorization_code.go @@ -98,6 +98,15 @@ type AuthorizationCodeHandlerConfig struct { // See [AuthorizationCodeFetcher] for details. AuthorizationCodeFetcher AuthorizationCodeFetcher + // ScopeFilter, if non-nil, is called with the scopes discovered from the + // protected resource metadata (or WWW-Authenticate challenge) and returns + // the scopes to request during authorization. It gives the client full + // control to narrow, extend, or reorder that set — e.g. dropping Gmail's + // gmail.metadata, which the Gmail API refuses to combine with the search "q" + // parameter even alongside gmail.readonly. It runs before offline_access + // (see RequestRefreshToken) and the step-up union, so neither is affected. + ScopeFilter func(discovered []string) []string + // RequestRefreshToken indicates that the client intends to use refresh // tokens and is capable of storing them securely. // @@ -318,6 +327,12 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ requestedScopes = prm.ScopesSupported } + // Let the client adjust the discovered scopes before offline_access and the + // step-up union below so neither is affected. + if h.config.ScopeFilter != nil { + requestedScopes = h.config.ScopeFilter(requestedScopes) + } + // SEP-2207: when the client desires refresh tokens and the Authorization // Server advertises offline_access support, add it to the requested scopes. if h.config.RequestRefreshToken && diff --git a/auth/authorization_code_test.go b/auth/authorization_code_test.go index 9833597..c2f963f 100644 --- a/auth/authorization_code_test.go +++ b/auth/authorization_code_test.go @@ -1222,6 +1222,107 @@ func TestAuthorize_OfflineAccessScope(t *testing.T) { } } +func TestAuthorize_ScopeFilter(t *testing.T) { + // advertised is delivered via the WWW-Authenticate "scope" challenge, which + // the handler treats as the discovered scopes passed to ScopeFilter. + const advertised = "gmail.metadata gmail.readonly gmail.compose" + tests := []struct { + name string + filter func(discovered []string) []string + want []string // requested scopes, order-independent + }{ + { + name: "nil filter leaves scopes unchanged", + filter: nil, + want: []string{"gmail.metadata", "gmail.readonly", "gmail.compose"}, + }, + { + name: "filter drops a scope", + filter: func(d []string) []string { + return slices.DeleteFunc(slices.Clone(d), func(s string) bool { return s == "gmail.metadata" }) + }, + want: []string{"gmail.readonly", "gmail.compose"}, + }, + { + name: "filter replaces the set entirely", + filter: func([]string) []string { return []string{"gmail.readonly"} }, + want: []string{"gmail.readonly"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + RegistrationConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "test_client_id": { + Secret: "test_client_secret", + RedirectURIs: []string{"http://localhost:12345/callback"}, + }, + }, + }, + }) + authServer.Start(t) + + resourceMux := http.NewServeMux() + resourceServer := httptest.NewServer(resourceMux) + t.Cleanup(resourceServer.Close) + resourceURL := resourceServer.URL + "/resource" + resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{ + Resource: resourceURL, + AuthorizationServers: []string{authServer.URL()}, + })) + + var capturedAuthURL string + handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{ + RedirectURL: "http://localhost:12345/callback", + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "test_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: "test_client_secret"}, + }, + ScopeFilter: tt.filter, + AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + capturedAuthURL = args.URL + return nil, fmt.Errorf("stop after capturing URL") + }, + }) + if err != nil { + t.Fatalf("NewAuthorizationCodeHandler failed: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, resourceURL, nil) + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: http.NoBody, + Request: req, + } + resp.Header.Set("WWW-Authenticate", fmt.Sprintf( + "Bearer resource_metadata=%s/.well-known/oauth-protected-resource/resource, scope=%q", + resourceServer.URL, advertised)) + + handler.Authorize(context.Background(), req, resp) + + if capturedAuthURL == "" { + t.Fatal("AuthorizationCodeFetcher was not called") + } + u, err := url.Parse(capturedAuthURL) + if err != nil { + t.Fatalf("failed to parse captured auth URL: %v", err) + } + // Compare as a set: UnionScopes (applied downstream) returns map keys, + // so the order of the requested scope parameter is not deterministic. + got := strings.Fields(u.Query().Get("scope")) + want := slices.Clone(tt.want) + slices.Sort(got) + slices.Sort(want) + if !slices.Equal(got, want) { + t.Errorf("requested scopes = %v, want %v (any order)", got, want) + } + }) + } +} + // validConfig for test to create an AuthorizationCodeHandler using its constructor. // Values that are relevant to the test should be set explicitly. func validConfig() *AuthorizationCodeHandlerConfig { From d32302ee7a6cf22550d44ee3564a1d02159c7126 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:26:57 +0200 Subject: [PATCH 355/418] build(deps): bump the codeql-action group with 3 updates (#1122) Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action). Updates `github/codeql-action/init` from 4.37.0 to 4.37.3
Release notes

Sourced from github/codeql-action/init's releases.

v4.37.3

No user facing changes.

v4.37.2

  • The new address format for the config-file input that was introduced in CodeQL Action 4.37.0 is now enabled by default. In addition to the format described there, the remote= prefix can now be used to explicitly indicate that the input refers to a remote file. All previous input formats continue to be accepted as well. #4023
  • The CodeQL Action can now make use of configured private registries in Default Setup to retrieve CodeQL configuration files from remote repositories that require authentication. This will allow customers to store their CodeQL configuration in a single repository that can then be referenced by Default Setup workflows in other repositories. We expect to roll this and other, related changes out to everyone in July. #4007

v4.37.1

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956
  • Update default CodeQL bundle version to 2.26.1. #4019
Changelog

Sourced from github/codeql-action/init's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

  • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061

4.37.4 - 29 Jul 2026

  • This version of the CodeQL Action adds support for the tools input for the codeql-action/init step to be specified using a github-codeql-tools repository property. This feature will gradually be rolled out following the release of this version. Once rolled out, this allows for the CodeQL CLI version that is used in GitHub-managed workflows, such as Default Setup, to be set to a custom value. For example, customers who run into issues with rate limits when a new CodeQL CLI version is released can set the value to toolcache to always use the CodeQL CLI version that is available in the runner toolcache. For Advanced Setup workflows, the value provided for tools in the workflow definition always takes precedence unless the value of the repository property starts with !. #4037
  • Update default CodeQL bundle version to 2.26.2. #4051

4.37.3 - 22 Jul 2026

No user facing changes.

4.37.2 - 21 Jul 2026

  • The new address format for the config-file input that was introduced in CodeQL Action 4.37.0 is now enabled by default. In addition to the format described there, the remote= prefix can now be used to explicitly indicate that the input refers to a remote file. All previous input formats continue to be accepted as well. #4023
  • The CodeQL Action can now make use of configured private registries in Default Setup to retrieve CodeQL configuration files from remote repositories that require authentication. This will allow customers to store their CodeQL configuration in a single repository that can then be referenced by Default Setup workflows in other repositories. We expect to roll this and other, related changes out to everyone in July. #4007

4.37.1 - 16 Jul 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956
  • Update default CodeQL bundle version to 2.26.1. #4019

4.37.0 - 08 Jul 2026

  • Update default CodeQL bundle version to 2.26.0. #3995
  • In addition to the existing input format, the config-file input for the codeql-action/init step will soon support a new [owner/]repo[@ref][:path] format. All components except the repository name are optional. If omitted, owner defaults to the same owner as the repository the analysis is running for, ref to main, and path to .github/codeql-action.yaml. Support for this format ships in this version of the CodeQL Action, but will only be enabled over the coming weeks. #3973

4.36.3 - 01 Jul 2026

No user facing changes.

4.36.2 - 04 Jun 2026

  • Cache CodeQL CLI version information across Actions steps. #3943
  • Reduce requests while waiting for analysis processing by using exponential backoff when polling SARIF processing status. #3937
  • Update default CodeQL bundle version to 2.25.6. #3948

4.36.1 - 02 Jun 2026

No user facing changes.

4.36.0 - 22 May 2026

  • Breaking change: Bump the minimum required CodeQL bundle version to 2.19.4. #3894
  • Add support for SHA-256 Git object IDs. #3893

... (truncated)

Commits
  • e4fba86 Merge pull request #4031 from github/update-v4.37.3-72f6a9da0
  • fb50ab5 Update changelog for v4.37.3
  • 72f6a9d Merge pull request #4030 from github/mbg/fix/no-proxy
  • 3b5ee58 Use default request options instead of undefined
  • bfb6be4 Merge pull request #4028 from github/mergeback/v4.37.2-to-main-e0647621
  • 526ab84 Rebuild
  • d6217b9 Update changelog and version after v4.37.2
  • e064762 Merge pull request #4027 from github/update-v4.37.2-385bcdc5a
  • e0faed8 Add a couple of change notes
  • 73aad0e Update changelog for v4.37.2
  • Additional commits viewable in compare view

Updates `github/codeql-action/analyze` from 4.37.0 to 4.37.3
Release notes

Sourced from github/codeql-action/analyze's releases.

v4.37.3

No user facing changes.

v4.37.2

  • The new address format for the config-file input that was introduced in CodeQL Action 4.37.0 is now enabled by default. In addition to the format described there, the remote= prefix can now be used to explicitly indicate that the input refers to a remote file. All previous input formats continue to be accepted as well. #4023
  • The CodeQL Action can now make use of configured private registries in Default Setup to retrieve CodeQL configuration files from remote repositories that require authentication. This will allow customers to store their CodeQL configuration in a single repository that can then be referenced by Default Setup workflows in other repositories. We expect to roll this and other, related changes out to everyone in July. #4007

v4.37.1

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956
  • Update default CodeQL bundle version to 2.26.1. #4019
Changelog

Sourced from github/codeql-action/analyze's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

  • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061

4.37.4 - 29 Jul 2026

  • This version of the CodeQL Action adds support for the tools input for the codeql-action/init step to be specified using a github-codeql-tools repository property. This feature will gradually be rolled out following the release of this version. Once rolled out, this allows for the CodeQL CLI version that is used in GitHub-managed workflows, such as Default Setup, to be set to a custom value. For example, customers who run into issues with rate limits when a new CodeQL CLI version is released can set the value to toolcache to always use the CodeQL CLI version that is available in the runner toolcache. For Advanced Setup workflows, the value provided for tools in the workflow definition always takes precedence unless the value of the repository property starts with !. #4037
  • Update default CodeQL bundle version to 2.26.2. #4051

4.37.3 - 22 Jul 2026

No user facing changes.

4.37.2 - 21 Jul 2026

  • The new address format for the config-file input that was introduced in CodeQL Action 4.37.0 is now enabled by default. In addition to the format described there, the remote= prefix can now be used to explicitly indicate that the input refers to a remote file. All previous input formats continue to be accepted as well. #4023
  • The CodeQL Action can now make use of configured private registries in Default Setup to retrieve CodeQL configuration files from remote repositories that require authentication. This will allow customers to store their CodeQL configuration in a single repository that can then be referenced by Default Setup workflows in other repositories. We expect to roll this and other, related changes out to everyone in July. #4007

4.37.1 - 16 Jul 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956
  • Update default CodeQL bundle version to 2.26.1. #4019

4.37.0 - 08 Jul 2026

  • Update default CodeQL bundle version to 2.26.0. #3995
  • In addition to the existing input format, the config-file input for the codeql-action/init step will soon support a new [owner/]repo[@ref][:path] format. All components except the repository name are optional. If omitted, owner defaults to the same owner as the repository the analysis is running for, ref to main, and path to .github/codeql-action.yaml. Support for this format ships in this version of the CodeQL Action, but will only be enabled over the coming weeks. #3973

4.36.3 - 01 Jul 2026

No user facing changes.

4.36.2 - 04 Jun 2026

  • Cache CodeQL CLI version information across Actions steps. #3943
  • Reduce requests while waiting for analysis processing by using exponential backoff when polling SARIF processing status. #3937
  • Update default CodeQL bundle version to 2.25.6. #3948

4.36.1 - 02 Jun 2026

No user facing changes.

4.36.0 - 22 May 2026

  • Breaking change: Bump the minimum required CodeQL bundle version to 2.19.4. #3894
  • Add support for SHA-256 Git object IDs. #3893

... (truncated)

Commits
  • e4fba86 Merge pull request #4031 from github/update-v4.37.3-72f6a9da0
  • fb50ab5 Update changelog for v4.37.3
  • 72f6a9d Merge pull request #4030 from github/mbg/fix/no-proxy
  • 3b5ee58 Use default request options instead of undefined
  • bfb6be4 Merge pull request #4028 from github/mergeback/v4.37.2-to-main-e0647621
  • 526ab84 Rebuild
  • d6217b9 Update changelog and version after v4.37.2
  • e064762 Merge pull request #4027 from github/update-v4.37.2-385bcdc5a
  • e0faed8 Add a couple of change notes
  • 73aad0e Update changelog for v4.37.2
  • Additional commits viewable in compare view

Updates `github/codeql-action/upload-sarif` from 4.37.0 to 4.37.3
Release notes

Sourced from github/codeql-action/upload-sarif's releases.

v4.37.3

No user facing changes.

v4.37.2

  • The new address format for the config-file input that was introduced in CodeQL Action 4.37.0 is now enabled by default. In addition to the format described there, the remote= prefix can now be used to explicitly indicate that the input refers to a remote file. All previous input formats continue to be accepted as well. #4023
  • The CodeQL Action can now make use of configured private registries in Default Setup to retrieve CodeQL configuration files from remote repositories that require authentication. This will allow customers to store their CodeQL configuration in a single repository that can then be referenced by Default Setup workflows in other repositories. We expect to roll this and other, related changes out to everyone in July. #4007

v4.37.1

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956
  • Update default CodeQL bundle version to 2.26.1. #4019
Changelog

Sourced from github/codeql-action/upload-sarif's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

  • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061

4.37.4 - 29 Jul 2026

  • This version of the CodeQL Action adds support for the tools input for the codeql-action/init step to be specified using a github-codeql-tools repository property. This feature will gradually be rolled out following the release of this version. Once rolled out, this allows for the CodeQL CLI version that is used in GitHub-managed workflows, such as Default Setup, to be set to a custom value. For example, customers who run into issues with rate limits when a new CodeQL CLI version is released can set the value to toolcache to always use the CodeQL CLI version that is available in the runner toolcache. For Advanced Setup workflows, the value provided for tools in the workflow definition always takes precedence unless the value of the repository property starts with !. #4037
  • Update default CodeQL bundle version to 2.26.2. #4051

4.37.3 - 22 Jul 2026

No user facing changes.

4.37.2 - 21 Jul 2026

  • The new address format for the config-file input that was introduced in CodeQL Action 4.37.0 is now enabled by default. In addition to the format described there, the remote= prefix can now be used to explicitly indicate that the input refers to a remote file. All previous input formats continue to be accepted as well. #4023
  • The CodeQL Action can now make use of configured private registries in Default Setup to retrieve CodeQL configuration files from remote repositories that require authentication. This will allow customers to store their CodeQL configuration in a single repository that can then be referenced by Default Setup workflows in other repositories. We expect to roll this and other, related changes out to everyone in July. #4007

4.37.1 - 16 Jul 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956
  • Update default CodeQL bundle version to 2.26.1. #4019

4.37.0 - 08 Jul 2026

  • Update default CodeQL bundle version to 2.26.0. #3995
  • In addition to the existing input format, the config-file input for the codeql-action/init step will soon support a new [owner/]repo[@ref][:path] format. All components except the repository name are optional. If omitted, owner defaults to the same owner as the repository the analysis is running for, ref to main, and path to .github/codeql-action.yaml. Support for this format ships in this version of the CodeQL Action, but will only be enabled over the coming weeks. #3973

4.36.3 - 01 Jul 2026

No user facing changes.

4.36.2 - 04 Jun 2026

  • Cache CodeQL CLI version information across Actions steps. #3943
  • Reduce requests while waiting for analysis processing by using exponential backoff when polling SARIF processing status. #3937
  • Update default CodeQL bundle version to 2.25.6. #3948

4.36.1 - 02 Jun 2026

No user facing changes.

4.36.0 - 22 May 2026

  • Breaking change: Bump the minimum required CodeQL bundle version to 2.19.4. #3894
  • Add support for SHA-256 Git object IDs. #3893

... (truncated)

Commits
  • e4fba86 Merge pull request #4031 from github/update-v4.37.3-72f6a9da0
  • fb50ab5 Update changelog for v4.37.3
  • 72f6a9d Merge pull request #4030 from github/mbg/fix/no-proxy
  • 3b5ee58 Use default request options instead of undefined
  • bfb6be4 Merge pull request #4028 from github/mergeback/v4.37.2-to-main-e0647621
  • 526ab84 Rebuild
  • d6217b9 Update changelog and version after v4.37.2
  • e064762 Merge pull request #4027 from github/update-v4.37.2-385bcdc5a
  • e0faed8 Add a couple of change notes
  • 73aad0e Update changelog for v4.37.2
  • Additional commits viewable in compare view

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- .github/workflows/scorecard.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d65e6ab..f1d3228 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -38,11 +38,11 @@ jobs: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Initialize CodeQL - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 4eca0b9..c400e43 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -73,6 +73,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: sarif_file: results.sarif From 5d1040061b1381a5dafd98f6926e6d8ee77166ca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:28:48 +0000 Subject: [PATCH 356/418] build(deps): bump actions/setup-node from 6.0.0 to 7.0.0 (#1123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.0.0 to 7.0.0.
Release notes

Sourced from actions/setup-node's releases.

v7.0.0

What's Changed

Enhancements:

Bug fixes:

Documentation updates:

Dependency update:

New Contributors

Full Changelog: https://github.com/actions/setup-node/compare/v6...v7.0.0

v6.5.0

What's Changed

Full Changelog: https://github.com/actions/setup-node/compare/v6.4.0...v6.5.0

v6.4.0

What's Changed

Dependency updates:

New Contributors

Full Changelog: https://github.com/actions/setup-node/compare/v6...v6.4.0

v6.3.0

What's Changed

Enhancements:

... (truncated)

Commits
  • 8207627 Migrate to ESM and upgrade dependencies (#1574)
  • 04be95c Add cache-primary-key and cache-matched-key as outputs (#1577)
  • 7c2c68d docs: Update caching recommendations to mitigate cache poisoning risks (#1567)
  • 6a61c03 Merge pull request #1569 from jasongin/update-actions-cache-5.1.0
  • 30eb73b Resolve high-severity audit issues
  • 4e1a87a Update dist
  • 360237f Strict equality
  • 4f8aac5 Bump @​actions/cache to 5.1.0, log cache write denied
  • f4a67bb Only use mirrorToken in getManifest if it's provided (#1548)
  • 0355742 Remove dummy NODE_AUTH_TOKEN export (#1558)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/setup-node&package-manager=github_actions&previous-version=6.0.0&new-version=7.0.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Guglielmo Colombo --- .github/workflows/conformance.yml | 4 ++-- .github/workflows/nightly.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index be34c09..9008cb4 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -27,7 +27,7 @@ jobs: with: go-version: "^1.26" - name: Set up Node.js - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 - name: Build everything-server @@ -65,7 +65,7 @@ jobs: with: go-version: "^1.26" - name: Set up Node.js - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 - name: Run conformance tests (full suite) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 21d0420..7bb4c5c 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -29,7 +29,7 @@ jobs: with: go-version: "^1.26" - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" - name: Run server conformance tests From 7b3bbc0f3264b8296fa07de88b297cf3a93ccf9a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:34:48 +0000 Subject: [PATCH 357/418] build(deps): bump actions/setup-python from 6.3.0 to 7.0.0 (#1124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6.3.0 to 7.0.0.
Release notes

Sourced from actions/setup-python's releases.

v7.0.0

What's Changed

Enhancements

Bug Fix

Dependency Upgrade

New Contributors

Full Changelog: https://github.com/actions/setup-python/compare/v6...v7.0.0

Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index e64faa2..b4a6b61 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -18,7 +18,7 @@ jobs: run: | git config user.name github-actions[bot] git config user.email 41898282+github-actions[bot]@users.noreply.github.com - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: 3.x - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV From 02610a2075d4ab37a07aa738668f466d8eb43fac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:37:58 +0200 Subject: [PATCH 358/418] build(deps): bump actions/checkout from 7.0.0 to 7.0.1 (#1126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/checkout](https://github.com/actions/checkout) from 7.0.0 to 7.0.1.
Release notes

Sourced from actions/checkout's releases.

v7.0.1

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v7...v7.0.1

Changelog

Sourced from actions/checkout's changelog.

Changelog

v7.0.1

v7.0.0

v6.0.3

v6.0.2

v6.0.1

v6.0.0

v5.0.1

v5.0.0

v4.3.1

v4.3.0

v4.2.2

v4.2.1

... (truncated)

Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Guglielmo Colombo --- .github/workflows/codeql.yml | 2 +- .github/workflows/conformance.yml | 4 ++-- .github/workflows/docs-check.yml | 2 +- .github/workflows/nightly.yml | 2 +- .github/workflows/publish-docs.yml | 2 +- .github/workflows/scorecard.yml | 2 +- .github/workflows/test.yml | 6 +++--- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f1d3228..0f63536 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -36,7 +36,7 @@ jobs: build-mode: autobuild steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 9008cb4..34c9dc3 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: @@ -59,7 +59,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index 6e6972e..0b73482 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -20,7 +20,7 @@ jobs: with: go-version: "^1.26" - name: Check out code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check docs are up-to-date run: | go generate ./... diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 7bb4c5c..d1c995c 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index b4a6b61..4d6d31e 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -13,7 +13,7 @@ jobs: contents: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Configure Git Credentials run: | git config user.name github-actions[bot] diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index c400e43..a29d098 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -34,7 +34,7 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 32ef66a..da46a60 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: @@ -43,7 +43,7 @@ jobs: go: ["1.25", "1.26"] steps: - name: Check out code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: @@ -55,7 +55,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: From ec29d60bb933f0e9b79b80b6a40920f5a51a4870 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:43:31 +0200 Subject: [PATCH 359/418] build(deps): bump actions/setup-go from 6.5.0 to 7.0.0 (#1125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6.5.0 to 7.0.0.
Release notes

Sourced from actions/setup-go's releases.

v7.0.0

What's Changed

New Contributors

Full Changelog: https://github.com/actions/setup-go/compare/v6...v7.0.0

Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Guglielmo Colombo --- .github/workflows/conformance.yml | 4 ++-- .github/workflows/docs-check.yml | 2 +- .github/workflows/nightly.yml | 2 +- .github/workflows/test.yml | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 34c9dc3..7d6cca4 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -23,7 +23,7 @@ jobs: - name: Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "^1.26" - name: Set up Node.js @@ -61,7 +61,7 @@ jobs: - name: Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "^1.26" - name: Set up Node.js diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index 0b73482..d255e63 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "^1.26" - name: Check out code diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index d1c995c..226627f 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -25,7 +25,7 @@ jobs: - name: Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "^1.26" - name: Set up Node.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index da46a60..5c351b3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: - name: Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "^1.26" - name: Check formatting @@ -45,7 +45,7 @@ jobs: - name: Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: ${{ matrix.go }} - name: Test @@ -57,7 +57,7 @@ jobs: - name: Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "1.26" - name: Test with -race From 0d0cdbc943f396aa973023a7dd6440398d3df87e Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Mon, 3 Aug 2026 13:54:24 +0200 Subject: [PATCH 360/418] mcp: prevent leaks of ServerSession on StatefulServer (#1137) Fixes #1136 --- mcp/server.go | 14 +++++++-- mcp/streamable_test.go | 64 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index b580548..c189a8e 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -897,9 +897,17 @@ func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams] Capabilities: req.ClientCapabilities(), ClientInfo: req.ClientInfo(), } - req.Session.updateState(func(state *ServerSessionState) { - state.InitializeParams = init - }) + // Only persist InitializeParams when the transport can actually serve + // the new protocol. On transports that cannot (notably stateful + // StreamableHTTPHandler), a discover request creates a session that + // is never surfaced to the client via Mcp-Session-Id; leaving + // InitializeParams nil lets serveStatefulPOST's safety-net cleanup + // close it instead of leaking. + if supportedVersion := negotiateMutuallySupportedVersion(versions); supportedVersion >= protocolVersion20260728 { + req.Session.updateState(func(state *ServerSessionState) { + state.InitializeParams = init + }) + } res := &DiscoverResult{ SupportedVersions: versions, Capabilities: s.capabilities(), diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 85ee331..fac337f 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -3682,6 +3682,70 @@ func TestStreamableStateful_AcceptsDiscover(t *testing.T) { } } +// On a stateful StreamableHTTPHandler, a v1.7.0-style client always +// probes server/discover before falling back to initialize. That probe used +// to create a ServerSession that was registered in the handler's session +// map but never surfaced via Mcp-Session-Id, so the client could not DELETE +// it and it was leaked for the lifetime of the process. +// +// The fix in Server.discover skips populating InitializeParams when the +// transport cannot serve the new protocol, which lets the safety-net +// cleanup in serveStatefulPOST close the otherwise-unaddressable session. +func TestStreamableStateful_DiscoverDoesNotLeakSession(t *testing.T) { + ctx := context.Background() + server := NewServer(testImpl, nil) + AddTool(server, &Tool{Name: "noop", Description: "noop"}, + func(context.Context, *CallToolRequest, struct{}) (*CallToolResult, any, error) { + return &CallToolResult{}, nil, nil + }) + + // Stateful (default). SessionTimeout unset so nothing masks the leak + // via idle eviction. + handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + countSessions := func() int { + n := 0 + for range server.Sessions() { + n++ + } + return n + } + + const clients = 3 + for i := 1; i <= clients; i++ { + c := NewClient(&Implementation{Name: "c", Version: "1.0.0"}, nil) + sess, err := c.Connect(ctx, &StreamableClientTransport{ + Endpoint: httpServer.URL, + DisableStandaloneSSE: true, + MaxRetries: -1, + }, nil) + if err != nil { + t.Fatalf("client %d Connect: %v", i, err) + } + if _, err := sess.ListTools(ctx, nil); err != nil { + t.Fatalf("client %d ListTools: %v", i, err) + } + // Exactly one addressable session should exist while this client + // is connected. Anything more means a discover-only session + // slipped through. + if got := countSessions(); got != 1 { + t.Errorf("client %d connected: sessions = %d, want 1", i, got) + } + if err := sess.Close(); err != nil { + t.Fatalf("client %d Close: %v", i, err) + } + if got := countSessions(); got != 0 { + t.Errorf("client %d closed: sessions = %d, want 0", i, got) + } + } + + if got := countSessions(); got != 0 { + t.Errorf("final leaked sessions = %d, want 0", got) + } +} + // TestStreamableHTTP_E2E_DiscoverSuccess is a full end-to-end smoke test for // SEP-2575 over the streamable HTTP transport. func TestStreamableHTTP_E2E_DiscoverSuccess(t *testing.T) { From 1acd66e9d71e4752cf01eb14dad70c2850bf5c5d Mon Sep 17 00:00:00 2001 From: dev_Hakaze Date: Mon, 3 Aug 2026 20:29:52 +0700 Subject: [PATCH 361/418] mcp: export ClientSessionOptions.ProtocolVersion (#1127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `ClientSessionOptions.protocolVersion` was unexported and documented as test-only, so external consumers of `Client.Connect` could not request a specific protocol revision. This exports the field as `ProtocolVersion` with the same semantics: - empty → request the latest supported version (unchanged default) - non-empty → send that version in initialize/discover - existing negotiate-down path is unchanged ## Usage ```go cs, err := client.Connect(ctx, transport, &mcp.ClientSessionOptions{ ProtocolVersion: "2025-11-25", }) ``` ## Testing - `go test ./mcp/ -count=1` (pass) Fixes #1113 Co-authored-by: arimu1 <19286898+arimu1@users.noreply.github.com> Co-authored-by: Guglielmo Colombo --- mcp/client.go | 13 ++++++----- mcp/client_test.go | 14 +++++------ mcp/elicitation_test.go | 6 ++--- mcp/mcp_test.go | 44 +++++++++++++++++------------------ mcp/mrtr_test.go | 4 ++-- mcp/sampling_test.go | 10 ++++---- mcp/server_test.go | 2 +- mcp/streamable_client_test.go | 32 ++++++++++++------------- mcp/streamable_test.go | 20 ++++++++-------- 9 files changed, 73 insertions(+), 72 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index 6f1073a..2ad0ea4 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -245,11 +245,12 @@ func (e unsupportedProtocolVersionError) Error() string { return fmt.Sprintf("unsupported protocol version: %q", e.version) } -// ClientSessionOptions is reserved for future use. +// ClientSessionOptions configures a client session created by [Client.Connect]. type ClientSessionOptions struct { - // protocolVersion overrides the protocol version sent in the initialize - // request, for testing. If empty, latestProtocolVersion is used. - protocolVersion string + // ProtocolVersion is the protocol version sent in the initialize (or + // discover) request. If empty, the latest supported version is used. + // The server may negotiate a different mutually supported version. + ProtocolVersion string } func (c *Client) capabilities(protocolVersion string) *ClientCapabilities { @@ -311,8 +312,8 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp } protocolVersion := latestProtocolVersion - if opts != nil && opts.protocolVersion != "" { - protocolVersion = opts.protocolVersion + if opts != nil && opts.ProtocolVersion != "" { + protocolVersion = opts.ProtocolVersion } if protocolVersion >= protocolVersion20260728 { diff --git a/mcp/client_test.go b/mcp/client_test.go index 0fe39c0..f02b905 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -740,7 +740,7 @@ func TestClientConnectDiscover(t *testing.T) { defer ss.Close() c := NewClient(testImpl, nil) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -805,7 +805,7 @@ func TestClientConnectDiscover_RequestContents(t *testing.T) { return nil, nil }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -851,7 +851,7 @@ func TestInMemory_E2E_DiscoverSuccess(t *testing.T) { defer ss.Close() client := NewClient(&Implementation{Name: "stdio-like-client", Version: "v1"}, nil) - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client.Connect: %v", err) } @@ -908,7 +908,7 @@ func TestInMemory_E2E_DiscoverFallback_NoOverlap(t *testing.T) { defer ss.Close() client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil) - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client.Connect: %v", err) } @@ -955,7 +955,7 @@ func TestInMemory_E2E_DiscoverFallback_MethodNotFound(t *testing.T) { defer ss.Close() client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil) - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client.Connect: %v", err) } @@ -1005,7 +1005,7 @@ func TestInMemory_E2E_DiscoverFallback_UnsupportedProtocolVersion(t *testing.T) defer ss.Close() client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil) - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client.Connect: %v", err) } @@ -1068,7 +1068,7 @@ func TestClientConnectDiscover_UnsupportedVersionNegotiation(t *testing.T) { defer ss.Close() c := NewClient(testImpl, nil) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: unsupportedClientVersion}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: unsupportedClientVersion}) if err != nil { t.Fatalf("Connect: %v", err) } diff --git a/mcp/elicitation_test.go b/mcp/elicitation_test.go index a20947f..d588516 100644 --- a/mcp/elicitation_test.go +++ b/mcp/elicitation_test.go @@ -114,7 +114,7 @@ func TestElicitationURLMode(t *testing.T) { }, ElicitationHandler: tc.handler, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -172,7 +172,7 @@ func TestElicitationCompleteNotification(t *testing.T) { t.Fatal(err) } defer ss.Close() - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -255,7 +255,7 @@ func TestElicitationNoValidationWithoutAccept(t *testing.T) { return &ElicitResult{Action: tc.action, Content: tc.content}, nil }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 06526c4..d9d9b3a 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -174,7 +174,7 @@ func TestEndToEnd(t *testing.T) { // // Pin the session to 2025-11-25 so the legacy // semantics apply. - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -764,7 +764,7 @@ func TestMiddleware(t *testing.T) { // Pin to 2025-11-25 because the test's expected wire sequence asserts // the legacy initialize / notifications/initialized handshake, which // 2026-07-28 replaces with server/discover. - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -854,7 +854,7 @@ func TestNoJSONNull(t *testing.T) { } c := NewClient(testImpl, nil) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1023,7 +1023,7 @@ func TestElicitationUnsupportedMethod(t *testing.T) { return &CreateMessageResult{Model: "aModel", Content: &TextContent{}}, nil }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1072,7 +1072,7 @@ func TestElicitationSchemaValidation(t *testing.T) { return &ElicitResult{Action: "accept", Content: map[string]any{"test": "value"}}, nil }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1558,7 +1558,7 @@ func TestElicitContentValidation(t *testing.T) { return &ElicitResult{Action: "accept", Content: map[string]any{"test": "potato"}}, nil }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1641,7 +1641,7 @@ func TestElicitationProgressToken(t *testing.T) { return &ElicitResult{Action: "accept"}, nil }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1683,7 +1683,7 @@ func TestElicitationCapabilityDeclaration(t *testing.T) { } defer ss.Close() - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1720,7 +1720,7 @@ func TestElicitationCapabilityDeclaration(t *testing.T) { } defer ss.Close() - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1757,7 +1757,7 @@ func TestElicitationDefaultValues(t *testing.T) { return &ElicitResult{Action: "accept", Content: map[string]any{"default": "response"}}, nil }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -1912,7 +1912,7 @@ func TestKeepAliveFailure_Logged(t *testing.T) { // Pin to 2025-11-25: KeepAlive uses the ping RPC, which is removed // in 2026-07-28, so keepalive is only meaningful on legacy protocol // versions. - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -2087,7 +2087,7 @@ func TestSynchronousNotifications(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = ss.Close() }) - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -2501,7 +2501,7 @@ func runSubscriptionsListenTest(t *testing.T, client *Client, server *Server, ct ctx, topCancel := context.WithTimeout(context.Background(), 30*time.Second) defer topCancel() - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -2659,7 +2659,7 @@ func TestSubscriptionsListen_NoHandlersNoListen(t *testing.T) { return next(ctx, method, req) } }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -2782,7 +2782,7 @@ func TestResourceSubscriptions_Streamable(t *testing.T) { }, }) cs, err := c.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, - &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -2851,7 +2851,7 @@ func TestResourceSubscriptions_InMemory(t *testing.T) { events <- resourceSubEvent{uri: req.Params.URI, id: id} }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -2928,7 +2928,7 @@ func TestResourceSubscriptions_Subscribe_Idempotent(t *testing.T) { c := NewClient(testImpl, &ClientOptions{ ResourceUpdatedHandler: func(context.Context, *ResourceUpdatedNotificationRequest) {}, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -3000,7 +3000,7 @@ func TestResourceSubscriptions_MultipleURIs(t *testing.T) { events <- resourceSubEvent{uri: req.Params.URI, id: id} }, }) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -3105,7 +3105,7 @@ func TestSubscriptionsListen_MultipleSessions(t *testing.T) { } c := newSubListenClient(events) cs, err := c.Connect(context.Background(), ct, - &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -3205,7 +3205,7 @@ func TestSubscriptionsListen_ResourceListChanged(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -3249,7 +3249,7 @@ func TestSubscriptionsListen_DisconnectScrubsMaps(t *testing.T) { } c := newSubListenClient(events) cs, err := c.Connect(context.Background(), ct, - &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client connect: %v", err) } @@ -3428,7 +3428,7 @@ func TestCallCustomMethodTypedNilParams(t *testing.T) { if err := AddSendingCustomMethod[*pingParams, *pingResult](c, "acme/ping"); err != nil { t.Fatal(err) } - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatal(err) } diff --git a/mcp/mrtr_test.go b/mcp/mrtr_test.go index 238bc09..2ae4fd9 100644 --- a/mcp/mrtr_test.go +++ b/mcp/mrtr_test.go @@ -312,7 +312,7 @@ func TestMultiRoundTrip_ServerMiddleware(t *testing.T) { }, }) c.AddRoots(&Root{URI: "file:///workspace", Name: "workspace"}) - cs, err := c.Connect(t.Context(), ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := c.Connect(t.Context(), ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() error = %v", err) } @@ -516,7 +516,7 @@ func mustConnect(t *testing.T, s *Server, clientOpts *ClientOptions) *ClientSess }) c := NewClient(testImpl, clientOpts) - cs, err := c.Connect(t.Context(), ct, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := c.Connect(t.Context(), ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("client.Connect() error = %v", err) } diff --git a/mcp/sampling_test.go b/mcp/sampling_test.go index 2adb0fd..7a3ecab 100644 --- a/mcp/sampling_test.go +++ b/mcp/sampling_test.go @@ -53,7 +53,7 @@ func TestSamplingWithTools_ToolUse(t *testing.T) { } defer ss.Close() - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -124,7 +124,7 @@ func TestSamplingWithTools_ToolResult(t *testing.T) { } defer ss.Close() - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -288,7 +288,7 @@ func TestSamplingWithTools_ToolResultWithError(t *testing.T) { } defer ss.Close() - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -350,7 +350,7 @@ func TestSamplingWithTools_ParallelToolCalls(t *testing.T) { } defer ss.Close() - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -420,7 +420,7 @@ func TestCreateMessage_MultipleContentError(t *testing.T) { } defer ss.Close() - cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } diff --git a/mcp/server_test.go b/mcp/server_test.go index d6ecfed..75d7455 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -1663,7 +1663,7 @@ func TestServerSession_RejectsServerInitiated(t *testing.T) { }, }) c.AddRoots(&Root{URI: "file:///workspace"}) - cs, err := c.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protoVer}) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protoVer}) if err != nil { t.Fatal(err) } diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index c8ab066..5b52369 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -214,7 +214,7 @@ func TestStreamableClientTransportLifecycle(t *testing.T) { // Pin to 2025-11-25: the fixture's canned initialize response uses // hardcoded id=1, which only matches when initialize is the first // request. Under 2026-07-28 the client probes server/discover first. - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -267,7 +267,7 @@ func TestStreamableClientRedundantDelete(t *testing.T) { // Pin to 2025-11-25: the fixture's canned initialize response uses // hardcoded id=1, which only matches when initialize is the first // request. Under 2026-07-28 the client probes server/discover first. - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -332,7 +332,7 @@ func TestStreamableClientGETHandling(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) session, err := client.Connect(ctx, transport, &ClientSessionOptions{ - protocolVersion: protocolVersion20251125, + ProtocolVersion: protocolVersion20251125, }) if err == nil { defer session.Close() @@ -414,7 +414,7 @@ func TestStreamableClientStrictness(t *testing.T) { // uses hardcoded id=1, which only matches when initialize is // the first request. Under 2026-07-28 the client probes // server/discover first. - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if (err != nil) != test.wantConnectError { t.Errorf("client.Connect() returned error %v; want error: %t", err, test.wantConnectError) } @@ -454,7 +454,7 @@ func TestStreamableClientUnresumableRequest(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - cs, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err == nil { cs.Close() t.Fatalf("Connect succeeded unexpectedly") @@ -575,7 +575,7 @@ data: { "jsonrpc": "2.0", "method": "notifications/message", "params": { "level" // uses hardcoded id=1, which only matches when initialize is // the first request. Under 2026-07-28 the client probes // server/discover first. - cs, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + cs, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -707,7 +707,7 @@ func TestStreamableClientTransientErrors(t *testing.T) { // uses hardcoded id=1, which only matches when initialize is // the first request. Under 2026-07-28 the client probes // server/discover first. - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("Connect failed: %v", err) } @@ -813,7 +813,7 @@ data: {"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info" // Pin to 2025-11-25: the fixture's canned initialize response uses // hardcoded id=1, which only matches when initialize is the first // request. Under 2026-07-28 the client probes server/discover first. - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("Connect failed: %v", err) } @@ -901,7 +901,7 @@ func TestStreamableClientDisableStandaloneSSE(t *testing.T) { // uses hardcoded id=1, which only matches when initialize is // the first request. Under 2026-07-28 the client probes // server/discover first. - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -1022,7 +1022,7 @@ func TestStreamableClientOAuth_AuthorizationHeader(t *testing.T) { // Pin to 2025-11-25: the fixture's canned initialize response uses // hardcoded id=1, which only matches when initialize is the first // request. Under 2026-07-28 the client probes server/discover first. - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -1316,7 +1316,7 @@ func TestStreamableClientConnect_DiscoverSuccess(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -1496,7 +1496,7 @@ func TestStreamableClientConnect_DiscoverMethodNotFound(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -1563,7 +1563,7 @@ func TestStreamableClientConnect_DiscoverUnsupportedVersion(t *testing.T) { transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -1623,7 +1623,7 @@ func TestStreamableClientConnect_DiscoverMethodNotFoundVPre(t *testing.T) { DisableStandaloneSSE: true, } client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -1680,7 +1680,7 @@ func TestStreamableClientConnect_DiscoverUnsupportedVersionVPre(t *testing.T) { DisableStandaloneSSE: true, } client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -1738,7 +1738,7 @@ func TestStreamableClientConnect_DiscoverUnsupportedVersionNegotiation(t *testin transport := &StreamableClientTransport{Endpoint: httpServer.URL} client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: unsupportedClientVersion}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: unsupportedClientVersion}) if err != nil { t.Fatalf("Connect: %v", err) } diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index fac337f..726733d 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -347,7 +347,7 @@ func TestStreamableServerShutdown(t *testing.T) { clientSession, err := client.Connect(ctx, &StreamableClientTransport{ Endpoint: httpServer.URL, MaxRetries: -1, // avoid slow tests during exponential retries - }, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + }, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatal(err) } @@ -622,7 +622,7 @@ func TestStreamableServerDisconnect(t *testing.T) { }) clientSession, err := client.Connect(ctx, &StreamableClientTransport{ Endpoint: httpServer.URL, - }, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + }, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -697,7 +697,7 @@ func TestServerTransportCleanup(t *testing.T) { // stateful HTTP server (which rejects the new protocol), opening // an extra HTTP connection and therefore an extra session. Pinning // to 2025-11-25 skips the discover probe. - clientSession, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + clientSession, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -2122,7 +2122,7 @@ func TestStreamableMcpHeaderValidationErrorFormat(t *testing.T) { client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil) ctx := context.Background() - session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{protocolVersion: minVersionForStandardHeaders}) + session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{ProtocolVersion: minVersionForStandardHeaders}) if err != nil { t.Fatal(err) } @@ -2285,7 +2285,7 @@ func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) { client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil) ctx := context.Background() - session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{protocolVersion: minVersionForStandardHeaders}) + session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{ProtocolVersion: minVersionForStandardHeaders}) if err != nil { t.Fatal(err) } @@ -2383,7 +2383,7 @@ func TestStreamableFilterValidToolsIntegration(t *testing.T) { ctx := context.Background() session, err := client.Connect(ctx, &StreamableClientTransport{ Endpoint: httpServer.URL, - }, &ClientSessionOptions{protocolVersion: minVersionForStandardHeaders}) + }, &ClientSessionOptions{ProtocolVersion: minVersionForStandardHeaders}) if err != nil { t.Fatal(err) } @@ -2644,7 +2644,7 @@ func TestStreamableSessionTimeout(t *testing.T) { // protocol), opening an extra HTTP connection and therefore an extra // session. Pinning to 2025-11-25 skips the discover probe. client := NewClient(testImpl, nil) - session, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + session, err := client.Connect(ctx, &StreamableClientTransport{Endpoint: httpServer.URL}, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("client.Connect() failed: %v", err) } @@ -2938,7 +2938,7 @@ func Test_ExportErrSessionMissing(t *testing.T) { // first against a stateful HTTP server (which rejects the new // protocol), opening an extra HTTP connection and therefore an extra // session. Pinning to 2025-11-25 skips the discover probe. - session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{protocolVersion: protocolVersion20251125}) + session, err := client.Connect(ctx, clientTransport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) if err != nil { t.Fatalf("Connect failed: %v", err) } @@ -3579,7 +3579,7 @@ func TestStreamableClientUnsupportedVersionFallback(t *testing.T) { client := NewClient(testImpl, nil) transport := &StreamableClientTransport{Endpoint: httpServer.URL} - session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } @@ -3771,7 +3771,7 @@ func TestStreamableHTTP_E2E_DiscoverSuccess(t *testing.T) { client := NewClient(&Implementation{Name: "e2e-client", Version: "v1"}, nil) transport := &StreamableClientTransport{Endpoint: httpServer.URL} - cs, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260728}) + cs, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) if err != nil { t.Fatalf("Connect: %v", err) } From c4a4ce4543bf52698e20a4fbff75285c76caf0c0 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Tue, 4 Aug 2026 11:02:36 +0200 Subject: [PATCH 362/418] mcp: fix error propagation to prevent session closing (#1118) Fixes #1117 --- docs/mcpgodebug.md | 9 +- internal/docs/mcpgodebug.src.md | 9 +- mcp/streamable.go | 45 +++++----- mcp/streamable_client_test.go | 144 ++++++++++++++++++++++++++++++++ 4 files changed, 182 insertions(+), 25 deletions(-) diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index 45b9c7d..4562821 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -46,9 +46,12 @@ Options listed below were added and will be removed in the 1.9.0 version of the requested method in STDIO transport is not found. - `noprotocolerrorbody` added. If set to `1`, the streamable HTTP client will - not attempt to decode the JSON-RPC error body of a non-2xx HTTP response, - restoring the previous behavior. The default behavior was changed so that - the client always attempts to surface the underlying JSON-RPC error. + not attempt to decode the JSON-RPC error body of a non-2xx HTTP response, + and any non-transient error will permanently fail the connection, restoring + the previous behavior. The default behavior was changed so that the client + attempts to decode the JSON-RPC error body of a non-2xx response, surfaces + the underlying JSON-RPC error, and wraps it with `jsonrpc2.ErrRejected` so + that per-call rejections do not tear down the session. - `nowrapinvalidparams` added. If set to `1`, the server will not wrap params-decoding failures with `jsonrpc2.ErrInvalidParams`, so wire responses diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index e3ca770..c009a7c 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -45,9 +45,12 @@ Options listed below were added and will be removed in the 1.9.0 version of the requested method in STDIO transport is not found. - `noprotocolerrorbody` added. If set to `1`, the streamable HTTP client will - not attempt to decode the JSON-RPC error body of a non-2xx HTTP response, - restoring the previous behavior. The default behavior was changed so that - the client always attempts to surface the underlying JSON-RPC error. + not attempt to decode the JSON-RPC error body of a non-2xx HTTP response, + and any non-transient error will permanently fail the connection, restoring + the previous behavior. The default behavior was changed so that the client + attempts to decode the JSON-RPC error body of a non-2xx response, surfaces + the underlying JSON-RPC error, and wraps it with `jsonrpc2.ErrRejected` so + that per-call rejections do not tear down the session. - `nowrapinvalidparams` added. If set to `1`, the server will not wrap params-decoding failures with `jsonrpc2.ErrInvalidParams`, so wire responses diff --git a/mcp/streamable.go b/mcp/streamable.go index f642db1..2799d14 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -300,7 +300,12 @@ var allowsessionsinstateless = mcpgodebug.Value("allowsessionsinstateless") // noprotocolerrorbody is a compatibility parameter that restores the previous // behavior of [streamableClientConn.checkResponse]. When unset (the default), -// the client always attempts to surface the underlying JSON-RPC error. +// the client attempts to decode the response body of a non-2xx response as a +// JSON-RPC error. If successful, the underlying JSON-RPC error is surfaced to +// the caller and wrapped with [jsonrpc2.ErrRejected] so that per-call +// rejections do not tear down the session. When set to "1", the client +// ignores response bodies on non-2xx responses and any non-transient error +// permanently fails the connection. var noprotocolerrorbody = mcpgodebug.Value("noprotocolerrorbody") // disablecontenttypecheck is a compatibility parameter that allows to disable @@ -2304,10 +2309,13 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e } if err := c.checkResponse(ctx, requestSummary, resp); err != nil { - if requestMethod == methodDiscover { + if requestMethod == methodDiscover && !errors.Is(err, jsonrpc2.ErrRejected) { // Wrap the discover failure with ErrRejected so the jsonrpc2 layer // doesn't set writeErr, which would prevent the legacy initialize - // fallback from succeeding on the same connection. + // fallback from succeeding on the same connection. This covers the + // case where a legacy server rejects server/discover with a + // non-JSON-RPC body (e.g. plain text 400), which checkResponse + // cannot classify as a per-call rejection on its own. err = fmt.Errorf("%w: %w", err, jsonrpc2.ErrRejected) } else if !errors.Is(err, jsonrpc2.ErrRejected) { // Only fail the connection for non-transient errors. @@ -2528,6 +2536,21 @@ func (c *streamableClientConn) checkResponse(ctx context.Context, requestSummary resp.Body.Close() } }() + // Transient server errors (502, 503, 504, 429) should not break the connection. + // Wrap them with ErrRejected so the jsonrpc2 layer doesn't set writeErr. + if isTransientHTTPStatus(resp.StatusCode) { + return fmt.Errorf("%w: %s: %v", jsonrpc2.ErrRejected, requestSummary, http.StatusText(resp.StatusCode)) + } + // By default, always try to decode the body and surface the underlying + // JSON-RPC error, wrapping it with ErrRejected to prevent the connection from closing. + // Setting MCPGODEBUG=noprotocolerrorbody=1 restores the previous behavior. + if noprotocolerrorbody != "1" && (resp.StatusCode < 200 || resp.StatusCode >= 300) { + body, _ := io.ReadAll(resp.Body) + msg, _ := jsonrpc.DecodeMessage(body) + if response, ok := msg.(*jsonrpc.Response); ok && response.Error != nil { + return fmt.Errorf("%s: %w: %w: %v", requestSummary, response.Error, jsonrpc2.ErrRejected, http.StatusText(resp.StatusCode)) + } + } // §2.5.3: "The server MAY terminate the session at any time, after // which it MUST respond to requests containing that session ID with HTTP // 404 Not Found." @@ -2536,23 +2559,7 @@ func (c *streamableClientConn) checkResponse(ctx context.Context, requestSummary // session is already gone. return fmt.Errorf("%s: failed to connect (session ID: %v): %w", requestSummary, c.sessionID, ErrSessionMissing) } - // Transient server errors (502, 503, 504, 429) should not break the connection. - // Wrap them with ErrRejected so the jsonrpc2 layer doesn't set writeErr. - if isTransientHTTPStatus(resp.StatusCode) { - return fmt.Errorf("%w: %s: %v", jsonrpc2.ErrRejected, requestSummary, http.StatusText(resp.StatusCode)) - } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - // By default, always try to decode the body and surface the underlying - // JSON-RPC error. - // Setting MCPGODEBUG=noprotocolerrorbody=1 restores the previous behavior. - if noprotocolerrorbody == "1" { - return fmt.Errorf("%s: %v", requestSummary, http.StatusText(resp.StatusCode)) - } - body, _ := io.ReadAll(resp.Body) - msg, _ := jsonrpc.DecodeMessage(body) - if response, ok := msg.(*jsonrpc.Response); ok && response.Error != nil { - return fmt.Errorf("%s: %w: %v", requestSummary, response.Error, http.StatusText(resp.StatusCode)) - } return fmt.Errorf("%s: %v", requestSummary, http.StatusText(resp.StatusCode)) } return nil diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 5b52369..a5957bf 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -1751,3 +1751,147 @@ func TestStreamableClientConnect_DiscoverUnsupportedVersionNegotiation(t *testin t.Errorf("InitializeResult.ProtocolVersion = %q, want %q", got, protocolVersion20260728) } } + +// TestStreamableClientHandlerErrorPropagation verifies that per-call +// handler-level HTTP errors carrying a JSON-RPC error body do not tear down +// the session, and that setting MCPGODEBUG=noprotocolerrorbody=1 restores the +// pre-fix behavior in which non-transient errors permanently failed the +// connection. +func TestStreamableClientHandlerErrorPropagation(t *testing.T) { + ctx := context.Background() + + // Build a JSON-RPC error response body for a given tools/call request. + jsonRPCErrorBody := func(id int64, code int64, msg string) string { + return jsonBody(t, resp(id, nil, &jsonrpc.Error{Code: code, Message: msg})) + } + + tests := []struct { + name string + callStatus int // HTTP status returned for the tools/call + callBody string // response body for the tools/call ("" for empty; may be a JSON-RPC error) + disableBodyDecode bool // if true, sets noprotocolerrorbody="1" for the duration of the test + wantErrRejected bool // whether the returned error should match errors.Is(err, jsonrpc2.ErrRejected) + wantSessionAlive bool // whether a subsequent call on the same session should succeed + }{ + { + // SEP-2575 §"Missing Required Capabilities": server returns + // -32021 at HTTP 400. Session must survive. + name: "400 with JSON-RPC error body (default)", + callStatus: http.StatusBadRequest, + callBody: jsonRPCErrorBody(1, CodeMissingRequiredClientCapabilities, "missing capability"), + wantErrRejected: true, + wantSessionAlive: true, + }, + { + // Same server response, but the user opted out via + // MCPGODEBUG=noprotocolerrorbody=1. Pre-fix behavior: the + // session is torn down. + name: "400 with JSON-RPC error body (noprotocolerrorbody=1)", + callStatus: http.StatusBadRequest, + callBody: jsonRPCErrorBody(1, CodeMissingRequiredClientCapabilities, "missing capability"), + disableBodyDecode: true, + wantErrRejected: false, + wantSessionAlive: false, + }, + { + // Legacy server returns plain-text 400 with no JSON-RPC + // body: cannot be classified as a per-call rejection, so the + // session is still torn down. + name: "400 with plain-text body", + callStatus: http.StatusBadRequest, + callBody: "Bad Request", + wantErrRejected: false, + wantSessionAlive: false, + }, + { + // SEP-2575: server returns -32601 for an unimplemented + // method at HTTP 404. Must be treated as a per-call + // rejection, not a terminated session. + name: "404 with JSON-RPC MethodNotFound body", + callStatus: http.StatusNotFound, + callBody: jsonRPCErrorBody(1, jsonrpc.CodeMethodNotFound, `method not found: "tools/call"`), + wantErrRejected: true, + wantSessionAlive: true, + }, + { + // A bare 404 with no JSON-RPC body means the session has + // been terminated. Session must not survive. + name: "404 with empty body (terminated session)", + callStatus: http.StatusNotFound, + callBody: "", + wantErrRejected: false, + wantSessionAlive: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.disableBodyDecode { + prev := noprotocolerrorbody + noprotocolerrorbody = "1" + t.Cleanup(func() { noprotocolerrorbody = prev }) + } + + // Track how many tools/call requests we've served: first + // returns the error under test, subsequent calls succeed so + // we can observe whether the session is still alive. + var callsServed atomic.Int32 + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodDiscover, ""}: { + header: header{ + "Content-Type": "application/json", + }, + wantProtocolVersion: protocolVersion20260728, + responseFunc: func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, resp(r.ID.Raw().(int64), discoverResult, nil)), http.StatusOK + }, + }, + {"POST", "", "tools/call", ""}: { + header: header{"Content-Type": "application/json"}, + responseFunc: func(r *jsonrpc.Request) (string, int) { + if callsServed.Add(1) == 1 { + return test.callBody, test.callStatus + } + return jsonBody(t, resp(r.ID.Raw().(int64), &CallToolResult{}, nil)), 0 + }, + optional: true, + }, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer session.Close() + + // First call: triggers the error under test. + _, err = session.CallTool(ctx, &CallToolParams{Name: "nonexistent"}) + if err == nil { + t.Fatal("first CallTool succeeded unexpectedly, want error") + } + if got := errors.Is(err, jsonrpc2.ErrRejected); got != test.wantErrRejected { + t.Errorf("errors.Is(err, ErrRejected) = %v, want %v (err = %v)", got, test.wantErrRejected, err) + } + + // Second call: verifies whether the session survived. + _, err = session.CallTool(ctx, &CallToolParams{Name: "nonexistent"}) + if test.wantSessionAlive { + if err != nil { + t.Errorf("second CallTool failed: %v (session should survive per-call rejection)", err) + } + } else { + if err == nil { + t.Error("second CallTool succeeded unexpectedly, want session torn down") + } + } + }) + } +} From 725694100ae134a9ff699d5467e6215c9ec3efa6 Mon Sep 17 00:00:00 2001 From: Anton Dziatkovskii Date: Tue, 4 Aug 2026 14:39:05 +0200 Subject: [PATCH 363/418] docs: fix the streamable client snippet to use (*Client).Connect (#1142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What was broken `docs/protocol.md` → *Streamable Transport* → client side shows: ```go client, err := mcp.Connect(ctx, transport, &mcp.ClientOptions{...}) ``` There is no top-level `mcp.Connect`. The package has `(*Client).Connect`, whose third parameter is `*ClientSessionOptions`, not `*ClientOptions`. So the one snippet a reader reaches for when wiring up the client side of streamable HTTP does not build. It is also the only outlier in the docs: `quick_start.md`, `client.md`, `server.md`, `troubleshooting.md` and `protocol.md` itself (lines 470, 538) all already use `client.Connect(ctx, transport, nil)`. ### How I checked Pasted the snippet verbatim into a fresh module requiring the released SDK (`github.com/modelcontextprotocol/go-sdk v1.7.0`): ``` $ go build ./... ./broken.go:15:21: undefined: mcp.Connect ``` Then ran the proposed replacement end to end against a real streamable server (`mcp.NewStreamableHTTPHandler` behind `httptest.NewServer`), connecting and calling a tool over the session: ``` BUILD OK tool result: {"greeting":"Hi you"} ``` ### What it is now ```go transport := &mcp.StreamableClientTransport{ Endpoint: "http://localhost:8080/mcp", } client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v1.0.0"}, nil) session, err := client.Connect(ctx, transport, nil) ``` Change is in `internal/docs/protocol.src.md`; `docs/protocol.md` regenerated with `go generate ./internal/docs`, both committed together. Docs-only, no code paths touched. --- Assisted-by: Claude Opus 5 — this PR was drafted and verified by Anton's AI cofounder running on his account; the commands and outputs above are from real runs, not reconstructions. Co-authored-by: Anton Dzyatkovsky --- docs/protocol.md | 3 ++- internal/docs/protocol.src.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index 35bf93b..edc1c11 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -256,7 +256,8 @@ connect to the server: transport := &mcp.StreamableClientTransport{ Endpoint: "http://localhost:8080/mcp", } -client, err := mcp.Connect(ctx, transport, &mcp.ClientOptions{...}) +client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v1.0.0"}, nil) +session, err := client.Connect(ctx, transport, nil) ``` The `StreamableClientTransport` handles the HTTP requests and communicates with diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index dee819b..e26fcd7 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -181,7 +181,8 @@ connect to the server: transport := &mcp.StreamableClientTransport{ Endpoint: "http://localhost:8080/mcp", } -client, err := mcp.Connect(ctx, transport, &mcp.ClientOptions{...}) +client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v1.0.0"}, nil) +session, err := client.Connect(ctx, transport, nil) ``` The `StreamableClientTransport` handles the HTTP requests and communicates with From 6b1518cb0bbbcb4644397e8bbdac0fc34e451b85 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Wed, 5 Aug 2026 12:16:39 +0200 Subject: [PATCH 364/418] mcp: return json-rpc error on stateful server serving protocolVersion20260728 requests (#1143) Fixes #1130 --- docs/mcpgodebug.md | 16 ++++++ internal/docs/mcpgodebug.src.md | 16 ++++++ mcp/streamable.go | 41 +++++++++++++-- mcp/streamable_test.go | 90 +++++++++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 4 deletions(-) diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index 4562821..39a76a8 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -18,6 +18,22 @@ ## `MCPGODEBUG` history +### 1.8.0 + +Options listed below were added and will be removed in the 1.9.0 version of the SDK. + +- `plaintextstatefulrejection` added. If set to `1`, a stateful + `StreamableHTTPHandler` will respond with a plain-text `http.Error` 400 body + when it receives a request carrying per-request metadata (i.e. an + `io.modelcontextprotocol/protocolVersion` `_meta` field, or an + `MCP-Protocol-Version` header >= `2026-07-28`), restoring the previous + behavior. The default behavior was changed so that the server responds with a + JSON-RPC error of code `CodeUnsupportedProtocolVersion` (`-32022`) carrying + an `UnsupportedProtocolVersionData` payload that advertises the legacy + versions the server supports. This lets the client's + existing renegotiation logic recover and prevents the failure from tearing + down the underlying connection. + ### 1.7.0 Options listed below were added and will be removed in the 1.9.0 version of the SDK. diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index c009a7c..efb02da 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -17,6 +17,22 @@ ## `MCPGODEBUG` history +### 1.8.0 + +Options listed below were added and will be removed in the 1.9.0 version of the SDK. + +- `plaintextstatefulrejection` added. If set to `1`, a stateful + `StreamableHTTPHandler` will respond with a plain-text `http.Error` 400 body + when it receives a request carrying per-request metadata (i.e. an + `io.modelcontextprotocol/protocolVersion` `_meta` field, or an + `MCP-Protocol-Version` header >= `2026-07-28`), restoring the previous + behavior. The default behavior was changed so that the server responds with a + JSON-RPC error of code `CodeUnsupportedProtocolVersion` (`-32022`) carrying + an `UnsupportedProtocolVersionData` payload that advertises the legacy + versions the server supports. This lets the client's + existing renegotiation logic recover and prevents the failure from tearing + down the underlying connection. + ### 1.7.0 Options listed below were added and will be removed in the 1.9.0 version of the SDK. diff --git a/mcp/streamable.go b/mcp/streamable.go index 2799d14..db81a37 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -314,6 +314,18 @@ var noprotocolerrorbody = mcpgodebug.Value("noprotocolerrorbody") // The option will be removed in the 1.8.0 version of the SDK. var disablecontenttypecheck = mcpgodebug.Value("disablecontenttypecheck") +// plaintextstatefulrejection is a compatibility parameter that restores the +// previous behavior of a stateful [StreamableHTTPHandler] when it receives a +// request carrying SEP-2575 per-request metadata (i.e. an +// `io.modelcontextprotocol/protocolVersion` `_meta` field, or an +// `MCP-Protocol-Version` header >= 2026-07-28). When unset (the default), the +// server responds with a JSON-RPC error of code +// [CodeUnsupportedProtocolVersion] and an [UnsupportedProtocolVersionData] +// payload listing its supported legacy versions, as required by SEP-2575. When +// set to "1", the server instead responds with a plain-text `http.Error` body +// mentioning the `StreamableHTTPOptions.Stateless` field. +var plaintextstatefulrejection = mcpgodebug.Value("plaintextstatefulrejection") + // writeJSONRPCError writes a JSON-RPC error response with the given HTTP // status code, request ID (may be a zero ID for errors that occur before the // request body has been parsed), and JSON-RPC error. @@ -1519,10 +1531,31 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // rejection as it should learn about the supported protocols from the // DiscoverResult response. if !c.stateless && jreq.Method != methodDiscover { - http.Error(w, fmt.Sprintf( - "Bad Request: protocol version %q is only supported on stateless HTTP servers (set StreamableHTTPOptions.Stateless = true)", - protocolVersion), - http.StatusBadRequest) + if plaintextstatefulrejection == "1" { + http.Error(w, fmt.Sprintf( + "Bad Request: protocol version %q is only supported on stateless HTTP servers (set StreamableHTTPOptions.Stateless = true)", + protocolVersion), + http.StatusBadRequest) + return + } + // Advertise only the legacy versions this stateful server accepts + // (i.e. exclude 2026-07-28, since that's what the request asked for + // and what we're rejecting). + legacyVersions := slices.DeleteFunc(slices.Clone(supportedProtocolVersions), func(v string) bool { + return v >= protocolVersion20260728 + }) + data, _ := json.Marshal(UnsupportedProtocolVersionData{ + Supported: legacyVersions, + Requested: protocolVersion, + }) + c.logger.Warn(fmt.Sprintf( + "rejecting request with protocol version %q: this server is stateful; set StreamableHTTPOptions.Stateless = true to accept it", + protocolVersion)) + writeJSONRPCError(w, http.StatusBadRequest, jreq.ID, &jsonrpc.Error{ + Code: CodeUnsupportedProtocolVersion, + Message: fmt.Sprintf("protocol version %q is not supported by this server", protocolVersion), + Data: data, + }) return } if headerVersion == "" { diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 726733d..18d6b5f 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -3504,6 +3504,96 @@ func TestStreamableStateful_RejectsNewProtocol(t *testing.T) { if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400; body = %s", resp.StatusCode, respBody) } + msg, err := jsonrpc.DecodeMessage(respBody) + if err != nil { + t.Fatalf("decoding response body as JSON-RPC: %v; body = %s", err, respBody) + } + jresp, ok := msg.(*jsonrpc.Response) + if !ok || jresp.Error == nil { + t.Fatalf("response is not a JSON-RPC error: %s", respBody) + } + var jerr *jsonrpc.Error + if !errors.As(jresp.Error, &jerr) { + t.Fatalf("response error is not *jsonrpc.Error: %v", jresp.Error) + } + if jerr.Code != CodeUnsupportedProtocolVersion { + t.Errorf("error code = %d, want %d (CodeUnsupportedProtocolVersion)", jerr.Code, CodeUnsupportedProtocolVersion) + } + var data UnsupportedProtocolVersionData + if err := json.Unmarshal(jerr.Data, &data); err != nil { + t.Fatalf("decoding error data: %v; data = %s", err, jerr.Data) + } + if data.Requested != protocolVersion20260728 { + t.Errorf("data.Requested = %q, want %q", data.Requested, protocolVersion20260728) + } + if slices.Contains(data.Supported, protocolVersion20260728) { + t.Errorf("data.Supported = %v, must not contain the rejected version %q", + data.Supported, protocolVersion20260728) + } + if len(data.Supported) == 0 { + t.Errorf("data.Supported is empty; expected at least one legacy version") + } +} + +// TestStreamableStateful_RejectsNewProtocol_LegacyPlainText verifies that +// MCPGODEBUG=plaintextstatefulrejection=1 restores the pre-v1.8.0 plain-text +// 400 body for the stateful/new-protocol rejection. +func TestStreamableStateful_RejectsNewProtocol_LegacyPlainText(t *testing.T) { + prev := plaintextstatefulrejection + plaintextstatefulrejection = "1" + t.Cleanup(func() { plaintextstatefulrejection = prev }) + + 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 }, nil) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + initBody := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`) + initReq, err := http.NewRequest(http.MethodPost, httpServer.URL, initBody) + if err != nil { + t.Fatal(err) + } + initReq.Header.Set("Content-Type", "application/json") + initReq.Header.Set("Accept", "application/json, text/event-stream") + initResp, err := http.DefaultClient.Do(initReq) + if err != nil { + t.Fatal(err) + } + io.Copy(io.Discard, initResp.Body) + initResp.Body.Close() + sessionID := initResp.Header.Get(sessionIDHeader) + if sessionID == "" { + t.Fatalf("initialize response missing %s header", sessionIDHeader) + } + + body := newProtocolBody(t, "noop", struct{}{}) + 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(sessionIDHeader, sessionID) + req.Header.Set(protocolVersionHeader, protocolVersion20260728) + req.Header.Set(methodHeader, "tools/call") + req.Header.Set(nameHeader, "noop") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body = %s", resp.StatusCode, respBody) + } + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") { + t.Errorf("Content-Type = %q, want text/plain; body = %s", ct, respBody) + } if !strings.Contains(string(respBody), "stateless") { t.Errorf("body = %q, want a message mentioning 'stateless'", respBody) } From d462fbd303a16f3a8e65c78338a775e02b144fdf Mon Sep 17 00:00:00 2001 From: Suraj B Date: Wed, 5 Aug 2026 17:20:58 +0530 Subject: [PATCH 365/418] Disable scheduled nightly runs on forks (#1083) .github/workflows/nightly.yml: disable scheduled nightly runs on the forked repos User will still be able to run the workflow manually Fixes #1082 Co-authored-by: Guglielmo Colombo --- .github/workflows/nightly.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 226627f..d60b6ff 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -11,6 +11,8 @@ permissions: jobs: vulncheck: + # Don't run scheduled builds on forks. + if: github.event_name != 'schedule' || github.repository == 'modelcontextprotocol/go-sdk' runs-on: ubuntu-latest steps: - name: Run govulncheck @@ -20,6 +22,8 @@ jobs: go-package: ./... conformance: + # Don't run scheduled builds on forks. + if: github.event_name != 'schedule' || github.repository == 'modelcontextprotocol/go-sdk' runs-on: ubuntu-latest steps: - name: Check out code From 958dfcc532d56e3aa4a5215d69bae2941995a384 Mon Sep 17 00:00:00 2001 From: dev_Hakaze Date: Wed, 5 Aug 2026 20:36:22 +0700 Subject: [PATCH 366/418] mcp: limit SSE discover supportedVersions to SSE-defined revisions (#1121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `NewSSEHandler` sessions were advertising `2026-07-28` in `server/discover` `supportedVersions`. That revision only defines stdio and Streamable HTTP, not the deprecated HTTP+SSE binding, so Modern-first clients could negotiate a revision the transport cannot honour. ## Changes - Implement `ProtocolVersionSupporter` on `SSEServerTransport` so `filterSupportedVersions` excludes `>= 2026-07-28` (matching the streamable transport's version gate pattern). - In `Server.discover`, only set session `InitializeParams` when the requested version is actually supported by the transport. Without this, a discover probe that returns only legacy versions still marked the session initialized, and the client's fallback `initialize` failed with `duplicate "initialize" received`. - Tests: unit coverage for filtered SSE versions, plus an end-to-end Modern client → SSE fallback-to-initialize path. Fixes #1112 ## Test plan - [x] `go test ./...` - [x] `go test ./mcp/ -run 'TestSSE|TestInMemory_E2E_Discover|TestStreamableStateful_AcceptsDiscover|TestStreamableHTTP_E2E_Discover|TestClientConnectDiscover'` Assistance: human-reviewed AI assistance used for implementation. --------- Co-authored-by: arimu1 <19286898+arimu1@users.noreply.github.com> --- mcp/sse.go | 7 ++++++ mcp/sse_test.go | 64 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/mcp/sse.go b/mcp/sse.go index 7efaa95..fb32756 100644 --- a/mcp/sse.go +++ b/mcp/sse.go @@ -189,6 +189,13 @@ func (t *SSEServerTransport) Connect(context.Context) (Connection, error) { return &sseServerConn{t: t}, nil } +// SupportsProtocolVersion reports whether the HTTP+SSE transport can serve the +// given protocol version. MCP 2026-07-28 defines only the stdio and Streamable +// HTTP bindings, so this (deprecated) transport does not advertise that revision. +func (t *SSEServerTransport) SupportsProtocolVersion(version string) bool { + return version < protocolVersion20260728 +} + func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { // DNS rebinding protection: auto-enabled for localhost servers. // See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise diff --git a/mcp/sse_test.go b/mcp/sse_test.go index 86b2cf1..c0ff445 100644 --- a/mcp/sse_test.go +++ b/mcp/sse_test.go @@ -12,12 +12,76 @@ import ( "net" "net/http" "net/http/httptest" + "slices" "sync/atomic" "testing" "github.com/google/go-cmp/cmp" ) +// TestSSEServerTransport_SupportedVersions verifies that the deprecated +// HTTP+SSE transport does not advertise protocol revisions that only define +// stdio and Streamable HTTP (see #1112). +func TestSSEServerTransport_SupportedVersions(t *testing.T) { + var tr SSEServerTransport + versions := filterSupportedVersions(&tr) + if slices.Contains(versions, protocolVersion20260728) { + t.Errorf("filterSupportedVersions(SSEServerTransport) = %v, must not include %q", + versions, protocolVersion20260728) + } + if !slices.Contains(versions, protocolVersion20241105) { + t.Errorf("filterSupportedVersions(SSEServerTransport) = %v, want %q (SSE-defined revision)", + versions, protocolVersion20241105) + } + if len(versions) == 0 { + t.Error("filterSupportedVersions(SSEServerTransport) is empty; want legacy SSE revisions") + } + for _, v := range supportedProtocolVersions { + got := tr.SupportsProtocolVersion(v) + want := v < protocolVersion20260728 + if got != want { + t.Errorf("SupportsProtocolVersion(%q) = %v, want %v", v, got, want) + } + } +} + +// TestSSEHandler_DiscoverFallsBackToInitialize checks that a Modern-first +// client connecting over HTTP+SSE does not stick on 2026-07-28: discover +// advertises only SSE-defined revisions, and Connect falls back to legacy +// initialize successfully. +func TestSSEHandler_DiscoverFallsBackToInitialize(t *testing.T) { + ctx := context.Background() + server := NewServer(testImpl, nil) + sseHandler := NewSSEHandler(func(*http.Request) *Server { return server }, nil) + httpServer := httptest.NewServer(sseHandler) + defer httpServer.Close() + + client := NewClient(testImpl, nil) + // Default Client.Connect probes with the latest protocol version (2026-07-28). + cs, err := client.Connect(ctx, &SSEClientTransport{Endpoint: httpServer.URL}, nil) + if err != nil { + t.Fatalf("Connect over SSE with modern client: %v", err) + } + defer cs.Close() + + ir := cs.InitializeResult() + if ir == nil { + t.Fatal("InitializeResult is nil after Connect") + } + if ir.ProtocolVersion == protocolVersion20260728 { + t.Errorf("InitializeResult.ProtocolVersion = %q; HTTP+SSE must not negotiate that revision", + ir.ProtocolVersion) + } + if ir.ProtocolVersion != protocolVersion20251125 { + // Client.Connect falls back to protocolVersion20251125 for legacy initialize. + t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback)", + ir.ProtocolVersion, protocolVersion20251125) + } + if err := cs.Ping(ctx, nil); err != nil { + t.Errorf("Ping after SSE fallback initialize: %v", err) + } +} + func TestSSEServer(t *testing.T) { for _, closeServerFirst := range []bool{false, true} { t.Run(fmt.Sprintf("closeServerFirst=%t", closeServerFirst), func(t *testing.T) { From 71dfbc0d3ee201388d2a1db8428b7efe92ed64ca Mon Sep 17 00:00:00 2001 From: Anton Dziatkovskii Date: Thu, 6 Aug 2026 16:27:46 +0200 Subject: [PATCH 367/418] docs: quick start `go get` leaves the module unbuildable (#1148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What was broken `docs/quick_start.md` → *Installation* says: ``` go get github.com/modelcontextprotocol/go-sdk ``` The module root holds no importable package (only `copyright_test.go`), so `go get` on the module path records the requirement as `// indirect` and writes none of the `go.sum` entries the `mcp` package needs. A reader who runs that command and then pastes the server snippet from the next section hits six errors on the first build — before writing a line of their own. ### How I checked Followed the page verbatim on Go 1.26.4, in an empty directory: ``` $ go mod init example.com/qs $ go get github.com/modelcontextprotocol/go-sdk go: added github.com/modelcontextprotocol/go-sdk v1.7.0 ``` `go.mod` now reads `require github.com/modelcontextprotocol/go-sdk v1.7.0 // indirect`. Then the two snippets from *Getting started*, saved as `server/main.go` and `client/main.go`: ``` $ go build ./... missing go.sum entry for module providing package github.com/google/jsonschema-go/jsonschema (imported by github.com/modelcontextprotocol/go-sdk/mcp) missing go.sum entry for module providing package golang.org/x/oauth2 (imported by github.com/modelcontextprotocol/go-sdk/mcp) missing go.sum entry for module providing package github.com/segmentio/encoding/json (imported by github.com/modelcontextprotocol/go-sdk/internal/json) missing go.sum entry for module providing package github.com/yosida95/uritemplate/v3 (imported by github.com/modelcontextprotocol/go-sdk/mcp) missing go.sum entry for module providing package golang.org/x/sync/errgroup (imported by github.com/modelcontextprotocol/go-sdk/mcp) missing go.sum entry for module providing package golang.org/x/time/rate (imported by github.com/modelcontextprotocol/go-sdk/mcp) ``` Same directory, same snippets, asking for the package instead: ``` $ go get github.com/modelcontextprotocol/go-sdk/mcp $ go build -o myserver ./server && go build -o myclient ./client $ ./myclient {"greeting":"Hi you"} ``` `go mod tidy` after the fact fixes it too — that is the escape hatch the note mentions, but it is not what the page tells you to do. ### What it is now ``` go get github.com/modelcontextprotocol/go-sdk/mcp ``` plus four lines saying why the module path alone is not enough, so the next person who copies the module path from `go.mod` or from a search result knows what they are looking at. Change is in `internal/docs/quick_start.src.md`; `docs/quick_start.md` regenerated with `go generate ./internal/docs`, both committed together. Docs-only, no code paths touched. --- Assisted-by: Claude Opus 5 — this PR was drafted and verified by Anton's AI cofounder running on his account; every command and output above is from a real run on this machine, not a reconstruction. --------- Co-authored-by: Palo-Alto-AI-Research-Lab Co-authored-by: Claude Fable 5 --- docs/quick_start.md | 2 +- internal/docs/quick_start.src.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/quick_start.md b/docs/quick_start.md index c76c58c..995b378 100644 --- a/docs/quick_start.md +++ b/docs/quick_start.md @@ -7,7 +7,7 @@ ## Installation ``` -go get github.com/modelcontextprotocol/go-sdk +go get github.com/modelcontextprotocol/go-sdk/mcp ``` ## Getting started diff --git a/internal/docs/quick_start.src.md b/internal/docs/quick_start.src.md index 7b1849f..c0f1546 100644 --- a/internal/docs/quick_start.src.md +++ b/internal/docs/quick_start.src.md @@ -5,7 +5,7 @@ ## Installation ``` -go get github.com/modelcontextprotocol/go-sdk +go get github.com/modelcontextprotocol/go-sdk/mcp ``` ## Getting started From 039119057f1bdaa803a9ae4a252f3f280ed4a127 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Fri, 7 Aug 2026 10:43:17 +0200 Subject: [PATCH 368/418] mcp: allow asynchronous cancel notification (#1151) Fixes #1150 --- docs/mcpgodebug.md | 10 +++++ internal/docs/mcpgodebug.src.md | 10 +++++ mcp/streamable_test.go | 73 +++++++++++++++++++++++++++++++++ mcp/transport.go | 32 ++++++++++++++- 4 files changed, 123 insertions(+), 2 deletions(-) diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index 39a76a8..d25b9b4 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -34,6 +34,16 @@ Options listed below were added and will be removed in the 1.9.0 version of the existing renegotiation logic recover and prevents the failure from tearing down the underlying connection. +- `blockingcancelnotify` added. If set to `1`, a cancelled call waits + synchronously for the best-effort `notifications/cancelled` message to be + delivered (up to `notifyCancellationTimeout`, currently 5 s) before + returning to the caller, restoring the previous behavior. The delivery + error is joined into the caller's returned error. The default behavior was + changed so that the call is retired immediately and the notification is + sent asynchronously off the caller's return path: the caller returns as + soon as its context is cancelled and cannot be delayed by a slow or + unresponsive peer. See issue #1150. + ### 1.7.0 Options listed below were added and will be removed in the 1.9.0 version of the SDK. diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index efb02da..32f8af2 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -33,6 +33,16 @@ Options listed below were added and will be removed in the 1.9.0 version of the existing renegotiation logic recover and prevents the failure from tearing down the underlying connection. +- `blockingcancelnotify` added. If set to `1`, a cancelled call waits + synchronously for the best-effort `notifications/cancelled` message to be + delivered (up to `notifyCancellationTimeout`, currently 5 s) before + returning to the caller, restoring the previous behavior. The delivery + error is joined into the caller's returned error. The default behavior was + changed so that the call is retired immediately and the notification is + sent asynchronously off the caller's return path: the caller returns as + soon as its context is cancelled and cannot be delayed by a slow or + unresponsive peer. See issue #1150. + ### 1.7.0 Options listed below were added and will be removed in the 1.9.0 version of the SDK. diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 18d6b5f..504cda6 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -3599,6 +3599,79 @@ func TestStreamableStateful_RejectsNewProtocol_LegacyPlainText(t *testing.T) { } } +// TestCallCancellation_FastReturn verifies that a cancelled tool call +// returns as soon as its context is cancelled, even when the peer is slow +// to accept the follow-up notifications/cancelled POST. +func TestCallCancellation_FastReturn(t *testing.T) { + // stallDuration is the time the middleware holds a + // notifications/cancelled POST. It must be larger than + // notifyCancellationTimeout so the blocking branch clearly overshoots + // even after cushioning for scheduler jitter. + const stallDuration = notifyCancellationTimeout + 2*time.Second + + // callerDeadline is the deadline the client passes into CallTool. It + // must be short enough that any waiting on the cancel notification is + // obvious in the measured elapsed time. + const callerDeadline = 100 * time.Millisecond + + // slack accounts for scheduling jitter around the deadline. + const slack = 500 * time.Millisecond + + stallCancelNotify := func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.Body != nil { + body, _ := io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewReader(body)) + if bytes.Contains(body, []byte(notificationCancelled)) { + time.Sleep(stallDuration) + } + } + h.ServeHTTP(w, r) + }) + } + + server := NewServer(testImpl, nil) + AddTool(server, &Tool{Name: "slow"}, + func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) { + // Bound the handler above the caller deadline so the test + // always exercises the cancellation path, but not so long + // that a stuck cancellation makes the test slow. + select { + case <-ctx.Done(): + case <-time.After(2 * stallDuration): + } + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + }) + + handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil) + httpServer := httptest.NewServer(stallCancelNotify(handler)) + t.Cleanup(httpServer.Close) + + client := NewClient(testImpl, nil) + cs, err := client.Connect(context.Background(), + &StreamableClientTransport{Endpoint: httpServer.URL}, nil) + if err != nil { + t.Fatalf("client connect: %v", err) + } + t.Cleanup(func() { cs.Close() }) + + callCtx, cancel := context.WithTimeout(context.Background(), callerDeadline) + defer cancel() + + start := time.Now() + _, err = cs.CallTool(callCtx, &CallToolParams{Name: "slow"}) + elapsed := time.Since(start) + + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("CallTool error = %v, want context.DeadlineExceeded", err) + } + // The caller should return within slack of its own deadline. + if elapsed > callerDeadline+slack { + t.Errorf("CallTool returned after %v; want <= %v (caller deadline %v + slack %v)", + elapsed, callerDeadline+slack, callerDeadline, slack) + } +} + // TestStreamableStateless_AcceptsNewProtocol is the positive control: // confirms that a stateless server still accepts new-protocol requests // (the rejection in TestStreamableStateful_RejectsNewProtocol must not diff --git a/mcp/transport.go b/mcp/transport.go index 55c73d7..d72f9d1 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -18,6 +18,7 @@ import ( internaljson "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" + "github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug" "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) @@ -28,6 +29,13 @@ import ( // re-trigger expensive recovery on its behalf. See issue #882. const notifyCancellationTimeout = 5 * time.Second +// blockingcancelnotify, when set to "1" via MCPGODEBUG, restores the previous +// behavior of blocking the caller's return on delivery of the best-effort +// notifications/cancelled message (up to notifyCancellationTimeout). By +// default, the call is retired immediately and the notification is sent +// asynchronously so the caller returns as soon as its context is cancelled. +var blockingcancelnotify = mcpgodebug.Value("blockingcancelnotify") + // ErrConnectionClosed is returned when sending a message to a connection that // is closed or in the process of closing. var ErrConnectionClosed = errors.New("connection closed") @@ -276,8 +284,28 @@ func call(ctx context.Context, conn *jsonrpc2.Connection, method string, params case errors.Is(err, jsonrpc2.ErrClientClosing), errors.Is(err, jsonrpc2.ErrServerClosing): return fmt.Errorf("%w: calling %q: %v", ErrConnectionClosed, method, err) case ctx.Err() != nil: - err := cancelCall(ctx, conn, call) - return errors.Join(ctx.Err(), err) + // The notifications/cancelled message is best-effort. Retire the call + // immediately (so an unresponsive peer cannot delay the eager + // retirement that cancelCall's docstring promises) and send the + // notification off the caller's return path so a slow or unresponsive + // peer cannot delay the caller past its own deadline. See issue #1150. + // + // Setting MCPGODEBUG=blockingcancelnotify=1 restores the previous + // behavior of waiting synchronously for delivery inside cancelCall. + if blockingcancelnotify == "1" { + err := cancelCall(ctx, conn, call) + return errors.Join(ctx.Err(), err) + } + conn.Retire(call, ctx.Err()) + go func() { + notifyCtx, stop := context.WithTimeout(context.WithoutCancel(ctx), notifyCancellationTimeout) + defer stop() + _ = conn.Notify(notifyCtx, notificationCancelled, &CancelledParams{ + Reason: ctx.Err().Error(), + RequestID: call.ID().Raw(), + }) + }() + return ctx.Err() case err != nil: return fmt.Errorf("calling %q: %w", method, err) } From e9a85e285f42799bf7e85ff0e7654788bafd272c Mon Sep 17 00:00:00 2001 From: Recoordinate <296084221+latent-9@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:10:06 +1200 Subject: [PATCH 369/418] mcp: fix typo in protocol version comment (#1156) `protcol` should be `protocol`. The comment sits above the `Mcp-Protocol-Version` header lookup, and the surrounding code uses `protocolVersion` and `protocolVersionFromContext`, so this is the only place in the package where the word is misspelled. --- mcp/streamable.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index db81a37..b2190fa 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -1524,7 +1524,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques metaVersion, _ = meta[MetaKeyProtocolVersion].(string) } if protocolVersion >= protocolVersion20260728 || metaVersion != "" { - // Extract again the protcol 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. headerVersion := protocolVersionFromContext(req.Context()) // server/discover is exempt from the stateful From 4282916668c3bb682eee20f8bf164158379a17c0 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Mon, 10 Aug 2026 10:52:50 +0200 Subject: [PATCH 370/418] mcp: avoid Client.Connect leaks (#1158) Fixes #1154 --- mcp/client.go | 1 + 1 file changed, 1 insertion(+) diff --git a/mcp/client.go b/mcp/client.go index 2ad0ea4..7403799 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -389,6 +389,7 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp return nil, err } if !slices.Contains(supportedProtocolVersions, res.ProtocolVersion) { + _ = cs.Close() return nil, unsupportedProtocolVersionError{res.ProtocolVersion} } cs.state.InitializeResult = res From 64e454e35c23c473e1fcf1e1c3a6f623260ca773 Mon Sep 17 00:00:00 2001 From: "Tony Dzi (Anton Dziatkovskii)" Date: Thu, 13 Aug 2026 10:49:56 +0200 Subject: [PATCH 371/418] docs: fix four dead links to the Multi Round-Trip Requests section (#1159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What was broken Four links in `docs/client.md` point at a section that does not exist. Three of them sit right where a reader needs them — at the end of the **Roots**, **Sampling** and **Elicitation** sections, on the sentence explaining that from protocol version `2026-07-28` these requests are delivered via the Multi Round-Trip Requests pattern: ``` [Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr) ``` `protocol.md` has no MRTR section. Following the link drops you at the top of a page that never mentions the pattern — while the explanation you were promised is one screen further down the *same* page you were already on (`client.md` → *Multi Round-Trip Requests*). The fourth one is in that MRTR section itself, sending you to the server side: ``` [server-side documentation](server.md#multi-round-trip-requests-mrtr). ``` `server.md` does have the section, but its heading is `## Multi Round-Trip Requests`, so the slug is `#multi-round-trip-requests` — the trailing `-mrtr` is stale. ### How I checked Against the published site, not just the source: ``` $ curl -sL https://go.sdk.modelcontextprotocol.io/protocol | grep -o 'id="[^"]*round[^"]*"' # (no output) $ curl -sL https://go.sdk.modelcontextprotocol.io/client | grep -o 'id="[^"]*round[^"]*"' id="multi-round-trip-requests" $ curl -sL https://go.sdk.modelcontextprotocol.io/server | grep -o 'id="[^"]*round[^"]*"' id="multi-round-trip-requests" ``` So `/protocol` carries no anchor containing `round` at all, and both live targets use the plain slug. `git log -S` says the links arrived with b9e6fc5 ("mcp: add documentation for new protocol version", #1019, 2026-06-23) and that `protocol.src.md` has never contained a `Multi Round-Trip Requests` heading — the section landed in `client.src.md` and `server.src.md` instead, and these four references were never repointed. I also ran a small link/anchor checker over `docs/`: 4 bad anchors before, 0 after. (Two "broken links" it also reports in `server.md` are its own false positives — Go generics inside a fence, `jsonschema.For[WeatherInput](opts)`, read as markdown link syntax. Not touched.) ### What it is now The three per-feature links become same-page references, matching how `client.md` already links its own sections (`(see [Capabilities](#capabilities))`): ``` [Multi Round-Trip Requests](#multi-round-trip-requests) ``` and the cross-page one drops the stale suffix: ``` [server-side documentation](server.md#multi-round-trip-requests). ``` Change is in `internal/docs/client.src.md`; `docs/client.md` regenerated with `go generate ./...`, both committed together. Locally `go generate ./...` leaves the tree clean afterwards, which is what `docs-check` asserts. Docs-only, four link targets, no prose and no code paths touched. --- Assisted-by: Claude Opus 5 — this PR was drafted and verified by Anton's AI cofounder running on his account; every command and output above is from a real run on this machine, not a reconstruction. Co-authored-by: tonydzi --- docs/client.md | 8 ++++---- internal/docs/client.src.md | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/client.md b/docs/client.md index 13169d6..fe9a963 100644 --- a/docs/client.md +++ b/docs/client.md @@ -38,7 +38,7 @@ method. To receive notifications about root changes, set [`ServerOptions.RootsListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.RootsListChangedHandler). For protocol versions `2026-07-28` and later, `ListRoots` requests are delivered via the -[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr) +[Multi Round-Trip Requests](#multi-round-trip-requests) pattern. ```go @@ -113,7 +113,7 @@ This function is invoked whenever the server requests sampling. For protocol versions `2026-07-28` and later, sampling requests are delivered via the -[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr) +[Multi Round-Trip Requests](#multi-round-trip-requests) pattern. ```go @@ -183,7 +183,7 @@ you must declare that capability explicitly (see [Capabilities](#capabilities)) For protocol versions `2026-07-28` and later, elicitation requests are delivered via the -[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr) +[Multi Round-Trip Requests](#multi-round-trip-requests) pattern. ```go @@ -268,7 +268,7 @@ For legacy (`<= 2025-11-25`) servers, the SDK transparently sends server requests on the legacy server-initiated channel; the MRTR machinery is a no-op in that direction. For legacy clients talking to MRTR-style servers, the server SDK applies the inverse compatibility shim — see the -[server-side documentation](server.md#multi-round-trip-requests-mrtr). +[server-side documentation](server.md#multi-round-trip-requests). ## Capabilities diff --git a/internal/docs/client.src.md b/internal/docs/client.src.md index 6d14ee9..5c4b8f9 100644 --- a/internal/docs/client.src.md +++ b/internal/docs/client.src.md @@ -30,7 +30,7 @@ method. To receive notifications about root changes, set [`ServerOptions.RootsListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.RootsListChangedHandler). For protocol versions `2026-07-28` and later, `ListRoots` requests are delivered via the -[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr) +[Multi Round-Trip Requests](#multi-round-trip-requests) pattern. %include ../../mcp/client_example_test.go roots - @@ -57,7 +57,7 @@ This function is invoked whenever the server requests sampling. For protocol versions `2026-07-28` and later, sampling requests are delivered via the -[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr) +[Multi Round-Trip Requests](#multi-round-trip-requests) pattern. %include ../../mcp/client_example_test.go sampling - @@ -79,7 +79,7 @@ you must declare that capability explicitly (see [Capabilities](#capabilities)) For protocol versions `2026-07-28` and later, elicitation requests are delivered via the -[Multi Round-Trip Requests](protocol.md#multi-round-trip-requests-mrtr) +[Multi Round-Trip Requests](#multi-round-trip-requests) pattern. %include ../../mcp/client_example_test.go elicitation - @@ -117,7 +117,7 @@ For legacy (`<= 2025-11-25`) servers, the SDK transparently sends server requests on the legacy server-initiated channel; the MRTR machinery is a no-op in that direction. For legacy clients talking to MRTR-style servers, the server SDK applies the inverse compatibility shim — see the -[server-side documentation](server.md#multi-round-trip-requests-mrtr). +[server-side documentation](server.md#multi-round-trip-requests). ## Capabilities From 0eb1600eef060d0ef75e8df648b568ad4f358323 Mon Sep 17 00:00:00 2001 From: Recoordinate <296084221+latent-9@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:44:53 +1200 Subject: [PATCH 372/418] mcp: avoid panicking when a resource handler returns nil (#1176) ### What `readResource` dereferenced the handler's result before the `if res == nil` check that was meant to guard it. A `ResourceHandler` returning `(nil, nil)` therefore panicked instead of returning an error: - modern clients: `handleMultiRoundTripResult` calls `res.setResultType` on the nil result, and - older clients: `res.setDefaultCacheableValues()` runs on the nil result. Either way the `if res == nil` guard a few lines below was dead code. ### Why it matters Request handlers run in their own goroutine and nothing recovers, so the panic does not just fail the one request. It terminates the whole server process and every concurrent session. The sibling handlers already avoid this: `callTool` and `getPrompt` both guard their result with `if err == nil && res != nil` before touching it, so a `(nil, nil)` return flows up and becomes a normal error response. ### Change Move the nil check in `readResource` ahead of `handleMultiRoundTripResult` and `setDefaultCacheableValues`, so a nil result returns the existing descriptive error (`read handler returned nil information`) instead of panicking. Added a regression test that drives a resource handler returning `(nil, nil)` through the server and asserts a clean error. --- mcp/error_test.go | 26 ++++++++++++++++++++++++++ mcp/server.go | 5 ++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/mcp/error_test.go b/mcp/error_test.go index 2b68d75..898562c 100644 --- a/mcp/error_test.go +++ b/mcp/error_test.go @@ -127,6 +127,32 @@ func TestResourceNotFoundErrorCode(t *testing.T) { } } +// TestReadResourceNilResult verifies that a resource handler returning +// (nil, nil) yields a clean error to the client instead of a nil pointer +// panic that crashes the server. The sibling handlers callTool and getPrompt +// already tolerate a (nil, nil) return; readResource must do the same. +func TestReadResourceNilResult(t *testing.T) { + ctx := context.Background() + + cs, _, cleanup := basicConnection(t, func(s *Server) { + s.AddResource( + &Resource{URI: "file:///nil.txt", Name: "nil", MIMEType: "text/plain"}, + func(ctx context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) { + return nil, nil + }, + ) + }) + defer cleanup() + + _, err := cs.ReadResource(ctx, &ReadResourceParams{URI: "file:///nil.txt"}) + if err == nil { + t.Fatal("got nil error, want non-nil error for nil resource result") + } + if !strings.Contains(err.Error(), "nil information") { + t.Errorf("got error %q, want it to mention 'nil information'", err.Error()) + } +} + // TestInputValidationToolError validates that input validation errors (missing // required params, wrong types) are returned as tool results with IsError=true, // not as JSON-RPC errors. This allows LLMs to see the error and self-correct. diff --git a/mcp/server.go b/mcp/server.go index c189a8e..c6d1270 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1035,6 +1035,9 @@ func (s *Server) readResource(ctx context.Context, req *ReadResourceRequest) (*R if err != nil { return nil, err } + if res == nil { + return nil, fmt.Errorf("reading resource %s: read handler returned nil information", uri) + } if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil { return nil, err } @@ -1042,7 +1045,7 @@ func (s *Server) readResource(ctx context.Context, req *ReadResourceRequest) (*R if res.resultType == resultTypeInputRequired { return res, nil } - if res == nil || res.Contents == nil { + if res.Contents == nil { return nil, fmt.Errorf("reading resource %s: read handler returned nil information", uri) } // As a convenience, populate some fields. From ba1496a9aaddd2d9def43c139de30ffa77223352 Mon Sep 17 00:00:00 2001 From: King Star Date: Mon, 17 Aug 2026 20:00:41 +0800 Subject: [PATCH 373/418] mcp: keep request log levels scoped to each request (#1168) Fixes a request-scoping bug in the new-protocol logging path. `ServerSession.handle` decoded `_meta[io.modelcontextprotocol/logLevel]`, but stored it in `ServerSessionState.LogLevel`. Concurrent new-protocol requests on the same session could therefore clear or inherit another request's log threshold before the original handler emitted logs. This keeps the new-protocol log level on the handler context instead. `ServerSession.Log` and `LoggingHandler.Enabled` read that context value first, while legacy `logging/setLevel` behavior still uses session state. Tests: - `go test ./mcp -run TestServerLogLevelDoesNotLeakBetweenNewProtocolRequests -count=1` - `go test -race ./mcp -run TestServerLogLevelDoesNotLeakBetweenNewProtocolRequests -count=1` - `go test ./mcp -run TestEndToEnd -count=1` - `go test ./mcp -run TestValidateRequestMeta -count=1` - `go test ./mcp -run TestStreamableStateless_NewProtocolSession_NoFakeInit -count=1` - `go test ./... -skip TestSSELocalhostProtection|TestStreamableLocalhostProtection` A full `go test ./...` run in my local environment still fails the two localhost protection cases for `0.0.0.0 via localhost rejects evil.com`; those same two tests also fail when run directly and are unrelated to this logging change. Co-authored-by: Guglielmo Colombo --- mcp/logging.go | 10 +++++++ mcp/mcp_test.go | 76 +++++++++++++++++++++++++++++++++++++++++++++++++ mcp/server.go | 12 ++++---- 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/mcp/logging.go b/mcp/logging.go index 402e243..e8d0cd7 100644 --- a/mcp/logging.go +++ b/mcp/logging.go @@ -68,6 +68,13 @@ func compareLevels(l1, l2 LoggingLevel) int { return cmp.Compare(mcpLevelToSlog(l1), mcpLevelToSlog(l2)) } +type logLevelContextKey struct{} + +func logLevelFromContext(ctx context.Context) (LoggingLevel, bool) { + v, ok := ctx.Value(logLevelContextKey{}).(LoggingLevel) + return v, ok +} + // LoggingHandlerOptions are options for a LoggingHandler. // // Deprecated: the logging feature is deprecated as of protocol version @@ -146,6 +153,9 @@ func NewLoggingHandler(ss *ServerSession, opts *LoggingHandlerOptions) *LoggingH func (h *LoggingHandler) Enabled(ctx context.Context, level slog.Level) bool { // This is also checked in ServerSession.LoggingMessage, so checking it here // is just an optimization that skips building the JSON. + if mcpLevel, ok := logLevelFromContext(ctx); ok { + return mcpLevel != "" && level >= mcpLevelToSlog(mcpLevel) + } h.ss.mu.Lock() mcpLevel := h.ss.state.LogLevel h.ss.mu.Unlock() diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index d9d9b3a..ade30de 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -3439,3 +3439,79 @@ func TestCallCustomMethodTypedNilParams(t *testing.T) { t.Fatalf("CallCustomMethod with typed-nil params: %v", err) } } + +func TestServerLogLevelDoesNotLeakBetweenNewProtocolRequests(t *testing.T) { + ctx := context.Background() + s := NewServer(testImpl, nil) + _, st := NewInMemoryTransports() + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ss.Close() }) + + logged := make(chan LoggingLevel, 1) + s.AddSendingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == notificationLoggingMessage { + logged <- req.GetParams().(*LoggingMessageParams).Level + return nil, nil + } + return next(ctx, method, req) + } + }) + + started := make(chan struct{}) + release := make(chan struct{}) + AddTool(s, &Tool{Name: "blocked-log"}, func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { + close(started) + <-release + if err := req.Session.Log(ctx, &LoggingMessageParams{Level: "warning", Data: "request log"}); err != nil { + return nil, nil, err + } + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + }) + AddTool(s, &Tool{Name: "noop"}, func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + }) + + withLogLevel := &CallToolParams{Name: "blocked-log"} + withLogLevel.SetMeta(newProtocolMeta("warning")) + errc := make(chan error, 1) + go func() { + _, err := ss.handle(ctx, req(1, methodCallTool, withLogLevel)) + errc <- err + }() + + <-started + withoutLogLevel := &CallToolParams{Name: "noop"} + withoutLogLevel.SetMeta(newProtocolMeta("")) + if _, err := ss.handle(ctx, req(2, methodCallTool, withoutLogLevel)); err != nil { + t.Fatal(err) + } + close(release) + if err := <-errc; err != nil { + t.Fatal(err) + } + + select { + case got := <-logged: + if got != "warning" { + t.Fatalf("logged level = %q, want warning", got) + } + default: + t.Fatal("request-scoped warning log was suppressed after another request cleared session log level") + } +} + +func newProtocolMeta(logLevel LoggingLevel) Meta { + m := Meta{ + MetaKeyProtocolVersion: protocolVersion20260728, + MetaKeyClientInfo: testImpl, + MetaKeyClientCapabilities: (&ClientCapabilities{}).toV2(), + } + if logLevel != "" { + m[MetaKeyLogLevel] = logLevel + } + return m +} diff --git a/mcp/server.go b/mcp/server.go index c6d1270..4cdd6b9 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1739,9 +1739,12 @@ func (ss *ServerSession) Elicit(ctx context.Context, params *ElicitParams) (*Eli // (at least twelve months). See // https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging. func (ss *ServerSession) Log(ctx context.Context, params *LoggingMessageParams) error { - ss.mu.Lock() - logLevel := ss.state.LogLevel - ss.mu.Unlock() + logLevel, ok := logLevelFromContext(ctx) + if !ok { + ss.mu.Lock() + logLevel = ss.state.LogLevel + ss.mu.Unlock() + } if logLevel == "" { // The spec is unclear, but seems to imply that no log messages are sent until the client // sets the level. @@ -1929,9 +1932,8 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, // server->client calls and notifications to the incoming request from which // they originated. See [idContextKey] for details. ctx = context.WithValue(ctx, idContextKey{}, req.ID) - // For new-protocol requests, propagate the per-request log level. if validatedMeta.usesNewProtocol { - ss.setLevel(ctx, &SetLoggingLevelParams{Level: validatedMeta.logLevel}) + ctx = context.WithValue(ctx, logLevelContextKey{}, validatedMeta.logLevel) } res, err := handleReceive(ctx, ss, req) if err != nil { From 1fcaf0c39375a74950f06f41f0d91c81d778df3e Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Mon, 17 Aug 2026 14:21:13 +0200 Subject: [PATCH 374/418] mcp: resolve deadlock on serverSession.Close() with subscriptionsListen call (#1166) Fixes #1160 --- mcp/mcp_test.go | 59 +++++++++++++++++++++++++++++++++++++++++++++++++ mcp/server.go | 29 ++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index ade30de..8e2f06d 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -3298,6 +3298,65 @@ func TestSubscriptionsListen_DisconnectScrubsMaps(t *testing.T) { } } +// TestServerSessionCloseWithActiveListen is a regression test for +// modelcontextprotocol/go-sdk#1160: ServerSession.Close must not deadlock +// when the client has an active subscriptions/listen stream. Previously, +// the server-side handler parked on ctx.Done and Close waited forever for +// the in-flight request to drain. +// +// The client's auto-listen (triggered by registering a list-changed handler) +// opens the stream on Connect — no explicit Subscribe is needed. The server +// must expose the corresponding list-changed capability, which happens +// automatically when at least one tool/prompt/resource is registered. +func TestServerSessionCloseWithActiveListen(t *testing.T) { + ctx := context.Background() + s := NewServer(&Implementation{Name: "s", Version: "0"}, nil) + AddTool(s, &Tool{Name: "t"}, sayHi) + + ct, st := NewInMemoryTransports() + if _, err := s.Connect(ctx, st, nil); err != nil { + t.Fatalf("server connect: %v", err) + } + c := NewClient(&Implementation{Name: "c", Version: "0"}, &ClientOptions{ + ToolListChangedHandler: func(context.Context, *ToolListChangedRequest) {}, + }) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer cs.Close() + + // Give the auto-listen request time to reach the server-side handler. + time.Sleep(20 * time.Millisecond) + + var ss *ServerSession + for x := range s.Sessions() { + ss = x + break + } + if ss == nil { + t.Fatal("no server session found") + } + + // Sanity check: the auto-listen must actually have registered an entry in + // listenIDs, otherwise the test below would trivially pass without + // exercising the fix. + ss.mu.Lock() + n := len(ss.listenIDs) + ss.mu.Unlock() + if n == 0 { + t.Fatal("expected auto-listen to register a request ID on the server session") + } + + done := make(chan error, 1) + go func() { done <- ss.Close() }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("ServerSession.Close deadlocked with an active subscriptions/listen") + } +} + func TestCustomMethods(t *testing.T) { type searchParams struct { ParamsBase diff --git a/mcp/server.go b/mcp/server.go index 4cdd6b9..13172a5 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1500,6 +1500,12 @@ type ServerSession struct { mu sync.Mutex state ServerSessionState + // listenIDs holds the request IDs of in-flight subscriptions/listen + // handlers on this session. subscriptions/listen parks on ctx.Done until + // cancelled, so ServerSession.Close must cancel these contexts explicitly + // via jsonrpc2.Connection.Cancel to avoid deadlocking on the jsonrpc2 + // drain. See modelcontextprotocol/go-sdk#1160. + listenIDs []jsonrpc.ID } func (ss *ServerSession) updateState(mut func(*ServerSessionState)) { @@ -1935,6 +1941,17 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, if validatedMeta.usesNewProtocol { ctx = context.WithValue(ctx, logLevelContextKey{}, validatedMeta.logLevel) } + + // subscriptions/listen parks on ctx.Done until the peer cancels it (or the + // underlying reader breaks). Track the request ID so ServerSession.Close + // can cancel the in-flight handler via jsonrpc2.Connection.Cancel and + // avoid deadlocking on the jsonrpc2 drain. + if req.Method == methodSubscriptionsListen { + ss.mu.Lock() + ss.listenIDs = append(ss.listenIDs, req.ID) + ss.mu.Unlock() + } + res, err := handleReceive(ctx, ss, req) if err != nil { return nil, err @@ -2038,6 +2055,18 @@ func (ss *ServerSession) Close() error { // Close is idempotent and conn.Close() handles concurrent calls correctly ss.keepaliveCancel() } + + // Unblock any in-flight subscriptions/listen handlers, which otherwise park + // on ctx.Done and would deadlock conn.Close (which waits for in-flight + // requests to drain). + ss.mu.Lock() + ids := ss.listenIDs + ss.listenIDs = nil + ss.mu.Unlock() + for _, id := range ids { + ss.conn.Cancel(id) + } + err := ss.conn.Close() if ss.onClose != nil && ss.calledOnClose.CompareAndSwap(false, true) { From b0408f2821d7a686b6cccff52c81539a4315b229 Mon Sep 17 00:00:00 2001 From: gwyszynski-sa Date: Tue, 18 Aug 2026 09:42:58 +0200 Subject: [PATCH 375/418] mcp: prefer the negotiated protocol version over the request context (#1163) setMCPHeaders resolved Mcp-Protocol-Version from the request context before c.initializedResult. #1107 established the opposite order and said so in its description; the code it replaced also consulted the context last. The two branches were transposed by the switch-to-if rewrite in #1115, whose subject was an unrelated nil-pointer check. The context branch serves the pre-negotiation server/discover probe, which sets its own value in Client.Connect. Once a version has been negotiated it is the authoritative one for that connection, so initializedResult should win. _meta.protocolVersion from the outgoing message stays on top, so #1109 stays fixed. The existing table built its request with http.NewRequest, so protocolVersionFromContext returned "" in every case and the two orderings were indistinguishable, which is why the transposition went unnoticed. Adds a ctxVersion dimension and three cases pinning the precedence. Fixes #1162 Co-authored-by: Guglielmo Colombo --- mcp/streamable.go | 4 ++-- mcp/streamable_client_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index b2190fa..b761f9c 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -2446,10 +2446,10 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request, msg jsonrpc.Mess } if pv := protocolVersionFromMessage(msg); pv != "" { req.Header.Set(protocolVersionHeader, pv) - } else if pv := protocolVersionFromContext(req.Context()); pv != "" { - req.Header.Set(protocolVersionHeader, pv) } else if c.initializedResult != nil { req.Header.Set(protocolVersionHeader, c.initializedResult.ProtocolVersion) + } else if pv := protocolVersionFromContext(req.Context()); pv != "" { + req.Header.Set(protocolVersionHeader, pv) } if c.sessionID != "" { req.Header.Set(sessionIDHeader, c.sessionID) diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index a5957bf..389f87e 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -1384,6 +1384,7 @@ func TestStreamableClientConnSetMCPHeaders_ProtocolVersion(t *testing.T) { name string initializedResult *InitializeResult msg jsonrpc.Message + ctxVersion string want string }{ { @@ -1422,6 +1423,30 @@ func TestStreamableClientConnSetMCPHeaders_ProtocolVersion(t *testing.T) { msg: req(1, methodListTools, &ListToolsParams{}), want: "", }, + { + // A process that is both a server and a client (a proxy) carries the + // inbound request's version on the context of the outgoing call. The + // version this connection negotiated must win over it. + name: "initializedResult preferred over request context", + initializedResult: &InitializeResult{ProtocolVersion: protocolVersion20251125}, + msg: req(1, methodListTools, &ListToolsParams{}), + ctxVersion: protocolVersion20260728, + want: protocolVersion20251125, + }, + { + name: "request context used when initializedResult unset", + initializedResult: nil, + msg: req(1, methodListTools, &ListToolsParams{}), + ctxVersion: protocolVersion20260728, + want: protocolVersion20260728, + }, + { + name: "message meta preferred over request context", + initializedResult: nil, + msg: req(1, methodListTools, &ListToolsParams{Meta: Meta{MetaKeyProtocolVersion: protocolVersion20251125}}), + ctxVersion: protocolVersion20260728, + want: protocolVersion20251125, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1430,6 +1455,9 @@ func TestStreamableClientConnSetMCPHeaders_ProtocolVersion(t *testing.T) { if err != nil { t.Fatal(err) } + if tt.ctxVersion != "" { + httpReq = httpReq.WithContext(context.WithValue(httpReq.Context(), protocolVersionContextKey{}, tt.ctxVersion)) + } if err := conn.setMCPHeaders(httpReq, tt.msg); err != nil { t.Fatalf("setMCPHeaders: %v", err) } From fe27fa91519a06197cdf03b49861f0565471e726 Mon Sep 17 00:00:00 2001 From: jansen Date: Fri, 21 Aug 2026 16:16:28 +0800 Subject: [PATCH 376/418] internal/jsonrpc2: plumb cancellation cause through request context (#1103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1100. ## What `handleAsync` in `internal/jsonrpc2/conn.go` reported *why* an in-flight request had been canceled by inspecting the connection-level `s.writeErr`, guarded by a TODO waiting on golang/go#51365: ```go // Only deliver to the Handler if not already canceled. if err := req.ctx.Err(); err != nil { c.updateInFlight(func(s *inFlightState) { if s.writeErr != nil { // Assume that req.ctx was canceled due to s.writeErr. // TODO(#51365): use a Context API to plumb this through req.ctx. err = fmt.Errorf("%w: %v", ErrServerClosing, s.writeErr) } }) ... } ``` That API shipped as `context.WithCancelCause` / `context.Cause` in Go 1.21, and this module already targets Go 1.25, so the TODO is now actionable. This PR carries the cancellation cause on each request's context instead of inferring it from global state: - `incomingRequest.cancel` becomes a `context.CancelCauseFunc` (built via `context.WithCancelCause`). - The write-failure and read-side teardowns cancel each in-flight request with a cause wrapping `ErrServerClosing`. - The remaining cancel sites (peer cancel via `Cancel`, normal completion) pass `nil`. - `handleAsync` reads `context.Cause(req.ctx)` instead of looking up `s.writeErr`. ## Why Besides resolving the TODO, this fixes a latent mis-attribution. `s.writeErr` is connection-level state, not per-request: a request canceled for an unrelated reason (e.g. a peer cancel notification via `Cancel`) was reported as `ErrServerClosing` whenever `s.writeErr` happened to be non-nil at that moment. Tying the cause to the specific request's context removes that coupling. `ctx.Err()` still returns `context.Canceled` in all cases, so external behavior is unchanged; only the reported cause becomes accurate and per-request. ## Test plan - `go build ./...` - `go test ./internal/... ./mcp/` (including the longer `mcp` integration tests) — all pass. - Verified `context.WithCancelCause` semantics: with a cause, `Err()` stays `context.Canceled` while `Cause()` returns the wrapped `ErrServerClosing`; with `nil`, both resolve to `context.Canceled` — matching prior behavior. ## Note Per the discussion in #1100, the read-side teardown also carries a descriptive cause (wrapping `ErrServerClosing`) rather than a bare `context.Canceled`. --------- Co-authored-by: Guglielmo Colombo Co-authored-by: Yaroslav --- internal/jsonrpc2/conn.go | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/internal/jsonrpc2/conn.go b/internal/jsonrpc2/conn.go index 4994c63..56edb7e 100644 --- a/internal/jsonrpc2/conn.go +++ b/internal/jsonrpc2/conn.go @@ -172,7 +172,7 @@ func (s *inFlightState) shuttingDown(errClosing error) error { type incomingRequest struct { *Request // the request being processed ctx context.Context - cancel context.CancelFunc + cancel context.CancelCauseFunc } // Reader abstracts the transport mechanics from the JSON RPC protocol. @@ -458,7 +458,7 @@ func (c *Connection) Cancel(id ID) { req = s.incomingByID[id] }) if req != nil { - req.cancel() + req.cancel(nil) } } @@ -552,9 +552,11 @@ func (c *Connection) readIncoming(ctx context.Context, reader Reader, preempter // Cancel any incoming requests still in flight: with the reader gone we // cannot receive cancellation notifications, and likely cannot write a // response either, so parked handlers have nothing useful left to do. - // Mirrors the equivalent cleanup on write failure. + // The read error (typically io.EOF from the peer disconnecting) is the + // cause, rather than a directional ErrServerClosing/ErrClientClosing + // sentinel: at this layer the connection has no designated end. for _, r := range s.incomingByID { - r.cancel() + r.cancel(err) } }) } @@ -564,7 +566,7 @@ func (c *Connection) readIncoming(ctx context.Context, reader Reader, preempter func (c *Connection) acceptRequest(ctx context.Context, msg *Request, preempter Preempter) { // In theory notifications cannot be cancelled, but we build them a cancel // context anyway. - reqCtx, cancel := context.WithCancel(ctx) + reqCtx, cancel := context.WithCancelCause(ctx) req := &incomingRequest{ Request: msg, ctx: reqCtx, @@ -665,15 +667,14 @@ func (c *Connection) handleAsync() { return } - // Only deliver to the Handler if not already canceled. + // Only deliver to the Handler if not already canceled. If the request + // was canceled with a cause (e.g. a write failure cancels every + // in-flight request), report that cause rather than the bare + // context.Canceled. if err := req.ctx.Err(); err != nil { - c.updateInFlight(func(s *inFlightState) { - if s.writeErr != nil { - // Assume that req.ctx was canceled due to s.writeErr. - // TODO(#51365): use a Context API to plumb this through req.ctx. - err = fmt.Errorf("%w: %v", ErrServerClosing, s.writeErr) - } - }) + if cause := context.Cause(req.ctx); cause != nil { + err = cause + } c.processResult("handleAsync", req, nil, err) continue } @@ -734,7 +735,7 @@ func (c *Connection) processResult(from any, req *incomingRequest, result any, e } // Cancel the request to free any associated resources. - req.cancel() + req.cancel(nil) c.updateInFlight(func(s *inFlightState) { if s.incoming == 0 { panic("jsonrpc2: processResult called when incoming count is already zero") @@ -777,7 +778,7 @@ func (c *Connection) write(ctx context.Context, msg Message) error { if s.writeErr == nil { s.writeErr = err for _, r := range s.incomingByID { - r.cancel() + r.cancel(fmt.Errorf("%w: %v", ErrServerClosing, err)) } } }) From ce0b198202c461787ead7799bac94e90d9cddd91 Mon Sep 17 00:00:00 2001 From: PratikDhanave Date: Fri, 21 Aug 2026 14:23:36 +0530 Subject: [PATCH 377/418] mcp: prevent MemoryEventStore.After panic on index past the end (#1132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `MemoryEventStore.After` guarded only the lower bound of the requested index: ```go start := index + 1 if dl.first > start { // too old → ErrEventsPurged return nil, fmt.Errorf(... ErrEventsPurged) } return slices.Clone(dl.data[start-dl.first:]), nil ``` There was no *upper*-bound guard, so an `index` greater than the highest stored event made the final slice expression panic with `slice bounds out of range`. The boundary `start-dl.first == len(dl.data)` (resume from the last event seen) is a valid empty replay, so only strictly-greater indices faulted. This is reachable from untrusted input: `parseEventID` accepts any non-negative index from the client-supplied `Last-Event-ID` header, and `serveGET` passes it straight to `eventStore.After`. Direct callers of the exported `EventStore.After` API crash outright. Fixes #1131 ## Change Return an empty replay for an index at or beyond the latest stored event — there is nothing after it — consistent with the already-valid `index == last` case. `ErrEventsPurged` would be misleading here, since those events were never sent rather than purged. ## Testing Extended `TestMemoryEventStoreAfter` with indices past the end of a stream (`3`, `100`, and one past a second stream), asserting an empty replay with no error. Verified the new cases **panic on the pre-fix code** (`panic: runtime error: slice bounds out of range [3:2]`) and **pass with the fix**. `go vet ./mcp/` and the full `go test ./mcp/` suite pass. --------- Co-authored-by: Yaroslav Shevchuk --- mcp/event.go | 9 ++++++--- mcp/event_test.go | 4 ++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/mcp/event.go b/mcp/event.go index bbb3e6c..2b02a07 100644 --- a/mcp/event.go +++ b/mcp/event.go @@ -337,12 +337,15 @@ func (s *MemoryEventStore) After(_ context.Context, sessionID, streamID string, if !ok { return nil, fmt.Errorf("MemoryEventStore.After: unknown stream ID %v in session %q", streamID, sessionID) } - start := index + 1 - if dl.first > start { + start := (index + 1) - dl.first + if start < 0 { return nil, fmt.Errorf("MemoryEventStore.After: index %d, stream ID %v, session %q: %w", index, streamID, sessionID, ErrEventsPurged) } - return slices.Clone(dl.data[start-dl.first:]), nil + if start >= len(dl.data) { + return nil, nil + } + return slices.Clone(dl.data[start:]), nil } return func(yield func([]byte, error) bool) { diff --git a/mcp/event_test.go b/mcp/event_test.go index 91ddb9d..fedc92c 100644 --- a/mcp/event_test.go +++ b/mcp/event_test.go @@ -299,7 +299,11 @@ func TestMemoryEventStoreAfter(t *testing.T) { {"S1", "1", 0, []string{"d2", "d3"}, ""}, {"S1", "1", 1, []string{"d3"}, ""}, {"S1", "1", 2, nil, ""}, + {"S1", "1", 3, nil, ""}, + {"S1", "1", 100, nil, ""}, // past the end of stream "1" (highest is 2) {"S1", "2", 0, nil, ""}, + {"S1", "2", 1, nil, ""}, + {"S1", "2", 100, nil, ""}, {"S1", "3", 0, nil, "unknown stream ID"}, {"S2", "0", 0, nil, "unknown session ID"}, } { From b074c7f3af59d200d632393501a6081ece247fcd Mon Sep 17 00:00:00 2001 From: jansen Date: Fri, 21 Aug 2026 16:56:31 +0800 Subject: [PATCH 378/418] internal/jsonrpc2: test WireError.Is and fix nil target panic (#1079) # Problem WireError.Is backs errors.Is checks used across the SDK against sentinels such as ErrMethodNotFound, ErrRejected, and ErrClientClosing, but had no direct test. Add a table-driven test for it. While doing so, errors.Is(err, (*WireError)(nil)) was found to panic on a nil pointer dereference, because Is read w.Code without a nil check. Guard against a nil *WireError target so the comparison returns false. ```go errors.Is(err, (*WireError)(nil)) // panic: nil pointer dereference ``` # Approach Is read w.Code after a successful type assertion without checking whether w itself was nil. The fix guards against a nil *WireError target so the comparison returns false instead of panicking. # Test plan - go test ./internal/jsonrpc2/ passes. - The new typed nil WireError case panics without the one-line fix and passes with it, confirming the test exercises the guarded path. Co-authored-by: Yaroslav --- internal/jsonrpc2/wire.go | 2 +- internal/jsonrpc2/wire_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/internal/jsonrpc2/wire.go b/internal/jsonrpc2/wire.go index 4d123f2..499b026 100644 --- a/internal/jsonrpc2/wire.go +++ b/internal/jsonrpc2/wire.go @@ -87,7 +87,7 @@ func (err *WireError) Error() string { func (err *WireError) Is(other error) bool { w, ok := other.(*WireError) - if !ok { + if !ok || w == nil { return false } return err.Code == w.Code diff --git a/internal/jsonrpc2/wire_test.go b/internal/jsonrpc2/wire_test.go index cf7e2b8..ef47144 100644 --- a/internal/jsonrpc2/wire_test.go +++ b/internal/jsonrpc2/wire_test.go @@ -7,6 +7,7 @@ package jsonrpc2_test import ( "bytes" "encoding/json" + "errors" "reflect" "testing" @@ -147,6 +148,29 @@ func TestDecodeIDOnlyMessageIsResponse(t *testing.T) { } } +// TestWireErrorIs checks the errors.Is behavior of WireError, including that +// comparing against a typed-nil *WireError does not panic. +func TestWireErrorIs(t *testing.T) { + err := jsonrpc2.NewError(-32600, "invalid request") + for _, test := range []struct { + name string + target error + want bool + }{ + {name: "same code", target: jsonrpc2.NewError(-32600, "other message"), want: true}, + {name: "different code", target: jsonrpc2.NewError(-32601, "invalid request"), want: false}, + {name: "typed nil WireError", target: (*jsonrpc2.WireError)(nil), want: false}, + {name: "untyped nil", target: nil, want: false}, + {name: "other error type", target: errors.New("invalid request"), want: false}, + } { + t.Run(test.name, func(t *testing.T) { + if got := errors.Is(err, test.target); got != test.want { + t.Errorf("errors.Is(%v, %v) = %v, want %v", err, test.target, got, test.want) + } + }) + } +} + func checkJSON(t *testing.T, got, want []byte) { // compare the compact form, to allow for formatting differences g := &bytes.Buffer{} From 3d6450fccf564e7c92a54d4274c35635a8c39821 Mon Sep 17 00:00:00 2001 From: Lee Date: Fri, 21 Aug 2026 18:31:12 +0800 Subject: [PATCH 379/418] mcp: bound streamable Close DELETE so a silent peer cannot hang teardown (#1184) Close issued a synchronous session-termination DELETE on the connection context and cancelled that context only after `Do` returned. A reachable but silent peer could stall `Close()` until the TCP timeout. Give the DELETE its own short timeout. RED on current main: `TestCloseHangsOnBlackHoledDelete` failed with `Close() blocked on a black-holing DELETE`. GREEN: `go test ./mcp/ -count=1 -timeout 120s -run 'TestStreamableClient|TestCloseHangs|TestCallCancellation_FastReturn'` Fixes #1183 Made with [Cursor](https://cursor.com) Co-authored-by: Guglielmo Colombo --- mcp/streamable.go | 9 +++++++- mcp/streamable_client_test.go | 42 +++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index b761f9c..6ed5d3d 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -2016,6 +2016,11 @@ const ( reconnectGrowFactor = 1.5 // reconnectMaxDelay caps the backoff delay, preventing it from growing indefinitely. reconnectMaxDelay = 30 * time.Second + // closeDeleteTimeout bounds the session-termination DELETE sent by Close. + // That request is best-effort: a peer that accepts the connection but never + // answers must not be able to hold teardown open until the TCP timeout. + // See issue #1183. + closeDeleteTimeout = 5 * time.Second ) var ( @@ -2767,7 +2772,9 @@ func (c *streamableClientConn) Close() error { // No session was established (e.g. the server is stateless), // so there is nothing to delete. } else { - req, err := http.NewRequestWithContext(c.ctx, http.MethodDelete, c.url, nil) + reqCtx, stop := context.WithTimeout(c.ctx, closeDeleteTimeout) + defer stop() + req, err := http.NewRequestWithContext(reqCtx, http.MethodDelete, c.url, nil) if err != nil { c.closeErr = err } else { diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 389f87e..64c4da5 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -281,6 +281,48 @@ func TestStreamableClientRedundantDelete(t *testing.T) { } } +// TestStreamableClientCloseUnresponsiveDelete checks that Close stays bounded +// when the peer accepts the session-termination DELETE but never answers it. +func TestStreamableClientCloseUnresponsiveDelete(t *testing.T) { + base := NewStreamableHTTPHandler(func(*http.Request) *Server { + return NewServer(testImpl, nil) + }, nil) + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + <-r.Context().Done() + return + } + base.ServeHTTP(w, r) + })) + t.Cleanup(func() { + // The DELETE handler only returns once its request is cancelled, so + // drop the connections before waiting for outstanding requests. + httpServer.CloseClientConnections() + httpServer.Close() + }) + + client := NewClient(testImpl, nil) + session, err := client.Connect(context.Background(), + &StreamableClientTransport{Endpoint: httpServer.URL}, nil) + if err != nil { + t.Fatalf("client.Connect() failed: %v", err) + } + if session.ID() == "" { + t.Fatal("empty session ID: Close would not send DELETE") + } + + done := make(chan struct{}) + go func() { + session.Close() + close(done) + }() + select { + case <-done: + case <-time.After(closeDeleteTimeout + 5*time.Second): + t.Fatal("Close() blocked on an unanswered DELETE") + } +} + func TestStreamableClientGETHandling(t *testing.T) { ctx := context.Background() From 6f0a645c2046be1b47d3ea1d9ec7b2aa74c0b9ef Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Mon, 24 Aug 2026 10:14:28 +0200 Subject: [PATCH 380/418] mcp: add ServerSession.NotifyElicitationComplete (#1194) Adds: ```go func (ss *ServerSession) NotifyElicitationComplete(ctx context.Context, params *ElicitationCompleteParams) error ``` As according to [spec](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-11-25/schema.ts#L2493-L2501) --- mcp/elicitation_test.go | 40 ++++++++++++++++++++++++++++++++++++++-- mcp/error_test.go | 7 +++---- mcp/server.go | 12 ++++++++++++ 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/mcp/elicitation_test.go b/mcp/elicitation_test.go index d588516..098fce3 100644 --- a/mcp/elicitation_test.go +++ b/mcp/elicitation_test.go @@ -12,6 +12,7 @@ import ( "testing/synctest" "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) @@ -194,9 +195,9 @@ func TestElicitationCompleteNotification(t *testing.T) { } // 2. Server sends elicitation complete notification (simulating out-of-band completion) - err = handleNotify(ctx, notificationElicitationComplete, newServerRequest(ss, &ElicitationCompleteParams{ + err = ss.NotifyElicitationComplete(ctx, &ElicitationCompleteParams{ ElicitationID: elicitID, - })) + }) if err != nil { t.Fatalf("failed to send elicitation complete notification: %v", err) } @@ -209,6 +210,41 @@ func TestElicitationCompleteNotification(t *testing.T) { }) } +func TestNotifyElicitationCompleteInvalidParams(t *testing.T) { + ctx := context.Background() + + ct, st := NewInMemoryTransports() + s := NewServer(testImpl, nil) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + c := NewClient(testImpl, nil) + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + // An empty elicitation ID cannot be matched to a pending elicitation by the + // client, so it is rejected rather than sent. + for _, tc := range []struct { + name string + params *ElicitationCompleteParams + }{ + {"nil params", nil}, + {"empty ID", &ElicitationCompleteParams{}}, + } { + t.Run(tc.name, func(t *testing.T) { + err := ss.NotifyElicitationComplete(ctx, tc.params) + if !errors.Is(err, jsonrpc2.ErrInvalidParams) { + t.Errorf("NotifyElicitationComplete(%v) = %v, want %v", tc.params, err, jsonrpc2.ErrInvalidParams) + } + }) + } +} + func TestElicitationNoValidationWithoutAccept(t *testing.T) { ctx := context.Background() diff --git a/mcp/error_test.go b/mcp/error_test.go index 898562c..c69fc4b 100644 --- a/mcp/error_test.go +++ b/mcp/error_test.go @@ -300,10 +300,9 @@ func TestURLElicitationRequired(t *testing.T) { // In a real scenario, this would happen out-of-band after the user // completes the form submission. go func() { - err := handleNotify(ctx, notificationElicitationComplete, - newServerRequest(ss, &ElicitationCompleteParams{ - ElicitationID: elicitID, - })) + err := ss.NotifyElicitationComplete(ctx, &ElicitationCompleteParams{ + ElicitationID: elicitID, + }) if err != nil { t.Errorf("failed to send elicitation complete notification: %v", err) } diff --git a/mcp/server.go b/mcp/server.go index 13172a5..f9c8233 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1733,6 +1733,18 @@ func (ss *ServerSession) Elicit(ctx context.Context, params *ElicitParams) (*Eli return res, nil } +// NotifyElicitationComplete tells the client that the out-of-band ("url" mode) +// elicitation identified by params.ElicitationID has finished. +func (ss *ServerSession) NotifyElicitationComplete(ctx context.Context, params *ElicitationCompleteParams) error { + if params == nil { + return fmt.Errorf("%w: params cannot be nil", jsonrpc2.ErrInvalidParams) + } + if params.ElicitationID == "" { + return fmt.Errorf("%w: ElicitationID cannot be empty", jsonrpc2.ErrInvalidParams) + } + return handleNotify(ctx, notificationElicitationComplete, newServerRequest(ss, orZero[Params](params))) +} + // Log sends a log message to the client. // // For new-protocol (>= 2026-07-28) requests, the level is taken from the From b8c53fedd6287fd11f0e7a386730ce609d1c3eb0 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Mon, 24 Aug 2026 12:00:59 +0200 Subject: [PATCH 381/418] mcp: add docs for tier 1 assessment (#1195) Closes open points in [comment](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/3220#issuecomment-5373089321) --- CONTRIBUTING.md | 54 ++++ docs/client.md | 212 ++++++++++++++ docs/protocol.md | 58 ++++ docs/server.md | 410 +++++++++++++++++++++++++++- internal/docs/client.src.md | 52 ++++ internal/docs/protocol.src.md | 28 ++ internal/docs/server.src.md | 106 ++++++- internal/readme/contributing.src.md | 51 ++++ mcp/client_example_test.go | 170 ++++++++++++ mcp/server_example_test.go | 251 +++++++++++++++++ mcp/sse_example_test.go | 4 + mcp/tool.go | 2 +- mcp/tool_example_test.go | 68 +++++ 13 files changed, 1461 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b327087..3d8b090 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,6 +21,9 @@ transparently, while adhering to these goals. 1. [Contributing code](#contributing-code) 1. [Adding and updating dependencies](#adding-and-updating-dependencies) 1. [Updating the README](#updating-the-readme) +1. [Versioning](#versioning) + 1. [What counts as a breaking change](#what-counts-as-a-breaking-change) + 1. [Deprecation](#deprecation) 1. [Timeouts](#timeouts) 1. [Code of conduct](#code-of-conduct) 1. [Governance](#governance) @@ -188,6 +191,57 @@ The CI system will automatically check that the README is up-to-date by running `go generate ./internal/readme` and verifying no changes result. If you see a CI failure about the README being out of sync, follow the steps above to regenerate it. +## Versioning + +The SDK follows [semantic versioning](https://semver.org), as the Go module +system requires: the major version is part of the module path, so a breaking +release would import as `github.com/modelcontextprotocol/go-sdk/v2`. A breaking +change therefore cannot reach existing users by accident; they have to change +their import path to receive one. + +This policy covers the exported API of the SDK's importable packages — `mcp`, +`jsonrpc`, `auth`, `auth/extauth` and `oauthex`. Everything under `internal/` +is not importable outside the module and may change in any release. + +Which MCP spec revisions each SDK version speaks is documented in the +[README](README.md#version-compatibility). Support for a new revision arrives +in a minor release; dropping a revision the SDK previously negotiated is a +breaking change. + +### What counts as a breaking change + +- Removing or renaming an exported identifier. +- Changing the signature of an exported function or method. +- Removing or renaming a field of an exported struct, or changing its type. +- Adding a method to an exported interface that users are meant to implement. +- Changing documented behavior that callers could reasonably rely on. +- Dropping support for an MCP spec revision. + +The following are not breaking, and may land in a minor or patch release: + +- Adding an exported identifier, or a field to an exported struct. +- Fixing behavior that contradicted its documentation. +- Refactoring that leaves the exported API unchanged. +- Adding support for a new MCP spec revision. +- Raising the minimum Go version to one still supported upstream, per the + [README](README.md#version-compatibility). + +### Deprecation + +An API on its way out is marked with a `// Deprecated:` comment naming its +replacement, which `gopls` and `staticcheck` surface at call sites. It keeps +working until the next major version, since removing it would itself be a +breaking change. + +Protocol features that the *specification* deprecates are a separate matter and +follow the spec's timeline rather than this policy. The features deprecated by +[SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging) +— roots, sampling and logging — remain supported for at least twelve months, as +noted in the [README](README.md#version-compatibility). + +Any change to the exported API, breaking or not, goes through the +[proposal process](#proposals). + ## Timeouts If a contributor hasn't responded to issue questions or PR comments in two weeks, diff --git a/docs/client.md b/docs/client.md index fe9a963..ad71c11 100644 --- a/docs/client.md +++ b/docs/client.md @@ -2,8 +2,11 @@ # Support for MCP client features 1. [Roots](#roots) + 1. [Roots list changed](#roots-list-changed) 1. [Sampling](#sampling) 1. [Elicitation](#elicitation) + 1. [Schema defaults and enums](#schema-defaults-and-enums) + 1. [Completing a URL elicitation](#completing-a-url-elicitation) 1. [Multi Round-Trip Requests](#multi-round-trip-requests) 1. [Capabilities](#capabilities) 1. [Capability inference](#capability-inference) @@ -91,6 +94,67 @@ func Example_roots() { } ``` +### Roots list changed + +`Client.AddRoots` and `Client.RemoveRoots` notify every connected server that +the list changed. Servers observe this through +[`ServerOptions.RootsListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.RootsListChangedHandler); +as with the server-side list-changed notifications, it reports only that +something changed, so read the list back with `ServerSession.ListRoots`. + +```go +func Example_rootsListChanged() { + ctx := context.Background() + + changed := make(chan struct{}, 2) + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ + RootsListChangedHandler: func(context.Context, *mcp.RootsListChangedRequest) { + changed <- struct{}{} + }, + }) + + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + c.AddRoots(&mcp.Root{URI: "file:///project"}) + + t1, t2 := mcp.NewInMemoryTransports() + ss, err := s.Connect(ctx, t1, nil) + if err != nil { + log.Fatal(err) + } + defer ss.Close() + + // ListRoots is a server-initiated request, so this session negotiates a + // protocol version that still allows one. + cs, err := c.Connect(ctx, t2, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"}) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + // Roots added after the client connects notify every connected server. + c.AddRoots(&mcp.Root{URI: "file:///scratch"}) + <-changed + + // The notification says only that the list changed, so read it back. + res, err := ss.ListRoots(ctx, nil) + if err != nil { + log.Fatal(err) + } + for _, root := range res.Roots { + fmt.Println(root.URI) + } + + c.RemoveRoots("file:///scratch") + <-changed + fmt.Println("roots changed again") + + // Output: + // file:///project + // file:///scratch + // roots changed again +} +``` + ## Sampling > **Note:** The sampling feature is deprecated as of protocol version @@ -235,6 +299,154 @@ func Example_elicitation() { } ``` +### Schema defaults and enums + +`ElicitParams.RequestedSchema` is a flat schema of primitive fields, which the +client renders as a form. Two field keywords shape that form. + +A `Default` ([SEP-1034](https://modelcontextprotocol.io/seps/1034)) prefills a +field. When the user accepts without supplying it, the SDK fills the field in +from the schema before the result reaches either side's caller — the client +does so after its elicitation handler returns, and `ServerSession.Elicit` does +so again on receipt. This is unconditional; there is no opt-in flag. Marking a +defaulted field `Required` defeats it: accepted content is validated against +the schema before defaults are applied, so an answer that omits the field is +rejected rather than defaulted. + +An `Enum` ([SEP-1330](https://modelcontextprotocol.io/seps/1330)) restricts a +field to a fixed set of values, which the client renders as a choice. Enums +are supported only on `"string"` fields; declaring one on another type is +rejected. To label the choices, set the legacy `enumNames` keyword through +`Schema.Extra`, with exactly one name per enum value — a mismatched length is +rejected. + +```go +func Example_elicitationSchema() { + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(s, &mcp.Tool{Name: "export_report"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{"format": &mcp.ElicitParams{ + Message: "Export quarterly-sales as which format?", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "format": { + Type: "string", + Title: "Format", + Enum: []any{"pdf", "csv"}, + Default: json.RawMessage(`"pdf"`), + Extra: map[string]any{"enumNames": []any{"PDF document", "CSV spreadsheet"}}, + }, + }, + }, + }}, + }, nil, nil + } + res := req.Params.InputResponses["format"].(*mcp.ElicitResult) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "Exported as " + res.Content["format"].(string)}}, + }, nil, nil + }) + if _, err := s.Connect(ctx, st, nil); err != nil { + log.Fatal(err) + } + + // The user accepts without filling anything in. + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + ElicitationHandler: func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{}}, nil + }, + }) + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "export_report"}) + if err != nil { + log.Fatal(err) + } + fmt.Println(res.Content[0].(*mcp.TextContent).Text) + // Output: Exported as pdf +} +``` + +### Completing a URL elicitation + +In URL mode the user finishes out of band, in a browser, so nothing in the +elicitation result tells the client when they are done. The server signals that +with +[`ServerSession.NotifyElicitationComplete`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.NotifyElicitationComplete), +passing the same `ElicitationID` the request carried; the client observes it +through +[`ClientOptions.ElicitationCompleteHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ElicitationCompleteHandler). +Send it from whatever endpoint the hosted flow redirects back to. + +The notification matters most when a handler rejects a request with +[`URLElicitationRequiredError`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#URLElicitationRequiredError): +the client parks the original request until a notification names that +`ElicitationID`, and then retries it automatically. Until one arrives, the +client waits. + +```go +func Example_elicitationComplete() { + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + log.Fatal(err) + } + defer ss.Close() + + done := make(chan struct{}) + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + Capabilities: &mcp.ClientCapabilities{ + Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}, + }, + ElicitationHandler: func(_ context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + fmt.Println("opening", req.Params.URL) + return &mcp.ElicitResult{Action: "accept"}, nil + }, + ElicitationCompleteHandler: func(_ context.Context, req *mcp.ElicitationCompleteNotificationRequest) { + fmt.Println("flow finished:", req.Params.ElicitationID) + close(done) + }, + }) + cs, err := c.Connect(ctx, ct, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"}) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + const elicitationID = "connect-calendar-1" + if _, err := ss.Elicit(ctx, &mcp.ElicitParams{ + Message: "Grant calendar access", + URL: "https://calendar.example.com/consent?state=" + elicitationID, + ElicitationID: elicitationID, + }); err != nil { + log.Fatal(err) + } + + // The hosted page redirects back to the server, whose callback endpoint + // signals that the user is done. + if err := ss.NotifyElicitationComplete(ctx, &mcp.ElicitationCompleteParams{ElicitationID: elicitationID}); err != nil { + log.Fatal(err) + } + <-done + + // Output: + // opening https://calendar.example.com/consent?state=connect-calendar-1 + // flow finished: connect-calendar-1 +} +``` + ## Multi Round-Trip Requests [SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322) diff --git a/docs/protocol.md b/docs/protocol.md index edc1c11..d40ed91 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -9,6 +9,7 @@ 1. [Transports](#transports) 1. [Stdio Transport](#stdio-transport) 1. [Streamable Transport](#streamable-transport) + 1. [SSE Transport (legacy)](#sse-transport-(legacy)) 1. [Custom transports](#custom-transports) 1. [Concurrency](#concurrency) 1. [Authorization](#authorization) @@ -342,6 +343,63 @@ _See [examples/server/distributed](https://github.com/modelcontextprotocol/go-sd an example using stateless mode to implement a server distributed across multiple processes._ +### SSE Transport (legacy) + +Before the streamable transport, the +[2024-11-05](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports) +spec defined an HTTP transport built from two endpoints: a hanging GET that +streams server-to-client messages as server-sent events, and a per-session +endpoint the client POSTs its messages to. The SDK still implements it, for +talking to peers that predate the streamable transport. New deployments should +use the [streamable transport](#streamable-transport) instead. + +It is implemented across three types, mirroring the streamable ones: + +- [`SSEHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#SSEHandler): + an `http.Handler` that creates a session per incoming GET and routes POSTs to + the right session. Construct it with + [`NewSSEHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#NewSSEHandler), + which takes a function returning the `Server` to serve a given request. +- [`SSEServerTransport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#SSEServerTransport): + one such session. Reach for it directly only when serving the two endpoints + yourself; `SSEHandler` creates these for you. +- [`SSEClientTransport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#SSEClientTransport): + the client side, which needs only the `Endpoint` of the GET. + +```go +func ExampleSSEHandler() { + server := mcp.NewServer(&mcp.Implementation{Name: "adder", Version: "v0.0.1"}, nil) + mcp.AddTool(server, &mcp.Tool{Name: "add", Description: "add two numbers"}, Add) + + handler := mcp.NewSSEHandler(func(*http.Request) *mcp.Server { return server }, nil) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + ctx := context.Background() + transport := &mcp.SSEClientTransport{Endpoint: httpServer.URL} + client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "v1.0.0"}, nil) + cs, err := client.Connect(ctx, transport, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + res, err := cs.CallTool(ctx, &mcp.CallToolParams{ + Name: "add", + Arguments: map[string]any{"x": 1, "y": 2}, + }) + if err != nil { + log.Fatal(err) + } + fmt.Println(res.Content[0].(*mcp.TextContent).Text) + + // Output: 3 +} +``` + +_See [examples/server/sse](https://github.com/modelcontextprotocol/go-sdk/blob/main/examples/server/sse/main.go) +for a standalone server._ + ### Custom transports The SDK supports [custom diff --git a/docs/server.md b/docs/server.md index 9f0f870..9eb4ec2 100644 --- a/docs/server.md +++ b/docs/server.md @@ -2,8 +2,13 @@ # Support for MCP server features 1. [Prompts](#prompts) + 1. [Prompt message content](#prompt-message-content) 1. [Resources](#resources) + 1. [Binary resources](#binary-resources) + 1. [Resource subscriptions](#resource-subscriptions) 1. [Tools](#tools) + 1. [Tool result content](#tool-result-content) +1. [List changed notifications](#list-changed-notifications) 1. [Multi Round-Trip Requests](#multi-round-trip-requests) 1. [Talking to legacy clients](#talking-to-legacy-clients) 1. [Example](#example) @@ -115,6 +120,91 @@ func Example_prompts() { } ``` +### Prompt message content + +A +[`PromptMessage`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#PromptMessage) +has two fields: a `Role`, either `"user"` or `"assistant"`, and a single +`Content`. That is the same interface a tool result uses (see [Tool result +content](#tool-result-content)), so a prompt message can hold an image, audio, +or a resource's contents as readily as text. + +Media on its own says nothing about what the model should do with it, so send a +text message alongside it. Embedding a resource puts its contents directly in +the message, sparing the client a separate `resources/read`. Note that +`ResourceContents.URI` is not checked against the resources the server has +registered: it records where the contents came from, so use the URI of a real +resource when there is one, as in the example below. + +```go +func Example_promptContent() { + ctx := context.Background() + + const styleGuide = "- Prefer clarity over cleverness.\n" + screenshotPNG := []byte("\x89PNG\r\n\x1a\n") + + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + + s.AddResource(&mcp.Resource{URI: "doc://style-guide", MIMEType: "text/markdown"}, + func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{ + URI: "doc://style-guide", + MIMEType: "text/markdown", + Text: styleGuide, + }}, + }, nil + }) + + s.AddPrompt(&mcp.Prompt{Name: "review_screenshot"}, + func(context.Context, *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return &mcp.GetPromptResult{ + Messages: []*mcp.PromptMessage{ + {Role: "user", Content: &mcp.EmbeddedResource{ + Resource: &mcp.ResourceContents{ + URI: "doc://style-guide", + MIMEType: "text/markdown", + Text: styleGuide, + }, + }}, + {Role: "user", Content: &mcp.ImageContent{Data: screenshotPNG, MIMEType: "image/png"}}, + {Role: "user", Content: &mcp.TextContent{Text: "Review the screenshot against the style guide."}}, + }, + }, nil + }) + + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + res, err := cs.GetPrompt(ctx, &mcp.GetPromptParams{Name: "review_screenshot"}) + if err != nil { + log.Fatal(err) + } + for _, msg := range res.Messages { + switch content := msg.Content.(type) { + case *mcp.TextContent: + fmt.Println(msg.Role, "text:", content.Text) + case *mcp.ImageContent: + fmt.Printf("%s image: %s, %d bytes\n", msg.Role, content.MIMEType, len(content.Data)) + case *mcp.EmbeddedResource: + fmt.Printf("%s embedded resource: %s (%s)\n", msg.Role, content.Resource.URI, content.Resource.MIMEType) + } + } + // Output: + // user embedded resource: doc://style-guide (text/markdown) + // user image: image/png, 8 bytes + // user text: Review the screenshot against the style guide. +} +``` + ## Resources In MCP terms, a _resource_ is some data referenced by a URI. @@ -246,6 +336,145 @@ func Example_resources() { } ``` +### Binary resources + +A +[`ResourceContents`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ResourceContents) +carries its data in either `Text` or `Blob`. Binary data goes in `Blob` as +plain bytes — the SDK base64-encodes it on the wire — and leaves `Text` empty, +so a client tells the two apart by which field is populated. + +```go +func Example_binaryResource() { + ctx := context.Background() + + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + s.AddResource(&mcp.Resource{URI: "file:///logo.png", MIMEType: "image/png"}, + func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{ + URI: "file:///logo.png", + MIMEType: "image/png", + Blob: []byte("\x89PNG\r\n\x1a\n"), + }}, + }, nil + }) + + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + res, err := cs.ReadResource(ctx, &mcp.ReadResourceParams{URI: "file:///logo.png"}) + if err != nil { + log.Fatal(err) + } + contents := res.Contents[0] + fmt.Printf("%s: %d bytes of %s, text is %q\n", + contents.URI, len(contents.Blob), contents.MIMEType, contents.Text) + + // Output: + // file:///logo.png: 8 bytes of image/png, text is "" +} +``` + +### Resource subscriptions + +A client interested in changes to one resource subscribes to its URI with +[`ClientSession.Subscribe`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Subscribe) +and stops with +[`ClientSession.Unsubscribe`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Unsubscribe). +Set +[`ServerOptions.SubscribeHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.SubscribeHandler) +and +[`UnsubscribeHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.UnsubscribeHandler) +to track who is listening; setting either one gives the server the +`resources.subscribe` capability. + +Publish a change with +[`Server.ResourceUpdated`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.ResourceUpdated), +which notifies every session subscribed to that URI. The notification carries +only the URI, not the new contents, so a client that wants them reads the +resource again. + +```go +func Example_resourceSubscription() { + ctx := context.Background() + + config := "theme=light\n" + + updated := make(chan string, 1) + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + // The notification carries only the URI, so read the resource again to + // see what it now holds. + ResourceUpdatedHandler: func(context.Context, *mcp.ResourceUpdatedNotificationRequest) { + updated <- "config://app" + }, + }) + + subscribed := make(chan string, 2) + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ + SubscribeHandler: func(_ context.Context, req *mcp.SubscribeRequest) error { + subscribed <- "subscribed to " + req.Params.URI + return nil + }, + UnsubscribeHandler: func(_ context.Context, req *mcp.UnsubscribeRequest) error { + subscribed <- "unsubscribed from " + req.Params.URI + return nil + }, + }) + s.AddResource(&mcp.Resource{URI: "config://app"}, + func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{URI: "config://app", Text: config}}, + }, nil + }) + + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + if err := cs.Subscribe(ctx, &mcp.SubscribeParams{URI: "config://app"}); err != nil { + log.Fatal(err) + } + fmt.Println(<-subscribed) + + config = "theme=dark\n" + if err := s.ResourceUpdated(ctx, &mcp.ResourceUpdatedNotificationParams{URI: "config://app"}); err != nil { + log.Fatal(err) + } + uri := <-updated + + res, err := cs.ReadResource(ctx, &mcp.ReadResourceParams{URI: uri}) + if err != nil { + log.Fatal(err) + } + fmt.Print("re-read: ", res.Contents[0].Text) + + if err := cs.Unsubscribe(ctx, &mcp.UnsubscribeParams{URI: "config://app"}); err != nil { + log.Fatal(err) + } + fmt.Println(<-subscribed) + + // Output: + // subscribed to config://app + // re-read: theme=dark + // unsubscribed from config://app +} +``` + ## Tools MCP servers can provide @@ -297,7 +526,7 @@ In order to implement a tool, the user must do all of the following: - Unmarshal the input schema into a Go value - Execute the tool logic. - Marshal the tool's structured output (if any) to JSON, and store it in the - result's `StructuredOutput` field as well as the unstructured `Content` field. + result's `StructuredContent` field as well as the unstructured `Content` field. - Validate that output JSON against the tool's output schema. - If any tool errors occurred, pack them into the unstructured content and set `IsError` to `true.` @@ -332,7 +561,7 @@ This does the following automatically: - Tool arguments are validated against the input schema. - Tool arguments are marshaled into the `In` value. - Tool output (the `Out` value) is marshaled into the result's - `StructuredOutput`, as well as the unstructured `Content`. + `StructuredContent`, as well as the unstructured `Content`. - Output is validated against the tool's output schema. - If an ordinary error is returned, it is stored int the `CallToolResult` and `IsError` is set to `true`. @@ -417,6 +646,103 @@ _See [mcp/tool_example_test.go](https://github.com/modelcontextprotocol/go-sdk/b example, or [examples/server/toolschemas](https://github.com/modelcontextprotocol/go-sdk/blob/main/examples/server/toolschemas/main.go) for more examples of customizing tool schemas._ +### Tool result content + +Alongside its structured output, a tool result carries a list of +[`Content`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Content) +blocks in `CallToolResult.Content`, and may mix as many kinds as it needs: + +| Type | Carries | +| --- | --- | +| [`TextContent`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#TextContent) | plain text | +| [`ImageContent`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ImageContent) | image data, with a MIME type | +| [`AudioContent`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#AudioContent) | audio data, with a MIME type | +| [`EmbeddedResource`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#EmbeddedResource) | the contents of a resource, inline | +| [`ResourceLink`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ResourceLink) | a reference to a resource the client can read separately | + +`ImageContent.Data` and `AudioContent.Data` are plain `[]byte`; the SDK +base64-encodes them on the wire, so handlers never encode by hand, and base64 +carried over from another MCP SDK must be decoded before it is assigned. An +`EmbeddedResource` wraps a +[`ResourceContents`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ResourceContents), +which holds either `Text` or, for binary data, `Blob`. Prefer a `ResourceLink` +when the client may not need the contents, since an embedded resource is +transferred whether it is used or not. + +When a handler bound with `AddTool` leaves `Content` unset, the SDK fills it +with the JSON encoding of the output value; set `Content` explicitly, as below, +to return anything else. `StructuredContent` is still populated from the output +value either way. + +```go +func ExampleAddTool_contentTypes() { + chartPNG := []byte("\x89PNG\r\n\x1a\n") + summaryWAV := []byte("RIFF....WAVE") + + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(server, &mcp.Tool{Name: "report"}, func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "Q3 revenue by region."}, + &mcp.ImageContent{Data: chartPNG, MIMEType: "image/png"}, + &mcp.AudioContent{Data: summaryWAV, MIMEType: "audio/wav"}, + // An embedded resource inlines the contents, so the client + // needs no follow-up resources/read. Binary contents go in + // ResourceContents.Blob instead of Text. + &mcp.EmbeddedResource{ + Resource: &mcp.ResourceContents{ + URI: "file:///reports/q3.csv", + MIMEType: "text/csv", + Text: "region,revenue\nEMEA,42\n", + }, + }, + // A resource link only points at a resource. Prefer it when the + // client may not need the contents, since an embedded resource + // is transferred either way. + &mcp.ResourceLink{URI: "file:///reports/q3.pdf", MIMEType: "application/pdf"}, + }, + }, nil, nil + }) + + ctx := context.Background() + session, err := connect(ctx, server) + if err != nil { + log.Fatal(err) + } + defer session.Close() + + res, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "report"}) + if err != nil { + log.Fatal(err) + } + + // Clients type-switch over the content they receive. + for _, content := range res.Content { + switch c := content.(type) { + case *mcp.TextContent: + fmt.Println("text:", c.Text) + case *mcp.ImageContent: + fmt.Printf("image: %s, %d bytes\n", c.MIMEType, len(c.Data)) + case *mcp.AudioContent: + fmt.Printf("audio: %s, %d bytes\n", c.MIMEType, len(c.Data)) + case *mcp.EmbeddedResource: + fmt.Printf("embedded resource: %s (%s)\n", c.Resource.URI, c.Resource.MIMEType) + case *mcp.ResourceLink: + fmt.Printf("resource link: %s (%s)\n", c.URI, c.MIMEType) + } + } + // Output: + // text: Q3 revenue by region. + // image: image/png, 8 bytes + // audio: audio/wav, 12 bytes + // embedded resource: file:///reports/q3.csv (text/csv) + // resource link: file:///reports/q3.pdf (application/pdf) +} +``` + +`ToolUseContent` and `ToolResultContent` also implement `Content`, but are only +valid in sampling messages, not in tool results. + **Stateless server deployments:** Some deployments create a new [`Server`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server) for each incoming request, re-registering tools every time. To avoid repeated @@ -434,6 +760,86 @@ func handleRequest(w http.ResponseWriter, r *http.Request) { } ``` +## List changed notifications + +Adding or removing a feature on a connected server sends the matching +`notifications/*/list_changed` to every client, which dispatches it to +[`ClientOptions.ToolListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ToolListChangedHandler), +[`PromptListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.PromptListChangedHandler), +or +[`ResourceListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ResourceListChangedHandler). +A notification reports only that the list changed, so a client that needs the +new contents lists them again. + +These notifications ride on capabilities, and capabilities are inferred from +what is registered before [`Server.Connect`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.Connect) +(see [Capabilities](#capabilities)). A server that registers its features +afterwards, as the example below does, has to declare +`ServerOptions.HasTools`, `HasPrompts`, or `HasResources` itself, or the +client is never told. + +```go +func Example_listChanged() { + ctx := context.Background() + + changed := make(chan string, 3) + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + ToolListChangedHandler: func(context.Context, *mcp.ToolListChangedRequest) { + changed <- "tools" + }, + PromptListChangedHandler: func(context.Context, *mcp.PromptListChangedRequest) { + changed <- "prompts" + }, + ResourceListChangedHandler: func(context.Context, *mcp.ResourceListChangedRequest) { + changed <- "resources" + }, + }) + + // Nothing is registered before Connect, so the capabilities that carry these + // notifications have to be declared explicitly. + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ + HasTools: true, + HasPrompts: true, + HasResources: true, + }) + + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + mcp.AddTool(s, &mcp.Tool{Name: "greet"}, func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{}, nil, nil + }) + fmt.Println("changed:", <-changed) + + s.AddPrompt(&mcp.Prompt{Name: "review"}, func(context.Context, *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return &mcp.GetPromptResult{}, nil + }) + fmt.Println("changed:", <-changed) + + s.AddResource(&mcp.Resource{URI: "file:///a"}, func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{}, nil + }) + fmt.Println("changed:", <-changed) + + // Removing a feature notifies the client just as adding one does. + s.RemoveTools("greet") + fmt.Println("changed:", <-changed) + + // Output: + // changed: tools + // changed: prompts + // changed: resources + // changed: tools +} +``` + ## Multi Round-Trip Requests [SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322) diff --git a/internal/docs/client.src.md b/internal/docs/client.src.md index 5c4b8f9..fdaef2a 100644 --- a/internal/docs/client.src.md +++ b/internal/docs/client.src.md @@ -35,6 +35,16 @@ pattern. %include ../../mcp/client_example_test.go roots - +### Roots list changed + +`Client.AddRoots` and `Client.RemoveRoots` notify every connected server that +the list changed. Servers observe this through +[`ServerOptions.RootsListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.RootsListChangedHandler); +as with the server-side list-changed notifications, it reports only that +something changed, so read the list back with `ServerSession.ListRoots`. + +%include ../../mcp/client_example_test.go rootslistchanged - + ## Sampling > **Note:** The sampling feature is deprecated as of protocol version @@ -84,6 +94,48 @@ pattern. %include ../../mcp/client_example_test.go elicitation - +### Schema defaults and enums + +`ElicitParams.RequestedSchema` is a flat schema of primitive fields, which the +client renders as a form. Two field keywords shape that form. + +A `Default` ([SEP-1034](https://modelcontextprotocol.io/seps/1034)) prefills a +field. When the user accepts without supplying it, the SDK fills the field in +from the schema before the result reaches either side's caller — the client +does so after its elicitation handler returns, and `ServerSession.Elicit` does +so again on receipt. This is unconditional; there is no opt-in flag. Marking a +defaulted field `Required` defeats it: accepted content is validated against +the schema before defaults are applied, so an answer that omits the field is +rejected rather than defaulted. + +An `Enum` ([SEP-1330](https://modelcontextprotocol.io/seps/1330)) restricts a +field to a fixed set of values, which the client renders as a choice. Enums +are supported only on `"string"` fields; declaring one on another type is +rejected. To label the choices, set the legacy `enumNames` keyword through +`Schema.Extra`, with exactly one name per enum value — a mismatched length is +rejected. + +%include ../../mcp/client_example_test.go elicitationschema - + +### Completing a URL elicitation + +In URL mode the user finishes out of band, in a browser, so nothing in the +elicitation result tells the client when they are done. The server signals that +with +[`ServerSession.NotifyElicitationComplete`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.NotifyElicitationComplete), +passing the same `ElicitationID` the request carried; the client observes it +through +[`ClientOptions.ElicitationCompleteHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ElicitationCompleteHandler). +Send it from whatever endpoint the hosted flow redirects back to. + +The notification matters most when a handler rejects a request with +[`URLElicitationRequiredError`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#URLElicitationRequiredError): +the client parks the original request until a notification names that +`ElicitationID`, and then retries it automatically. Until one arrives, the +client waits. + +%include ../../mcp/client_example_test.go elicitationcomplete - + ## Multi Round-Trip Requests [SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322) diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index e26fcd7..1eb3c2f 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -267,6 +267,34 @@ _See [examples/server/distributed](https://github.com/modelcontextprotocol/go-sd an example using stateless mode to implement a server distributed across multiple processes._ +### SSE Transport (legacy) + +Before the streamable transport, the +[2024-11-05](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports) +spec defined an HTTP transport built from two endpoints: a hanging GET that +streams server-to-client messages as server-sent events, and a per-session +endpoint the client POSTs its messages to. The SDK still implements it, for +talking to peers that predate the streamable transport. New deployments should +use the [streamable transport](#streamable-transport) instead. + +It is implemented across three types, mirroring the streamable ones: + +- [`SSEHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#SSEHandler): + an `http.Handler` that creates a session per incoming GET and routes POSTs to + the right session. Construct it with + [`NewSSEHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#NewSSEHandler), + which takes a function returning the `Server` to serve a given request. +- [`SSEServerTransport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#SSEServerTransport): + one such session. Reach for it directly only when serving the two endpoints + yourself; `SSEHandler` creates these for you. +- [`SSEClientTransport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#SSEClientTransport): + the client side, which needs only the `Endpoint` of the GET. + +%include ../../mcp/sse_example_test.go ssehandler - + +_See [examples/server/sse](https://github.com/modelcontextprotocol/go-sdk/blob/main/examples/server/sse/main.go) +for a standalone server._ + ### Custom transports The SDK supports [custom diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index 756bba3..cbc5689 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -33,6 +33,24 @@ notification. %include ../../mcp/server_example_test.go prompts - +### Prompt message content + +A +[`PromptMessage`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#PromptMessage) +has two fields: a `Role`, either `"user"` or `"assistant"`, and a single +`Content`. That is the same interface a tool result uses (see [Tool result +content](#tool-result-content)), so a prompt message can hold an image, audio, +or a resource's contents as readily as text. + +Media on its own says nothing about what the model should do with it, so send a +text message alongside it. Embedding a resource puts its contents directly in +the message, sparing the client a separate `resources/read`. Note that +`ResourceContents.URI` is not checked against the resources the server has +registered: it records where the contents came from, so use the URI of a real +resource when there is one, as in the example below. + +%include ../../mcp/server_example_test.go promptcontent - + ## Resources In MCP terms, a _resource_ is some data referenced by a URI. @@ -94,6 +112,37 @@ notification. %include ../../mcp/server_example_test.go resources - +### Binary resources + +A +[`ResourceContents`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ResourceContents) +carries its data in either `Text` or `Blob`. Binary data goes in `Blob` as +plain bytes — the SDK base64-encodes it on the wire — and leaves `Text` empty, +so a client tells the two apart by which field is populated. + +%include ../../mcp/server_example_test.go binaryresource - + +### Resource subscriptions + +A client interested in changes to one resource subscribes to its URI with +[`ClientSession.Subscribe`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Subscribe) +and stops with +[`ClientSession.Unsubscribe`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Unsubscribe). +Set +[`ServerOptions.SubscribeHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.SubscribeHandler) +and +[`UnsubscribeHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.UnsubscribeHandler) +to track who is listening; setting either one gives the server the +`resources.subscribe` capability. + +Publish a change with +[`Server.ResourceUpdated`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.ResourceUpdated), +which notifies every session subscribed to that URI. The notification carries +only the URI, not the new contents, so a client that wants them reads the +resource again. + +%include ../../mcp/server_example_test.go subscribe - + ## Tools MCP servers can provide @@ -145,7 +194,7 @@ In order to implement a tool, the user must do all of the following: - Unmarshal the input schema into a Go value - Execute the tool logic. - Marshal the tool's structured output (if any) to JSON, and store it in the - result's `StructuredOutput` field as well as the unstructured `Content` field. + result's `StructuredContent` field as well as the unstructured `Content` field. - Validate that output JSON against the tool's output schema. - If any tool errors occurred, pack them into the unstructured content and set `IsError` to `true.` @@ -180,7 +229,7 @@ This does the following automatically: - Tool arguments are validated against the input schema. - Tool arguments are marshaled into the `In` value. - Tool output (the `Out` value) is marshaled into the result's - `StructuredOutput`, as well as the unstructured `Content`. + `StructuredContent`, as well as the unstructured `Content`. - Output is validated against the tool's output schema. - If an ordinary error is returned, it is stored int the `CallToolResult` and `IsError` is set to `true`. @@ -202,6 +251,39 @@ _See [mcp/tool_example_test.go](https://github.com/modelcontextprotocol/go-sdk/b example, or [examples/server/toolschemas](https://github.com/modelcontextprotocol/go-sdk/blob/main/examples/server/toolschemas/main.go) for more examples of customizing tool schemas._ +### Tool result content + +Alongside its structured output, a tool result carries a list of +[`Content`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Content) +blocks in `CallToolResult.Content`, and may mix as many kinds as it needs: + +| Type | Carries | +| --- | --- | +| [`TextContent`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#TextContent) | plain text | +| [`ImageContent`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ImageContent) | image data, with a MIME type | +| [`AudioContent`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#AudioContent) | audio data, with a MIME type | +| [`EmbeddedResource`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#EmbeddedResource) | the contents of a resource, inline | +| [`ResourceLink`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ResourceLink) | a reference to a resource the client can read separately | + +`ImageContent.Data` and `AudioContent.Data` are plain `[]byte`; the SDK +base64-encodes them on the wire, so handlers never encode by hand, and base64 +carried over from another MCP SDK must be decoded before it is assigned. An +`EmbeddedResource` wraps a +[`ResourceContents`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ResourceContents), +which holds either `Text` or, for binary data, `Blob`. Prefer a `ResourceLink` +when the client may not need the contents, since an embedded resource is +transferred whether it is used or not. + +When a handler bound with `AddTool` leaves `Content` unset, the SDK fills it +with the JSON encoding of the output value; set `Content` explicitly, as below, +to return anything else. `StructuredContent` is still populated from the output +value either way. + +%include ../../mcp/tool_example_test.go contenttypes - + +`ToolUseContent` and `ToolResultContent` also implement `Content`, but are only +valid in sampling messages, not in tool results. + **Stateless server deployments:** Some deployments create a new [`Server`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server) for each incoming request, re-registering tools every time. To avoid repeated @@ -219,6 +301,26 @@ func handleRequest(w http.ResponseWriter, r *http.Request) { } ``` +## List changed notifications + +Adding or removing a feature on a connected server sends the matching +`notifications/*/list_changed` to every client, which dispatches it to +[`ClientOptions.ToolListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ToolListChangedHandler), +[`PromptListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.PromptListChangedHandler), +or +[`ResourceListChangedHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ResourceListChangedHandler). +A notification reports only that the list changed, so a client that needs the +new contents lists them again. + +These notifications ride on capabilities, and capabilities are inferred from +what is registered before [`Server.Connect`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#Server.Connect) +(see [Capabilities](#capabilities)). A server that registers its features +afterwards, as the example below does, has to declare +`ServerOptions.HasTools`, `HasPrompts`, or `HasResources` itself, or the +client is never told. + +%include ../../mcp/server_example_test.go listchanged - + ## Multi Round-Trip Requests [SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322) diff --git a/internal/readme/contributing.src.md b/internal/readme/contributing.src.md index 08fc9b5..f2e931b 100644 --- a/internal/readme/contributing.src.md +++ b/internal/readme/contributing.src.md @@ -173,6 +173,57 @@ The CI system will automatically check that the README is up-to-date by running `go generate ./internal/readme` and verifying no changes result. If you see a CI failure about the README being out of sync, follow the steps above to regenerate it. +## Versioning + +The SDK follows [semantic versioning](https://semver.org), as the Go module +system requires: the major version is part of the module path, so a breaking +release would import as `github.com/modelcontextprotocol/go-sdk/v2`. A breaking +change therefore cannot reach existing users by accident; they have to change +their import path to receive one. + +This policy covers the exported API of the SDK's importable packages — `mcp`, +`jsonrpc`, `auth`, `auth/extauth` and `oauthex`. Everything under `internal/` +is not importable outside the module and may change in any release. + +Which MCP spec revisions each SDK version speaks is documented in the +[README](README.md#version-compatibility). Support for a new revision arrives +in a minor release; dropping a revision the SDK previously negotiated is a +breaking change. + +### What counts as a breaking change + +- Removing or renaming an exported identifier. +- Changing the signature of an exported function or method. +- Removing or renaming a field of an exported struct, or changing its type. +- Adding a method to an exported interface that users are meant to implement. +- Changing documented behavior that callers could reasonably rely on. +- Dropping support for an MCP spec revision. + +The following are not breaking, and may land in a minor or patch release: + +- Adding an exported identifier, or a field to an exported struct. +- Fixing behavior that contradicted its documentation. +- Refactoring that leaves the exported API unchanged. +- Adding support for a new MCP spec revision. +- Raising the minimum Go version to one still supported upstream, per the + [README](README.md#version-compatibility). + +### Deprecation + +An API on its way out is marked with a `// Deprecated:` comment naming its +replacement, which `gopls` and `staticcheck` surface at call sites. It keeps +working until the next major version, since removing it would itself be a +breaking change. + +Protocol features that the *specification* deprecates are a separate matter and +follow the spec's timeline rather than this policy. The features deprecated by +[SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging) +— roots, sampling and logging — remain supported for at least twelve months, as +noted in the [README](README.md#version-compatibility). + +Any change to the exported API, breaking or not, goes through the +[proposal process](#proposals). + ## Timeouts If a contributor hasn't responded to issue questions or PR comments in two weeks, diff --git a/mcp/client_example_test.go b/mcp/client_example_test.go index a741bbf..98ac3b1 100644 --- a/mcp/client_example_test.go +++ b/mcp/client_example_test.go @@ -6,6 +6,7 @@ package mcp_test import ( "context" + "encoding/json" "fmt" "log" @@ -65,6 +66,61 @@ func Example_roots() { // !-roots +// !+rootslistchanged + +func Example_rootsListChanged() { + ctx := context.Background() + + changed := make(chan struct{}, 2) + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ + RootsListChangedHandler: func(context.Context, *mcp.RootsListChangedRequest) { + changed <- struct{}{} + }, + }) + + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + c.AddRoots(&mcp.Root{URI: "file:///project"}) + + t1, t2 := mcp.NewInMemoryTransports() + ss, err := s.Connect(ctx, t1, nil) + if err != nil { + log.Fatal(err) + } + defer ss.Close() + + // ListRoots is a server-initiated request, so this session negotiates a + // protocol version that still allows one. + cs, err := c.Connect(ctx, t2, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"}) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + // Roots added after the client connects notify every connected server. + c.AddRoots(&mcp.Root{URI: "file:///scratch"}) + <-changed + + // The notification says only that the list changed, so read it back. + res, err := ss.ListRoots(ctx, nil) + if err != nil { + log.Fatal(err) + } + for _, root := range res.Roots { + fmt.Println(root.URI) + } + + c.RemoveRoots("file:///scratch") + <-changed + fmt.Println("roots changed again") + + // Output: + // file:///project + // file:///scratch + // roots changed again +} + +// !-rootslistchanged + // !+sampling func Example_sampling() { @@ -167,3 +223,117 @@ func Example_elicitation() { } // !-elicitation + +// !+elicitationschema + +func Example_elicitationSchema() { + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(s, &mcp.Tool{Name: "export_report"}, func(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + return &mcp.CallToolResult{ + InputRequests: mcp.InputRequestMap{"format": &mcp.ElicitParams{ + Message: "Export quarterly-sales as which format?", + RequestedSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "format": { + Type: "string", + Title: "Format", + Enum: []any{"pdf", "csv"}, + Default: json.RawMessage(`"pdf"`), + Extra: map[string]any{"enumNames": []any{"PDF document", "CSV spreadsheet"}}, + }, + }, + }, + }}, + }, nil, nil + } + res := req.Params.InputResponses["format"].(*mcp.ElicitResult) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "Exported as " + res.Content["format"].(string)}}, + }, nil, nil + }) + if _, err := s.Connect(ctx, st, nil); err != nil { + log.Fatal(err) + } + + // The user accepts without filling anything in. + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + ElicitationHandler: func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{}}, nil + }, + }) + cs, err := c.Connect(ctx, ct, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "export_report"}) + if err != nil { + log.Fatal(err) + } + fmt.Println(res.Content[0].(*mcp.TextContent).Text) + // Output: Exported as pdf +} + +// !-elicitationschema + +// !+elicitationcomplete + +func Example_elicitationComplete() { + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + log.Fatal(err) + } + defer ss.Close() + + done := make(chan struct{}) + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + Capabilities: &mcp.ClientCapabilities{ + Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}, + }, + ElicitationHandler: func(_ context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + fmt.Println("opening", req.Params.URL) + return &mcp.ElicitResult{Action: "accept"}, nil + }, + ElicitationCompleteHandler: func(_ context.Context, req *mcp.ElicitationCompleteNotificationRequest) { + fmt.Println("flow finished:", req.Params.ElicitationID) + close(done) + }, + }) + cs, err := c.Connect(ctx, ct, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"}) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + const elicitationID = "connect-calendar-1" + if _, err := ss.Elicit(ctx, &mcp.ElicitParams{ + Message: "Grant calendar access", + URL: "https://calendar.example.com/consent?state=" + elicitationID, + ElicitationID: elicitationID, + }); err != nil { + log.Fatal(err) + } + + // The hosted page redirects back to the server, whose callback endpoint + // signals that the user is done. + if err := ss.NotifyElicitationComplete(ctx, &mcp.ElicitationCompleteParams{ElicitationID: elicitationID}); err != nil { + log.Fatal(err) + } + <-done + + // Output: + // opening https://calendar.example.com/consent?state=connect-calendar-1 + // flow finished: connect-calendar-1 +} + +// !-elicitationcomplete diff --git a/mcp/server_example_test.go b/mcp/server_example_test.go index 5e95585..ae4318f 100644 --- a/mcp/server_example_test.go +++ b/mcp/server_example_test.go @@ -88,6 +88,77 @@ func Example_prompts() { // !-prompts +// !+promptcontent + +func Example_promptContent() { + ctx := context.Background() + + const styleGuide = "- Prefer clarity over cleverness.\n" + screenshotPNG := []byte("\x89PNG\r\n\x1a\n") + + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + + s.AddResource(&mcp.Resource{URI: "doc://style-guide", MIMEType: "text/markdown"}, + func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{ + URI: "doc://style-guide", + MIMEType: "text/markdown", + Text: styleGuide, + }}, + }, nil + }) + + s.AddPrompt(&mcp.Prompt{Name: "review_screenshot"}, + func(context.Context, *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return &mcp.GetPromptResult{ + Messages: []*mcp.PromptMessage{ + {Role: "user", Content: &mcp.EmbeddedResource{ + Resource: &mcp.ResourceContents{ + URI: "doc://style-guide", + MIMEType: "text/markdown", + Text: styleGuide, + }, + }}, + {Role: "user", Content: &mcp.ImageContent{Data: screenshotPNG, MIMEType: "image/png"}}, + {Role: "user", Content: &mcp.TextContent{Text: "Review the screenshot against the style guide."}}, + }, + }, nil + }) + + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + res, err := cs.GetPrompt(ctx, &mcp.GetPromptParams{Name: "review_screenshot"}) + if err != nil { + log.Fatal(err) + } + for _, msg := range res.Messages { + switch content := msg.Content.(type) { + case *mcp.TextContent: + fmt.Println(msg.Role, "text:", content.Text) + case *mcp.ImageContent: + fmt.Printf("%s image: %s, %d bytes\n", msg.Role, content.MIMEType, len(content.Data)) + case *mcp.EmbeddedResource: + fmt.Printf("%s embedded resource: %s (%s)\n", msg.Role, content.Resource.URI, content.Resource.MIMEType) + } + } + // Output: + // user embedded resource: doc://style-guide (text/markdown) + // user image: image/png, 8 bytes + // user text: Review the screenshot against the style guide. +} + +// !-promptcontent + // !+logging func Example_logging() { @@ -222,6 +293,186 @@ func Example_resources() { // !-resources +// !+listchanged + +func Example_listChanged() { + ctx := context.Background() + + changed := make(chan string, 3) + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + ToolListChangedHandler: func(context.Context, *mcp.ToolListChangedRequest) { + changed <- "tools" + }, + PromptListChangedHandler: func(context.Context, *mcp.PromptListChangedRequest) { + changed <- "prompts" + }, + ResourceListChangedHandler: func(context.Context, *mcp.ResourceListChangedRequest) { + changed <- "resources" + }, + }) + + // Nothing is registered before Connect, so the capabilities that carry these + // notifications have to be declared explicitly. + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ + HasTools: true, + HasPrompts: true, + HasResources: true, + }) + + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + mcp.AddTool(s, &mcp.Tool{Name: "greet"}, func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{}, nil, nil + }) + fmt.Println("changed:", <-changed) + + s.AddPrompt(&mcp.Prompt{Name: "review"}, func(context.Context, *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return &mcp.GetPromptResult{}, nil + }) + fmt.Println("changed:", <-changed) + + s.AddResource(&mcp.Resource{URI: "file:///a"}, func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{}, nil + }) + fmt.Println("changed:", <-changed) + + // Removing a feature notifies the client just as adding one does. + s.RemoveTools("greet") + fmt.Println("changed:", <-changed) + + // Output: + // changed: tools + // changed: prompts + // changed: resources + // changed: tools +} + +// !-listchanged + +// !+subscribe + +func Example_resourceSubscription() { + ctx := context.Background() + + config := "theme=light\n" + + updated := make(chan string, 1) + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{ + // The notification carries only the URI, so read the resource again to + // see what it now holds. + ResourceUpdatedHandler: func(context.Context, *mcp.ResourceUpdatedNotificationRequest) { + updated <- "config://app" + }, + }) + + subscribed := make(chan string, 2) + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, &mcp.ServerOptions{ + SubscribeHandler: func(_ context.Context, req *mcp.SubscribeRequest) error { + subscribed <- "subscribed to " + req.Params.URI + return nil + }, + UnsubscribeHandler: func(_ context.Context, req *mcp.UnsubscribeRequest) error { + subscribed <- "unsubscribed from " + req.Params.URI + return nil + }, + }) + s.AddResource(&mcp.Resource{URI: "config://app"}, + func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{URI: "config://app", Text: config}}, + }, nil + }) + + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + if err := cs.Subscribe(ctx, &mcp.SubscribeParams{URI: "config://app"}); err != nil { + log.Fatal(err) + } + fmt.Println(<-subscribed) + + config = "theme=dark\n" + if err := s.ResourceUpdated(ctx, &mcp.ResourceUpdatedNotificationParams{URI: "config://app"}); err != nil { + log.Fatal(err) + } + uri := <-updated + + res, err := cs.ReadResource(ctx, &mcp.ReadResourceParams{URI: uri}) + if err != nil { + log.Fatal(err) + } + fmt.Print("re-read: ", res.Contents[0].Text) + + if err := cs.Unsubscribe(ctx, &mcp.UnsubscribeParams{URI: "config://app"}); err != nil { + log.Fatal(err) + } + fmt.Println(<-subscribed) + + // Output: + // subscribed to config://app + // re-read: theme=dark + // unsubscribed from config://app +} + +// !-subscribe + +// !+binaryresource + +func Example_binaryResource() { + ctx := context.Background() + + s := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + s.AddResource(&mcp.Resource{URI: "file:///logo.png", MIMEType: "image/png"}, + func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{ + URI: "file:///logo.png", + MIMEType: "image/png", + Blob: []byte("\x89PNG\r\n\x1a\n"), + }}, + }, nil + }) + + c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, nil) + t1, t2 := mcp.NewInMemoryTransports() + if _, err := s.Connect(ctx, t1, nil); err != nil { + log.Fatal(err) + } + cs, err := c.Connect(ctx, t2, nil) + if err != nil { + log.Fatal(err) + } + defer cs.Close() + + res, err := cs.ReadResource(ctx, &mcp.ReadResourceParams{URI: "file:///logo.png"}) + if err != nil { + log.Fatal(err) + } + contents := res.Contents[0] + fmt.Printf("%s: %d bytes of %s, text is %q\n", + contents.URI, len(contents.Blob), contents.MIMEType, contents.Text) + + // Output: + // file:///logo.png: 8 bytes of image/png, text is "" +} + +// !-binaryresource + // !+mrtr // Example_mrtr demonstrates the [Multi Round-Trip Requests] pattern diff --git a/mcp/sse_example_test.go b/mcp/sse_example_test.go index 6132d31..434f6de 100644 --- a/mcp/sse_example_test.go +++ b/mcp/sse_example_test.go @@ -27,6 +27,8 @@ func Add(ctx context.Context, req *mcp.CallToolRequest, args AddParams) (*mcp.Ca }, nil, nil } +// !+ssehandler + func ExampleSSEHandler() { server := mcp.NewServer(&mcp.Implementation{Name: "adder", Version: "v0.0.1"}, nil) mcp.AddTool(server, &mcp.Tool{Name: "add", Description: "add two numbers"}, Add) @@ -55,3 +57,5 @@ func ExampleSSEHandler() { // Output: 3 } + +// !-ssehandler diff --git a/mcp/tool.go b/mcp/tool.go index 04cf551..11ad100 100644 --- a/mcp/tool.go +++ b/mcp/tool.go @@ -43,7 +43,7 @@ type ToolHandler func(context.Context, *CallToolRequest) (*CallToolResult, error // - If the Out type is not the empty interface [any], it provides the // default output schema for the tool (which again may be overridden in // [AddTool]). -// - The Out value is used to populate result.StructuredOutput. +// - The Out value is used to populate result.StructuredContent. // - If [CallToolResult.Content] is unset, it is populated with the JSON // content of the output. // - An error result is treated as a tool error, rather than a protocol diff --git a/mcp/tool_example_test.go b/mcp/tool_example_test.go index 8efc3ee..610755b 100644 --- a/mcp/tool_example_test.go +++ b/mcp/tool_example_test.go @@ -253,6 +253,74 @@ func ExampleAddTool_complexSchema() { // weather types: [sun partly_cloudy clouds rain snow] } +// !+contenttypes + +func ExampleAddTool_contentTypes() { + chartPNG := []byte("\x89PNG\r\n\x1a\n") + summaryWAV := []byte("RIFF....WAVE") + + server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v0.0.1"}, nil) + mcp.AddTool(server, &mcp.Tool{Name: "report"}, func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "Q3 revenue by region."}, + &mcp.ImageContent{Data: chartPNG, MIMEType: "image/png"}, + &mcp.AudioContent{Data: summaryWAV, MIMEType: "audio/wav"}, + // An embedded resource inlines the contents, so the client + // needs no follow-up resources/read. Binary contents go in + // ResourceContents.Blob instead of Text. + &mcp.EmbeddedResource{ + Resource: &mcp.ResourceContents{ + URI: "file:///reports/q3.csv", + MIMEType: "text/csv", + Text: "region,revenue\nEMEA,42\n", + }, + }, + // A resource link only points at a resource. Prefer it when the + // client may not need the contents, since an embedded resource + // is transferred either way. + &mcp.ResourceLink{URI: "file:///reports/q3.pdf", MIMEType: "application/pdf"}, + }, + }, nil, nil + }) + + ctx := context.Background() + session, err := connect(ctx, server) + if err != nil { + log.Fatal(err) + } + defer session.Close() + + res, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "report"}) + if err != nil { + log.Fatal(err) + } + + // Clients type-switch over the content they receive. + for _, content := range res.Content { + switch c := content.(type) { + case *mcp.TextContent: + fmt.Println("text:", c.Text) + case *mcp.ImageContent: + fmt.Printf("image: %s, %d bytes\n", c.MIMEType, len(c.Data)) + case *mcp.AudioContent: + fmt.Printf("audio: %s, %d bytes\n", c.MIMEType, len(c.Data)) + case *mcp.EmbeddedResource: + fmt.Printf("embedded resource: %s (%s)\n", c.Resource.URI, c.Resource.MIMEType) + case *mcp.ResourceLink: + fmt.Printf("resource link: %s (%s)\n", c.URI, c.MIMEType) + } + } + // Output: + // text: Q3 revenue by region. + // image: image/png, 8 bytes + // audio: audio/wav, 12 bytes + // embedded resource: file:///reports/q3.csv (text/csv) + // resource link: file:///reports/q3.pdf (application/pdf) +} + +// !-contenttypes + func connect(ctx context.Context, server *mcp.Server) (*mcp.ClientSession, error) { t1, t2 := mcp.NewInMemoryTransports() if _, err := server.Connect(ctx, t1, nil); err != nil { From 810e6cb751ddfad47d96d43fd0d9675346eafae0 Mon Sep 17 00:00:00 2001 From: Anton Dziatkovskii Date: Tue, 25 Aug 2026 08:26:21 +0100 Subject: [PATCH 382/418] docs: make the protocol.md table of contents resolve (#1196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hi — I am an AI agent (Claude), working as Anton Dzyatkovsky's synthetic co-founder. Same author as #1159 (dead MRTR links in `client.md`), same genre, but this one has a cause worth naming rather than five links worth patching. **AI disclosure** (per [AI_POLICY.md](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/AI_POLICY.md)): this contribution — the investigation, the diff, the test and this description — was written by Claude Code running autonomously; Anton is the accountable human and reachable on this thread. Every measurement quoted below was produced by running the thing, not inferred: the id tables are scraped from the two rendered surfaces, the test's slug function was checked against 31 of GitHub's real ids, and the failure output is a real run against the pre-fix tree. Any reply from this account is written the same way unless it says otherwise. I edited only `internal/docs/**` and regenerated, per `AGENTS.md`. ## What is broken Five table-of-contents entries in `docs/protocol.md` point at anchors that no heading produces. They are dead on **both** surfaces — GitHub's render of the file, and the published site. | ToC entry links to | heading actually gets | |---|---| | `#discovery-(server/discover)` | `discovery-serverdiscover` | | `#per-request-meta-keys` | `per-request-_meta-keys` | | `#per-response-meta-keys` | `per-response-_meta-keys` | | `#subscriptions-(subscriptions/listen)` | `subscriptions-subscriptionslisten` | | `#sse-transport-(legacy)` | `sse-transport-legacy` | Right-hand column is scraped, not derived: `user-content-*` ids from GitHub's rendered `docs/protocol.md`, and `` from `https://go.sdk.modelcontextprotocol.io/protocol`. Four of the five are dead on the live site too; `sse-transport-legacy` is not in the currently deployed build, so I could only confirm that one on GitHub. ## Why `%toc` and the renderer slug the same heading by different rules, and they disagree on exactly the punctuation these headings use: * **weave** keeps `(`, `)` and `/`, and drops `_` * **GitHub and python-markdown's `toc`** drop `(`, `)` and `/`, and keep `_` So the two agree on plain words and part ways the moment a heading carries a method name in parentheses or a `_meta` field. This is a class, not five typos: any future heading with those characters gets a dead ToC entry, silently — the docs regenerate fine, `docs-check` passes, and only a reader clicking the ToC finds out. Someone already hit this, by hand: `internal/docs/server.src.md` links to `protocol.md#subscriptions-subscriptionslisten`, i.e. the real renderer id, not the one the ToC uses. That cross-link is the odd one out today; the ToC is what is wrong. ## The fix The characters have to leave the headings, because weave computes the ToC and it is not in this repo. Each of the five sections already names its RPC or field in its first sentence, so the heading loses nothing: | was | becomes | the name is still in the first sentence | |---|---|---| | Discovery (``server/discover``) | **Discovery** | "the `server/discover` RPC lets a client discover…" | | Per-request ``_meta`` keys | **Per-request metadata keys** | "carries these keys inside its `_meta` map" | | Per-response ``_meta`` keys | **Per-response metadata keys** | "servers SHOULD identify themselves on every response" | | Subscriptions (``subscriptions/listen``) | **Subscriptions** | "`subscriptions/listen` replaces the legacy…" | | SSE Transport (legacy) | **Legacy SSE Transport** | "Before the streamable transport, the 2024-11-05 spec…" | Plus the one hand-written cross-link in `server.src.md`, updated to `protocol.md#subscriptions`. **The cost, stated plainly:** renaming a heading changes its id, so any external deep link to `#discovery-serverdiscover` or `#per-request-_meta-keys` breaks. Those ids are only reachable today by someone who scrolled and copied the permalink — the ToC never produced them — so I think the trade is worth it, but it is your call. **The alternative is to fix the slug in weave** (`golang.org/x/example/internal/cmd/weave`) and leave the headings alone. I am happy to take it there instead if you would rather keep the ids; say the word and I will close this. ## The guard `internal/docs/toc_test.go` — reads the generated docs and checks every `%toc` entry against the id its heading will actually get. No network, no new dependency. The slug function in it is validated, not asserted: run over all 31 headings of the current `docs/protocol.md`, it reproduces **31 of 31** of the ids GitHub actually emitted for that file (and 30 of 31 on the live site — the miss is the not-yet-deployed SSE section above). Behaviour on the pre-fix tree, which is the point of it: ``` --- FAIL: TestTOCAnchorsResolve protocol.md: table-of-contents entry "Discovery (`server/discover`)" points at #discovery-(server/discover), which no heading produces (want #discovery-serverdiscover); drop (, ), / and _ from the heading ... and the other four, each named ``` All five, by name, with the id they should have had. On this branch it passes. ## Runs `go generate ./...` → no diff (idempotent, so `docs-check` stays green) · `go vet ./...` clean · `go build ./...` clean · `go test ./...` → 14 packages ok, 0 failures, 26 without tests. Docs and one new test only; no SDK code touched. Co-authored-by: Anton Dziatkovskii <194927794+tonydzi@users.noreply.github.com> --- docs/protocol.md | 20 +++++----- docs/server.md | 2 +- internal/docs/protocol.src.md | 10 ++--- internal/docs/server.src.md | 2 +- internal/docs/toc_test.go | 70 +++++++++++++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 internal/docs/toc_test.go diff --git a/docs/protocol.md b/docs/protocol.md index d40ed91..47ae2c3 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -2,14 +2,14 @@ # Support for the MCP base protocol 1. [Lifecycle](#lifecycle) - 1. [Discovery (`server/discover`)](#discovery-(server/discover)) - 1. [Per-request `_meta` keys](#per-request-meta-keys) - 1. [Per-response `_meta` keys](#per-response-meta-keys) - 1. [Subscriptions (`subscriptions/listen`)](#subscriptions-(subscriptions/listen)) + 1. [Discovery](#discovery) + 1. [Per-request metadata keys](#per-request-metadata-keys) + 1. [Per-response metadata keys](#per-response-metadata-keys) + 1. [Subscriptions](#subscriptions) 1. [Transports](#transports) 1. [Stdio Transport](#stdio-transport) 1. [Streamable Transport](#streamable-transport) - 1. [SSE Transport (legacy)](#sse-transport-(legacy)) + 1. [Legacy SSE Transport](#legacy-sse-transport) 1. [Custom transports](#custom-transports) 1. [Concurrency](#concurrency) 1. [Authorization](#authorization) @@ -111,7 +111,7 @@ func Example_lifecycle() { } ``` -### Discovery (`server/discover`) +### Discovery Introduced in `2026-07-28` by [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), @@ -128,7 +128,7 @@ request. Servers implementing `2026-07-28` MUST implement it. fails or the server does not support the latest version, the client falls back to the legacy `initialize` handshake. -### Per-request `_meta` keys +### Per-request metadata keys When the negotiated protocol version is `2026-07-28` or later, every request carries these keys inside its `_meta` map (constants live in @@ -147,7 +147,7 @@ request, and populates `clientInfo` when configured with an `*Implementation` `ServerRequest[P].ProtocolVersion()`, `ServerRequest[P].ClientInfo()`, and `ServerRequest[P].ClientCapabilities()`. -### Per-response `_meta` keys +### Per-response metadata keys Under the same protocol version, servers SHOULD identify themselves on every response. The SDK populates this @@ -157,7 +157,7 @@ key automatically on every outgoing response: |---|---|---|---| | `MetaKeyServerInfo` | `io.modelcontextprotocol/serverInfo` | `*Implementation` | No | -### Subscriptions (`subscriptions/listen`) +### Subscriptions Introduced in `2026-07-28` by [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), @@ -343,7 +343,7 @@ _See [examples/server/distributed](https://github.com/modelcontextprotocol/go-sd an example using stateless mode to implement a server distributed across multiple processes._ -### SSE Transport (legacy) +### Legacy SSE Transport Before the streamable transport, the [2024-11-05](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports) diff --git a/docs/server.md b/docs/server.md index 9eb4ec2..966da75 100644 --- a/docs/server.md +++ b/docs/server.md @@ -241,7 +241,7 @@ to be notified of changes to subscribed resources. On `2026-07-28` and later sessions the SDK delivers these notifications over a `subscriptions/listen` stream instead of the legacy `resources/subscribe` RPC; see [Subscriptions -(`subscriptions/listen`)](protocol.md#subscriptions-subscriptionslisten) +(`subscriptions/listen`)](protocol.md#subscriptions) for the wire-level details. **Server-side**: diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index 1eb3c2f..446b555 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -55,7 +55,7 @@ it is the client's responsibility to end the session. %include ../../mcp/mcp_example_test.go lifecycle - -### Discovery (`server/discover`) +### Discovery Introduced in `2026-07-28` by [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), @@ -72,7 +72,7 @@ request. Servers implementing `2026-07-28` MUST implement it. fails or the server does not support the latest version, the client falls back to the legacy `initialize` handshake. -### Per-request `_meta` keys +### Per-request metadata keys When the negotiated protocol version is `2026-07-28` or later, every request carries these keys inside its `_meta` map (constants live in @@ -91,7 +91,7 @@ request, and populates `clientInfo` when configured with an `*Implementation` `ServerRequest[P].ProtocolVersion()`, `ServerRequest[P].ClientInfo()`, and `ServerRequest[P].ClientCapabilities()`. -### Per-response `_meta` keys +### Per-response metadata keys Under the same protocol version, servers SHOULD identify themselves on every response. The SDK populates this @@ -101,7 +101,7 @@ key automatically on every outgoing response: |---|---|---|---| | `MetaKeyServerInfo` | `io.modelcontextprotocol/serverInfo` | `*Implementation` | No | -### Subscriptions (`subscriptions/listen`) +### Subscriptions Introduced in `2026-07-28` by [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), @@ -267,7 +267,7 @@ _See [examples/server/distributed](https://github.com/modelcontextprotocol/go-sd an example using stateless mode to implement a server distributed across multiple processes._ -### SSE Transport (legacy) +### Legacy SSE Transport Before the streamable transport, the [2024-11-05](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports) diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index cbc5689..8ad704a 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -87,7 +87,7 @@ to be notified of changes to subscribed resources. On `2026-07-28` and later sessions the SDK delivers these notifications over a `subscriptions/listen` stream instead of the legacy `resources/subscribe` RPC; see [Subscriptions -(`subscriptions/listen`)](protocol.md#subscriptions-subscriptionslisten) +(`subscriptions/listen`)](protocol.md#subscriptions) for the wire-level details. **Server-side**: diff --git a/internal/docs/toc_test.go b/internal/docs/toc_test.go new file mode 100644 index 0000000..23b02e7 --- /dev/null +++ b/internal/docs/toc_test.go @@ -0,0 +1,70 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package docs + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// Headings are slugged twice by two different tools: weave writes the %toc +// links, and the renderer (GitHub, and mkdocs on go.sdk.modelcontextprotocol.io) +// assigns the heading ids. The two agree on plain words, and disagree on +// punctuation: weave keeps "(", ")" and "/" and drops "_", the renderers drop +// the first three and keep the last. A heading carrying any of them therefore +// gets a table-of-contents entry that lands nowhere. +// +// This test reads the generated docs and checks every %toc entry against the +// ids the renderers will actually produce. + +var ( + headingRE = regexp.MustCompile(`(?m)^(#{1,6})\s+(.*?)\s*$`) + tocLineRE = regexp.MustCompile(`(?m)^\s*1\. \[(.*?)\]\((#.*?)\)\s*$`) + dropRE = regexp.MustCompile(`[^\w\s-]`) + spaceRE = regexp.MustCompile(`[-\s]+`) +) + +// renderedID reproduces the anchor GitHub and python-markdown's toc extension +// derive from heading text: take the text, drop everything that is not a word +// character, whitespace or a hyphen, lowercase it, and join on hyphens. +func renderedID(heading string) string { + s := strings.ReplaceAll(heading, "`", "") + s = dropRE.ReplaceAllString(s, "") + s = strings.ToLower(strings.TrimSpace(s)) + return spaceRE.ReplaceAllString(s, "-") +} + +func TestTOCAnchorsResolve(t *testing.T) { + files, err := filepath.Glob(filepath.Join("..", "..", "docs", "*.md")) + if err != nil { + t.Fatal(err) + } + if len(files) == 0 { + t.Fatal("no generated docs found") + } + for _, file := range files { + data, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + text := string(data) + + ids := make(map[string]bool) + for _, m := range headingRE.FindAllStringSubmatch(text, -1) { + ids[renderedID(m[2])] = true + } + for _, m := range tocLineRE.FindAllStringSubmatch(text, -1) { + label, target := m[1], strings.TrimPrefix(m[2], "#") + if !ids[target] { + t.Errorf("%s: table-of-contents entry %q points at #%s, which no heading produces "+ + "(want #%s); drop (, ), / and _ from the heading", + filepath.Base(file), label, target, renderedID(label)) + } + } + } +} From 32404652e60bf829006b0c6a65bba01d3cabf37b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E6=88=90=E9=94=B4?= Date: Tue, 25 Aug 2026 17:26:17 +0800 Subject: [PATCH 383/418] auth: validate unadvertised RFC 9207 iss instead of rejecting it (#1187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes #1152. Sentry, Linear, and Notion omit `authorization_response_iss_parameter_supported` from their authorization-server metadata. Sentry still sends a correct `iss` in the authorization response, so sign-in fails after the browser step: the user has already consented and an authorization code has been issued, but `validateIssuerResponse` rejects the exchange solely because the server did not advertise RFC 9207 support. RFC 9207 §2.4 says clients MUST extract and compare `iss` when the parameter is present. §2.4 also notes that clients SHOULD discard an unadvertised `iss`, but that is a recommendation and local policy may accept servers that send `iss` without advertising. Validating a matching `iss` is interoperable with those hosted MCP servers and still catches mix-up attacks: a wrong `iss` is rejected whether or not support was advertised. When `iss` is present, this change always compares it to the expected issuer (simple string equality). A match is accepted even if the server did not advertise support. A mismatch still returns `authorization response issuer %q does not match expected issuer %q`. Absence of `iss` remains an error only when the server advertised support. ## Test plan - [x] `go test ./auth/ -run TestValidateIssuerResponse -count=1` - [x] `go test ./auth/...` New `TestValidateIssuerResponse` cases: - `UnadvertisedIssCorrect`: `iss == expectedIssuer`, `issSupported == false` → no error - `UnadvertisedIssWrong`: `iss == "https://attacker.example.com"`, `issSupported == false` → error containing `does not match expected issuer` Existing advertised+match / advertised+mismatch / missing-iss cases are unchanged. --------- Co-authored-by: Guglielmo Colombo --- auth/authorization_code.go | 30 +++- auth/authorization_code_test.go | 149 +++++++++++++++++- .../oauthtest/fake_authorization_server.go | 10 +- 3 files changed, 173 insertions(+), 16 deletions(-) diff --git a/auth/authorization_code.go b/auth/authorization_code.go index 1673a2e..f349842 100644 --- a/auth/authorization_code.go +++ b/auth/authorization_code.go @@ -124,6 +124,11 @@ type AuthorizationCodeHandlerConfig struct { // See https://modelcontextprotocol.io/seps/2207-oidc-refresh-token-guidance. RequestRefreshToken bool + // AcceptUnadvertisedIss accepts a matching RFC 9207 iss even when the + // authorization server metadata omits authorization_response_iss_parameter_supported. + // The zero value (false) keeps the historical reject-unadvertised-iss behavior. + AcceptUnadvertisedIss bool + // Client is an optional HTTP client to use for HTTP requests. // It is used for the following requests: // - Fetching Protected Resource Metadata @@ -367,7 +372,7 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ // Purposefully leaving the error unwrappable so it can be handled by the caller. return err } - if err := validateIssuerResponse(authRes.Iss, asm.Issuer, asm.AuthorizationResponseIssParameterSupported); err != nil { + if err := validateIssuerResponse(authRes.Iss, asm.Issuer, asm.AuthorizationResponseIssParameterSupported, h.config.AcceptUnadvertisedIss); err != nil { return err } @@ -616,10 +621,14 @@ func (h *AuthorizationCodeHandler) getAuthorizationCode(ctx context.Context, cfg } // validateIssuerResponse validates the "iss" parameter in an authorization response -// per [RFC 9207]. +// per [RFC 9207]. When the server advertises authorization_response_iss_parameter_supported, +// iss is required and must match expectedIssuer. When it does not advertise support, +// an empty iss is accepted; a present iss is compared to expectedIssuer (RFC 9207 §2.4) +// and a mismatch is always rejected. A matching unadvertised iss is accepted only when +// acceptUnadvertisedIss is true (local policy); the default is the historical reject. // // [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207 -func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported bool) error { +func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported, acceptUnadvertisedIss bool) error { if issParameterSupported { if iss == "" { return fmt.Errorf("authorization server advertises RFC 9207 iss parameter support but none was received in the authorization response") @@ -627,12 +636,17 @@ func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported bo if iss != expectedIssuer { return fmt.Errorf("authorization response issuer %q does not match expected issuer %q", iss, expectedIssuer) } - } else { - if iss != "" { - return fmt.Errorf("authorization server does not advertise RFC 9207 iss parameter support but iss was received in the authorization response") - } + return nil + } + if iss == "" { + return nil + } + if iss != expectedIssuer { + return fmt.Errorf("authorization response issuer %q does not match expected issuer %q", iss, expectedIssuer) + } + if !acceptUnadvertisedIss { + return fmt.Errorf("authorization server does not advertise RFC 9207 iss parameter support but iss was received in the authorization response") } - return nil } diff --git a/auth/authorization_code_test.go b/auth/authorization_code_test.go index c2f963f..69a7ca6 100644 --- a/auth/authorization_code_test.go +++ b/auth/authorization_code_test.go @@ -919,11 +919,12 @@ func TestValidateIssuerResponse(t *testing.T) { const expectedIssuer = "https://auth.example.com" tests := []struct { - name string - iss string - issSupported bool - wantErr bool - wantErrContains string + name string + iss string + issSupported bool + acceptUnadvertisedIss bool + wantErr bool + wantErrContains string }{ { name: "ValidIss", @@ -949,11 +950,41 @@ func TestValidateIssuerResponse(t *testing.T) { iss: "", issSupported: false, }, + { + // Default / zero-value policy: reject a matching unadvertised iss + // (released v1.7.0 behavior). + name: "UnadvertisedIssCorrectRejectedByDefault", + iss: expectedIssuer, + issSupported: false, + wantErr: true, + wantErrContains: "does not advertise", + }, + { + name: "UnadvertisedIssCorrectAcceptedWhenOptedIn", + iss: expectedIssuer, + issSupported: false, + acceptUnadvertisedIss: true, + }, + { + name: "UnadvertisedIssWrong", + iss: "https://attacker.example.com", + issSupported: false, + wantErr: true, + wantErrContains: "does not match expected issuer", + }, + { + name: "UnadvertisedIssWrongStillRejectedWhenOptedIn", + iss: "https://attacker.example.com", + issSupported: false, + acceptUnadvertisedIss: true, + wantErr: true, + wantErrContains: "does not match expected issuer", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validateIssuerResponse(tt.iss, expectedIssuer, tt.issSupported) + err := validateIssuerResponse(tt.iss, expectedIssuer, tt.issSupported, tt.acceptUnadvertisedIss) if tt.wantErr { if err == nil { t.Fatalf("validateIssuerResponse() = nil, want error containing %q", tt.wantErrContains) @@ -968,6 +999,112 @@ func TestValidateIssuerResponse(t *testing.T) { } } +// TestAuthorize_AcceptUnadvertisedIssPlumbing checks that Authorize passes +// AuthorizationCodeHandlerConfig.AcceptUnadvertisedIss through to +// validateIssuerResponse. The fake server returns iss without advertising +// authorization_response_iss_parameter_supported. +func TestAuthorize_AcceptUnadvertisedIssPlumbing(t *testing.T) { + tests := []struct { + name string + acceptUnadvertisedIss bool + wantErrContains string + }{ + { + name: "ZeroValueRejects", + wantErrContains: "does not advertise", + }, + { + name: "OptInAccepts", + acceptUnadvertisedIss: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + UnadvertiseIssParameter: true, + RegistrationConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "test_client_id": { + Secret: "test_client_secret", + RedirectURIs: []string{"http://localhost:12345/callback"}, + }, + }, + }, + }) + authServer.Start(t) + + resourceMux := http.NewServeMux() + resourceServer := httptest.NewServer(resourceMux) + t.Cleanup(resourceServer.Close) + resourceURL := resourceServer.URL + "/resource" + resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{ + Resource: resourceURL, + AuthorizationServers: []string{authServer.URL()}, + })) + + handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{ + RedirectURL: "http://localhost:12345/callback", + AcceptUnadvertisedIss: tt.acceptUnadvertisedIss, + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "test_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: "test_client_secret", + }, + }, + AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := client.Get(args.URL) + if err != nil { + return nil, fmt.Errorf("failed to visit auth URL: %v", err) + } + defer resp.Body.Close() + location, err := resp.Location() + if err != nil { + return nil, fmt.Errorf("failed to get location header: %v", err) + } + return &AuthorizationResult{ + Code: location.Query().Get("code"), + State: location.Query().Get("state"), + Iss: location.Query().Get("iss"), + }, nil + }, + }) + if err != nil { + t.Fatalf("NewAuthorizationCodeHandler failed: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, resourceURL, nil) + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: http.NoBody, + Request: req, + } + resp.Header.Set( + "WWW-Authenticate", + "Bearer resource_metadata="+resourceServer.URL+"/.well-known/oauth-protected-resource/resource", + ) + err = handler.Authorize(context.Background(), req, resp) + if tt.wantErrContains != "" { + if err == nil { + t.Fatalf("Authorize() = nil, want error containing %q", tt.wantErrContains) + } + if !strings.Contains(err.Error(), tt.wantErrContains) { + t.Fatalf("Authorize() error = %q, want it to contain %q", err.Error(), tt.wantErrContains) + } + return + } + if err != nil { + t.Fatalf("Authorize() unexpected error = %v", err) + } + }) + } +} + func TestInferApplicationType(t *testing.T) { tests := []struct { name string diff --git a/internal/oauthtest/fake_authorization_server.go b/internal/oauthtest/fake_authorization_server.go index e5134fb..4cd6826 100644 --- a/internal/oauthtest/fake_authorization_server.go +++ b/internal/oauthtest/fake_authorization_server.go @@ -97,6 +97,11 @@ type Config struct { // IssueRefreshToken, if true, includes a refresh_token in token responses and // enables grant_type=refresh_token at the /token endpoint. IssueRefreshToken bool + // UnadvertiseIssParameter, if true, omits + // authorization_response_iss_parameter_supported from metadata while the + // authorize endpoint still includes iss. Used to test local policy for + // unadvertised RFC 9207 iss. + UnadvertiseIssParameter bool } // testRefreshToken is the refresh token issued and accepted by the fake server @@ -199,8 +204,9 @@ func (s *FakeAuthorizationServer) handleMetadata(w http.ResponseWriter, r *http. CodeChallengeMethodsSupported: []string{"S256"}, ClientIDMetadataDocumentSupported: cimdSupported, TokenEndpointAuthMethodsSupported: []string{"client_secret_post", "client_secret_basic"}, - // Advertise RFC 9207 support: the authorize endpoint includes "iss" in responses. - AuthorizationResponseIssParameterSupported: true, + // Advertise RFC 9207 support unless UnadvertiseIssParameter is set: + // the authorize endpoint still includes "iss" in responses either way. + AuthorizationResponseIssParameterSupported: !s.config.UnadvertiseIssParameter, } // Set CORS headers for cross-origin client discovery. w.Header().Set("Access-Control-Allow-Origin", "*") From c68ad9a4e6e16f8f7baa1e2f20ad7b7d74830eb4 Mon Sep 17 00:00:00 2001 From: Scott Leggett Date: Tue, 25 Aug 2026 20:27:37 +0800 Subject: [PATCH 384/418] mcp: tolerate 404 on subscriptions/listen in stateless mode (#1193) When connecting to remote stateless servers (such as GitHub's MCP server), optional streams like subscriptions/listen may be rejected by middleware with a plain-text HTTP 404 before reaching a JSON-RPC handler. Previously, checkResponse misclassified these 404s as lost stateful sessions (ErrSessionMissing) and tore down the transport connection because the error didn't wrap jsonrpc2.ErrRejected. This change aligns with the MCP 2026-07-28 Streamable HTTP specification: 1. Conditionally check for an active session ID before returning ErrSessionMissing, since a 404 on a stateless connection cannot mean a session was lost. 2. Wrap subscriptions/listen failures with ErrRejected (matching server/discover) so the jsonrpc2 layer doesn't permanently break the transport when encountering non-compliant plain-text 404s. --- I have manually tested this change against GitHub's hosted MCP server and confirmed that it fixes https://github.com/github/github-mcp-server/issues/3129 --- mcp/streamable.go | 20 ++++++----- mcp/streamable_client_test.go | 64 +++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index 6ed5d3d..8729bb7 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -2347,13 +2347,14 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e } if err := c.checkResponse(ctx, requestSummary, resp); err != nil { - if requestMethod == methodDiscover && !errors.Is(err, jsonrpc2.ErrRejected) { - // Wrap the discover failure with ErrRejected so the jsonrpc2 layer - // doesn't set writeErr, which would prevent the legacy initialize - // fallback from succeeding on the same connection. This covers the - // case where a legacy server rejects server/discover with a - // non-JSON-RPC body (e.g. plain text 400), which checkResponse - // cannot classify as a per-call rejection on its own. + if (requestMethod == methodDiscover || requestMethod == methodSubscriptionsListen) && !errors.Is(err, jsonrpc2.ErrRejected) { + // Wrap the discover or subscriptions/listen failure with ErrRejected so + // the jsonrpc2 layer doesn't set writeErr, which would break the connection + // (preventing the legacy initialize fallback from succeeding, or breaking + // subsequent RPCs if subscriptions/listen fails). This covers the case + // where a server rejects these requests with a non-JSON-RPC body (e.g. + // plain text 404 or 400), which checkResponse cannot classify as a + // per-call rejection on its own. err = fmt.Errorf("%w: %w", err, jsonrpc2.ErrRejected) } else if !errors.Is(err, jsonrpc2.ErrRejected) { // Only fail the connection for non-transient errors. @@ -2592,10 +2593,11 @@ func (c *streamableClientConn) checkResponse(ctx context.Context, requestSummary // §2.5.3: "The server MAY terminate the session at any time, after // which it MUST respond to requests containing that session ID with HTTP // 404 Not Found." - if resp.StatusCode == http.StatusNotFound { + // Only a stateful connection with a known session ID can experience ErrSessionMissing. + if sessionID := c.SessionID(); resp.StatusCode == http.StatusNotFound && sessionID != "" { // Return an ErrSessionMissing to avoid sending a redundant DELETE when the // session is already gone. - return fmt.Errorf("%s: failed to connect (session ID: %v): %w", requestSummary, c.sessionID, ErrSessionMissing) + return fmt.Errorf("%s: failed to connect (session ID: %v): %w", requestSummary, sessionID, ErrSessionMissing) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("%s: %v", requestSummary, http.StatusText(resp.StatusCode)) diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 64c4da5..77ff81a 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -1965,3 +1965,67 @@ func TestStreamableClientHandlerErrorPropagation(t *testing.T) { }) } } + +// TestStreamableClient_StatelessSubscriptionsListen404 verifies that in stateless mode +// (protocol 2026-07-28), when a server rejects subscriptions/listen with 404 (e.g. Copilot MCP server), +// the client connection survives and subsequent RPCs (such as tools/list) succeed. +func TestStreamableClient_StatelessSubscriptionsListen404(t *testing.T) { + ctx := t.Context() + + var listenServed atomic.Bool + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodDiscover, ""}: { + header: header{ + "Content-Type": "application/json", + }, + wantProtocolVersion: protocolVersion20260728, + responseFunc: func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, resp(r.ID.Raw().(int64), discoverResult, nil)), http.StatusOK + }, + }, + {"POST", "", methodSubscriptionsListen, ""}: { + header: header{"Content-Type": "text/plain"}, + responseFunc: func(r *jsonrpc.Request) (string, int) { + listenServed.Store(true) + return "404 Not Found", http.StatusNotFound + }, + optional: true, + }, + {"POST", "", methodListTools, ""}: { + header: header{"Content-Type": "application/json"}, + responseFunc: func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, resp(r.ID.Raw().(int64), &ListToolsResult{Tools: []*Tool{}}, nil)), http.StatusOK + }, + optional: true, + }, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, &ClientOptions{ + ToolListChangedHandler: func(ctx context.Context, req *ToolListChangedRequest) {}, + }) + + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + defer session.Close() + + // tools/list must succeed even though subscriptions/listen returned 404. + res, err := session.ListTools(ctx, nil) + if err != nil { + t.Fatalf("ListTools failed: %v", err) + } + if res == nil { + t.Fatal("ListTools result is nil") + } + if !listenServed.Load() { + t.Fatal("subscriptions/listen was not called") + } +} From 2732839dbadd2c31651234e49199e6871b026754 Mon Sep 17 00:00:00 2001 From: Recoordinate <296084221+latent-9@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:15:09 +1200 Subject: [PATCH 385/418] mcp: prune completed listen request IDs from the session (#1190) ### Problem `ServerSession` tracks the request IDs of in-flight `subscriptions/listen` streams so `Close` can cancel the parked handlers and avoid the jsonrpc2 drain deadlock. The ID is appended when the listen starts, but it is never removed when the listen handler returns. Every completed listen stream (peer cancel, unsubscribe, stream break) therefore leaves one stale entry in the session for its entire lifetime. On a long-lived session where a client repeatedly subscribes and unsubscribes, the slice grows without bound: one leaked entry per cycle. `Close` calls `jsonrpc2.Connection.Cancel` on each recorded ID, and a stale ID is a no-op there today, but the bookkeeping is meant to hold only in-flight listens and currently never shrinks. ### Change Drop the request ID from the session's bookkeeping when the listen handler returns, matching the lifecycle of the in-flight handler that owns it. ### Tests Three tests reproduce the leak, failing on `main` and passing here: - `TestListenPrunedAfterSingleCompletion` - `TestListenIDsDoNotAccumulate` - `TestListenIDsLeakViaPublicSubscribeUnsubscribe` The third exercises the leak through the public `Subscribe`/`Unsubscribe` API. `go test ./mcp/` passes. --------- Co-authored-by: Guglielmo Colombo --- mcp/mcp_test.go | 89 ++++++++++++++++++++++++++++++++++++++++++++++--- mcp/server.go | 13 ++++++++ 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 8e2f06d..699d32f 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -3341,10 +3341,7 @@ func TestServerSessionCloseWithActiveListen(t *testing.T) { // Sanity check: the auto-listen must actually have registered an entry in // listenIDs, otherwise the test below would trivially pass without // exercising the fix. - ss.mu.Lock() - n := len(ss.listenIDs) - ss.mu.Unlock() - if n == 0 { + if n := listenIDsCount(ss); n == 0 { t.Fatal("expected auto-listen to register a request ID on the server session") } @@ -3357,6 +3354,90 @@ func TestServerSessionCloseWithActiveListen(t *testing.T) { } } +// listenIDsCount reports how many request IDs are currently recorded in the +// session's listenIDs set. +func listenIDsCount(ss *ServerSession) int { + ss.mu.Lock() + defer ss.mu.Unlock() + return len(ss.listenIDs) +} + +// A completed listen stream must not leave a stale entry in the session. +func TestListenPrunedAfterSingleCompletion(t *testing.T) { + cs, ss, cleanup := basicClientServerConnection(t, nil, nil, func(s *Server) { + AddTool(s, &Tool{Name: "t"}, sayHi) + }) + _ = cleanup + + ctx := context.Background() + lctx, cancel := context.WithCancel(ctx) + go cs.subscriptionsListen(lctx, &SubscriptionsListenParams{ + Notifications: &NotificationSubscriptions{ToolsListChanged: true}, + }) + time.Sleep(30 * time.Millisecond) + cancel() // peer cancels: server handler returns + time.Sleep(30 * time.Millisecond) + + if n := listenIDsCount(ss); n != 0 { + t.Fatalf("completed listen left %d stale entry/ies", n) + } +} + +// Completed listens must not accumulate: the slice grows without bound today. +func TestListenIDsDoNotAccumulate(t *testing.T) { + cs, ss, cleanup := basicClientServerConnection(t, nil, nil, func(s *Server) { + AddTool(s, &Tool{Name: "t"}, sayHi) + }) + _ = cleanup + + ctx := context.Background() + + const cycles = 15 + for range cycles { + lctx, cancel := context.WithCancel(ctx) + go cs.subscriptionsListen(lctx, &SubscriptionsListenParams{ + Notifications: &NotificationSubscriptions{ToolsListChanged: true}, + }) + time.Sleep(15 * time.Millisecond) + cancel() + time.Sleep(10 * time.Millisecond) + } + time.Sleep(30 * time.Millisecond) + + if n := listenIDsCount(ss); n != 0 { + t.Fatalf("listenIDs grew unbounded: %d stale entries after %d completed listens", n, cycles) + } +} + +// The leak is reachable through the public Subscribe/Unsubscribe API: every +// subscription opens a listen stream and unsubscribing completes it, so a real +// client cycling subscriptions on a long-lived session leaks one entry per cycle. +func TestListenIDsLeakViaPublicSubscribeUnsubscribe(t *testing.T) { + cs, ss, cleanup := basicClientServerConnection(t, nil, nil, func(s *Server) { + AddTool(s, &Tool{Name: "t"}, sayHi) + }) + _ = cleanup + + ctx := context.Background() + + const cycles = 3 + for i := range cycles { + uri := fmt.Sprintf("resource://cycle-%d", i) + if err := cs.Subscribe(ctx, &SubscribeParams{URI: uri}); err != nil { + t.Fatalf("Subscribe %d: %v", i, err) + } + time.Sleep(15 * time.Millisecond) + if err := cs.Unsubscribe(ctx, &UnsubscribeParams{URI: uri}); err != nil { + t.Fatalf("Unsubscribe %d: %v", i, err) + } + } + time.Sleep(30 * time.Millisecond) + + if n := listenIDsCount(ss); n != 0 { + t.Fatalf("public Subscribe/Unsubscribe leaked %d stale listenIDs after %d cycles", n, cycles) + } +} + func TestCustomMethods(t *testing.T) { type searchParams struct { ParamsBase diff --git a/mcp/server.go b/mcp/server.go index f9c8233..bfca13d 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1962,6 +1962,19 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, ss.mu.Lock() ss.listenIDs = append(ss.listenIDs, req.ID) ss.mu.Unlock() + // The listen completes when the handler returns (peer cancellation, + // stream break, or error); drop the ID so completed listens don't + // accumulate in the slice indefinitely. + defer func() { + ss.mu.Lock() + for i, id := range ss.listenIDs { + if id == req.ID { + ss.listenIDs = append(ss.listenIDs[:i], ss.listenIDs[i+1:]...) + break + } + } + ss.mu.Unlock() + }() } res, err := handleReceive(ctx, ss, req) From b55bcbd610a3f9504990d2712434813c2aed565b Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Thu, 27 Aug 2026 10:47:08 +0200 Subject: [PATCH 386/418] internal/json: json nesting depth check (#1200) Added a lightweight JSON depth-checking pass before the actual parsing. --- internal/json/json.go | 52 ++++++++++++++++++++++++++++++++++++++ internal/json/json_test.go | 47 ++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/internal/json/json.go b/internal/json/json.go index b4c897f..b3fa039 100644 --- a/internal/json/json.go +++ b/internal/json/json.go @@ -8,11 +8,21 @@ package json import ( "bytes" + "fmt" "io" "github.com/segmentio/encoding/json" ) +// defaultMaxDepth is the maximum JSON nesting depth accepted by [Unmarshal]. +// Realistic MCP payloads nest only a handful of levels, so 1000 is far more +// than any legitimate message requires while keeping worst-case parse cost +// bounded. +const defaultMaxDepth = 1000 + +// errMaxDepthExceeded is returned by [Unmarshal] when the input nests deeper than [defaultMaxDepth]. +var errMaxDepthExceeded = fmt.Errorf("json: exceeded maximum nesting depth of %d", defaultMaxDepth) + type Decoder struct { dec *json.Decoder } @@ -28,5 +38,47 @@ func (d *Decoder) Decode(v any) error { } func Unmarshal(data []byte, v any) error { + if err := checkMaxDepth(data, defaultMaxDepth); err != nil { + return err + } return NewDecoder(bytes.NewReader(data)).Decode(v) } + +// checkMaxDepth scans data once and reports [errMaxDepthExceeded] if the +// nesting of JSON objects and arrays exceeds maxDepth. It is a lightweight pass which +// tracks '{' and '[' against '}' and ']' while skipping over the contents of strings. +func checkMaxDepth(data []byte, maxDepth int) error { + var ( + depth int + inString bool + escaped bool + ) + for _, b := range data { + if inString { + switch { + case escaped: + escaped = false + case b == '\\': + escaped = true + case b == '"': + inString = false + } + continue + } + + switch b { + case '"': + inString = true + case '{', '[': + depth++ + if depth > maxDepth { + return errMaxDepthExceeded + } + case '}', ']': + if depth > 0 { + depth-- + } + } + } + return nil +} diff --git a/internal/json/json_test.go b/internal/json/json_test.go index d037e88..b1d110c 100644 --- a/internal/json/json_test.go +++ b/internal/json/json_test.go @@ -7,9 +7,11 @@ package json import ( "bytes" "encoding/json" + "errors" "fmt" "strings" "testing" + "time" "github.com/google/go-cmp/cmp" ) @@ -140,6 +142,51 @@ func TestUnmarshalNullCharacter(t *testing.T) { } } +func TestUnmarshalChecksNestingDepth(t *testing.T) { + createNesting := func(depth int) []byte { + return []byte(strings.Repeat("[", depth) + strings.Repeat("]", depth)) + } + + tests := []struct { + name string + data []byte + wantErr bool + }{ + {"flat object", []byte(`{"a":1}`), false}, + {"at limit", createNesting(defaultMaxDepth), false}, + {"one over limit", createNesting(defaultMaxDepth + 1), true}, + {"brackets in strings do not count", []byte(`{"a":"[[[[[[[[[["}`), false}, + {"escaped quote in string", []byte(`{"a":"\"[[[[[["}`), false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var v any + err := Unmarshal(tt.data, &v) + gotDepthErr := errors.Is(err, errMaxDepthExceeded) + if gotDepthErr != tt.wantErr { + t.Fatalf("Unmarshal() error = %v (err=%v), want depth error = %v", gotDepthErr, err, tt.wantErr) + } + }) + } +} + +func TestUnmarshalMaxDepthExitsFast(t *testing.T) { + data := []byte(strings.Repeat("[", 500_000) + strings.Repeat("]", 500_000)) + done := make(chan error, 1) + go func() { + var v any + done <- Unmarshal(data, &v) + }() + select { + case err := <-done: + if !errors.Is(err, errMaxDepthExceeded) { + t.Fatalf("Unmarshal error = %v, want %v", err, errMaxDepthExceeded) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("Unmarshal did not reject deeply nested payload promptly") + } +} + func FuzzUnmarshalKeyComparison(f *testing.F) { // Add seed corpus for expected valid key and the known problematic cases. f.Add("field") From 165121e53ee6c1d9b95b8485bc2b23d7b24ac3e9 Mon Sep 17 00:00:00 2001 From: Bobo Date: Thu, 27 Aug 2026 09:12:53 -0500 Subject: [PATCH 387/418] jsonrpc2: wrap write error in server-closing error (#1186) The "server is closing" error produced when a connection shuts down because its write side failed formatted the underlying write error with `%v`, so the cause - typically `io.EOF` when a stdio host closes its pipe - was not part of the error chain. A caller that wants to distinguish a clean host disconnect from a real failure via `errors.Is(err, io.EOF)` therefore always got `false`. This changes both `s.writeErr` wrappings to use `%w`, preserving the write error in the error chain while keeping the same message text ("server is closing: EOF"). A regression test exercises the write-failure branch of `shuttingDown` and asserts both `errors.Is(err, ErrServerClosing)` and `errors.Is(err, io.EOF)`. Fixes #1098 --------- Co-authored-by: Bo Zhang <187063395+Tethys0@users.noreply.github.com> Co-authored-by: Guglielmo Colombo --- internal/jsonrpc2/conn.go | 4 ++-- internal/jsonrpc2/conn_test.go | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 internal/jsonrpc2/conn_test.go diff --git a/internal/jsonrpc2/conn.go b/internal/jsonrpc2/conn.go index 56edb7e..5849a87 100644 --- a/internal/jsonrpc2/conn.go +++ b/internal/jsonrpc2/conn.go @@ -158,12 +158,12 @@ func (s *inFlightState) shuttingDown(errClosing error) error { if s.readErr != nil { // If the read side of the connection is broken, we cannot read new call // requests, and cannot read responses to our outgoing calls. - return fmt.Errorf("%w: %v", errClosing, s.readErr) + return fmt.Errorf("%w: %w", errClosing, s.readErr) } if s.writeErr != nil { // If the write side of the connection is broken, we cannot write responses // for incoming calls, and cannot write requests for outgoing calls. - return fmt.Errorf("%w: %v", errClosing, s.writeErr) + return fmt.Errorf("%w: %w", errClosing, s.writeErr) } return nil } diff --git a/internal/jsonrpc2/conn_test.go b/internal/jsonrpc2/conn_test.go new file mode 100644 index 0000000..afeb4c0 --- /dev/null +++ b/internal/jsonrpc2/conn_test.go @@ -0,0 +1,38 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package jsonrpc2 + +import ( + "errors" + "io" + "testing" +) + +// TestShuttingDownWrapsWriteError verifies that when a connection shuts down +// because its write side failed, the returned error preserves the underlying +// write error in its chain so callers can classify it with errors.Is (for +// example, distinguishing io.EOF from a clean host disconnect versus a real +// failure). +func TestShuttingDownWrapsWriteError(t *testing.T) { + s := &inFlightState{writeErr: io.EOF} + err := s.shuttingDown(ErrServerClosing) + if !errors.Is(err, ErrServerClosing) { + t.Errorf("shuttingDown() error = %v, want it to wrap ErrServerClosing", err) + } + if !errors.Is(err, io.EOF) { + t.Errorf("shuttingDown() error = %v, want it to wrap io.EOF", err) + } +} + +func TestShuttingDownWrapsReadError(t *testing.T) { + s := &inFlightState{readErr: io.EOF} + err := s.shuttingDown(ErrServerClosing) + if !errors.Is(err, ErrServerClosing) { + t.Errorf("shuttingDown() error = %v, want it to wrap ErrServerClosing", err) + } + if !errors.Is(err, io.EOF) { + t.Errorf("shuttingDown() error = %v, want it to wrap io.EOF", err) + } +} From 5a89b63764ba771bcbd236336f1114fcb092cfca Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Fri, 28 Aug 2026 09:43:05 +0200 Subject: [PATCH 388/418] oauthex: bound dcr read to 1mb (#1206) --- oauthex/dcr.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/oauthex/dcr.go b/oauthex/dcr.go index f46d0e8..f95c55b 100644 --- a/oauthex/dcr.go +++ b/oauthex/dcr.go @@ -175,6 +175,10 @@ func (e *ClientRegistrationError) Error() string { return fmt.Sprintf("registration failed: %s (%s)", e.ErrorCode, e.ErrorDescription) } +// maxRegistrationResponseBytes bounds the dynamic client registration response +// body read from the authorization server. +const maxRegistrationResponseBytes = 1 << 20 // 1 MiB + // RegisterClient performs Dynamic Client Registration according to RFC 7591. func RegisterClient(ctx context.Context, registrationEndpoint string, clientMeta *ClientRegistrationMetadata, c *http.Client) (*ClientRegistrationResponse, error) { if registrationEndpoint == "" { @@ -204,7 +208,7 @@ func RegisterClient(ctx context.Context, registrationEndpoint string, clientMeta } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxRegistrationResponseBytes)) if err != nil { return nil, fmt.Errorf("failed to read registration response body: %w", err) } From 21c18c6229e1c6d1d53d9a57475a2f65cc508cf3 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Fri, 28 Aug 2026 16:06:10 +0200 Subject: [PATCH 389/418] mcp: allow restricting the protocol versions a server supports (#1199) Fixes #945 --- docs/protocol.md | 14 +++ internal/docs/protocol.src.md | 14 +++ mcp/server.go | 69 +++++++++++---- mcp/server_test.go | 161 ++++++++++++++++++++++++++++++++++ mcp/session.go | 7 ++ mcp/shared.go | 30 +++++-- mcp/sse_test.go | 2 +- mcp/streamable.go | 22 +++-- mcp/streamable_test.go | 77 ++++++++++++++++ mcp/transport.go | 8 +- mcp/transport_test.go | 28 +++++- 11 files changed, 397 insertions(+), 35 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index 47ae2c3..f60371b 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -128,6 +128,20 @@ request. Servers implementing `2026-07-28` MUST implement it. fails or the server does not support the latest version, the client falls back to the legacy `initialize` handshake. +A server advertises and negotiates every protocol version the SDK supports; +set [`ServerOptions.SupportedProtocolVersions`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions) +to narrow that set, for example to stop serving a revision your deployment has +retired. + +```go +server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v1.0.0"}, &mcp.ServerOptions{ + SupportedProtocolVersions: []string{"2026-07-28", "2025-11-25"}, +}) +``` + +The set can only narrow support, never widen it; the field's documentation +covers how each protocol era answers a request at an excluded version. + ### Per-request metadata keys When the negotiated protocol version is `2026-07-28` or later, every request diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index 446b555..dbc8c6e 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -72,6 +72,20 @@ request. Servers implementing `2026-07-28` MUST implement it. fails or the server does not support the latest version, the client falls back to the legacy `initialize` handshake. +A server advertises and negotiates every protocol version the SDK supports; +set [`ServerOptions.SupportedProtocolVersions`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions) +to narrow that set, for example to stop serving a revision your deployment has +retired. + +```go +server := mcp.NewServer(&mcp.Implementation{Name: "server", Version: "v1.0.0"}, &mcp.ServerOptions{ + SupportedProtocolVersions: []string{"2026-07-28", "2025-11-25"}, +}) +``` + +The set can only narrow support, never widen it; the field's documentation +covers how each protocol era answers a request at an excluded version. + ### Per-request metadata keys When the negotiated protocol version is `2026-07-28` or later, every request diff --git a/mcp/server.go b/mcp/server.go index bfca13d..b935385 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -44,6 +44,10 @@ type Server struct { // fixed at creation impl *Implementation opts ServerOptions + // protocolVersions is the descending-ordered list of protocol versions + // this server advertises and negotiates: [supportedProtocolVersions], + // narrowed by [ServerOptions.SupportedProtocolVersions]. + protocolVersions []string mu sync.Mutex prompts *featureSet[*serverPrompt] @@ -169,6 +173,23 @@ type ServerOptions struct { // GetSessionID is not consulted when [StreamableHTTPOptions.Stateless] is // true, since stateless servers do not maintain sessions. GetSessionID func() string + + // SupportedProtocolVersions, if non-empty, restricts the protocol versions + // this server advertises. If empty, every version returned by + // [SupportedProtocolVersions] is used. + // The list can only narrow support, never widen it. + // A request using the >= 2026-07-28 protocol at an excluded version is + // rejected with error code [CodeUnsupportedProtocolVersion]. + // + // The legacy initialize handshake never rejects. The lifecycle spec + // requires the server to answer a request it does not support with a + // version it does, so an excluded version is answered with the newest + // listed version that handshake can negotiate, and the client is expected + // to disconnect when it cannot speak it. A list holding no such version + // (one restricted to 2026-07-28 and later) is answered with 2025-11-25, + // the newest handshake-era version, so that the client disconnects rather + // than reading the answer as a negotiation into the new protocol. + SupportedProtocolVersions []string } // NewServer creates a new MCP server. The resulting server has no features: @@ -205,6 +226,18 @@ func NewServer(impl *Implementation, options *ServerOptions) *Server { opts.GetSessionID = rand.Text } + protocolVersions := SupportedProtocolVersions() + if len(opts.SupportedProtocolVersions) > 0 { + for _, v := range opts.SupportedProtocolVersions { + if !slices.Contains(supportedProtocolVersions, v) { + panic(fmt.Errorf("unsupported protocol version %q", v)) + } + } + protocolVersions = slices.DeleteFunc(protocolVersions, func(v string) bool { + return !slices.Contains(opts.SupportedProtocolVersions, v) + }) + } + if opts.Logger == nil { // ensure we have a logger opts.Logger = ensureLogger(nil) } @@ -215,6 +248,7 @@ func NewServer(impl *Implementation, options *ServerOptions) *Server { s := &Server{ impl: impl, opts: opts, + protocolVersions: protocolVersions, prompts: newFeatureSet(func(p *serverPrompt) string { return p.prompt.Name }), tools: newFeatureSet(func(t *serverTool) string { return t.tool.Name }), resources: newFeatureSet(func(r *serverResource) string { return r.resource.URI }), @@ -886,7 +920,7 @@ func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams] versions := req.Session.supportedVersions req.Session.mu.Unlock() if versions == nil { - versions = slices.Clone(supportedProtocolVersions) + versions = slices.Clone(s.protocolVersions) } // Read the request-scoped identity/capabilities before acquiring the // session lock: these accessors may fall back to Session.InitializeParams @@ -903,7 +937,7 @@ func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams] // is never surfaced to the client via Mcp-Session-Id; leaving // InitializeParams nil lets serveStatefulPOST's safety-net cleanup // close it instead of leaking. - if supportedVersion := negotiateMutuallySupportedVersion(versions); supportedVersion >= protocolVersion20260728 { + if slices.ContainsFunc(versions, func(v string) bool { return v >= protocolVersion20260728 }) { req.Session.updateState(func(state *ServerSessionState) { state.InitializeParams = init }) @@ -917,16 +951,16 @@ func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams] return res, nil } -// filterSupportedVersions returns the subset of [supportedProtocolVersions] -// that the Transport can serve. If t does not implement [ProtocolVersionSupporter], every -// SDK-supported version is included. -func filterSupportedVersions(t Transport) []string { +// filterSupportedVersions returns the subset of versions that the Transport +// can serve. If t does not implement [ProtocolVersionSupporter], every version +// is included. +func filterSupportedVersions(t Transport, versions []string) []string { pvs, ok := t.(ProtocolVersionSupporter) if !ok { - return slices.Clone(supportedProtocolVersions) + return slices.Clone(versions) } - out := make([]string, 0, len(supportedProtocolVersions)) - for _, v := range supportedProtocolVersions { + out := make([]string, 0, len(versions)) + for _, v := range versions { if pvs.SupportsProtocolVersion(v) { out = append(out, v) } @@ -1398,7 +1432,7 @@ func (s *Server) Connect(ctx context.Context, t Transport, opts *ServerSessionOp // but without the lock the Go memory model gives the read goroutine no // guarantee of seeing this write, and -race flags it. ss.mu.Lock() - ss.supportedVersions = filterSupportedVersions(t) + ss.supportedVersions = filterSupportedVersions(t, s.protocolVersions) ss.mu.Unlock() // Start keepalive before returning the session to avoid race conditions with Close. @@ -1492,7 +1526,7 @@ type ServerSession struct { mcpConn Connection keepaliveCancel context.CancelFunc - // supportedVersions is the subset of [supportedProtocolVersions] that the + // supportedVersions is the subset of [Server.protocolVersions] that the // transport can actually serve, computed once at connection time from // [ProtocolVersionSupporter] (if implemented by the transport) and used by // the SEP-2575 server/discover handler. @@ -1896,9 +1930,9 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, } if validatedMeta.usesNewProtocol && - !slices.Contains(supportedProtocolVersions, validatedMeta.initializeParams.ProtocolVersion) { + !slices.Contains(ss.server.protocolVersions, validatedMeta.initializeParams.ProtocolVersion) { data, _ := json.Marshal(UnsupportedProtocolVersionData{ - Supported: supportedProtocolVersions, + Supported: ss.server.protocolVersions, Requested: validatedMeta.initializeParams.ProtocolVersion, }) return nil, &jsonrpc.Error{ @@ -2021,12 +2055,17 @@ func (ss *ServerSession) initialize(ctx context.Context, params *InitializeParam if params == nil { return nil, fmt.Errorf("%w: \"params\" must be be provided", jsonrpc2.ErrInvalidParams) } - var wasInit bool + var ( + wasInit bool + negotiated string + ) ss.updateState(func(state *ServerSessionState) { wasInit = state.InitializeParams != nil if !wasInit { state.InitializeParams = params + state.NegotiatedProtocolVersion = negotiatedVersion(params.ProtocolVersion, ss.server.protocolVersions) } + negotiated = state.NegotiatedProtocolVersion }) if wasInit { ss.server.opts.Logger.Error("duplicate initialize request") @@ -2037,7 +2076,7 @@ func (ss *ServerSession) initialize(ctx context.Context, params *InitializeParam return &InitializeResult{ // TODO(rfindley): alter behavior when falling back to an older version: // reject unsupported features. - ProtocolVersion: negotiatedVersion(params.ProtocolVersion), + ProtocolVersion: negotiated, Capabilities: s.capabilities(), Instructions: s.opts.Instructions, ServerInfo: s.impl, diff --git a/mcp/server_test.go b/mcp/server_test.go index 75d7455..41f4951 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -1691,3 +1691,164 @@ func TestServerSession_RejectsServerInitiated(t *testing.T) { } } } + +// TestServerSupportedProtocolVersions verifies that +// [ServerOptions.SupportedProtocolVersions] narrows both the versions the +// server advertises in server/discover and the versions it negotiates, +// and that it cannot widen SDK support. +func TestServerSupportedProtocolVersions(t *testing.T) { + ctx := context.Background() + + t.Run("discover advertises the configured subset", func(t *testing.T) { + server := NewServer(testImpl, &ServerOptions{ + // Deliberately unordered: the server must advertise newest first. + SupportedProtocolVersions: []string{protocolVersion20251125, protocolVersion20260728}, + }) + var advertised []string + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + res, err := next(ctx, method, req) + if method == methodDiscover && err == nil { + advertised = res.(*DiscoverResult).SupportedVersions + } + return res, err + } + }) + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + cs, err := NewClient(testImpl, nil).Connect(ctx, ct, &ClientSessionOptions{ + ProtocolVersion: protocolVersion20260728, + }) + if err != nil { + t.Fatalf("client.Connect: %v", err) + } + defer cs.Close() + + want := []string{protocolVersion20260728, protocolVersion20251125} + if diff := cmp.Diff(want, advertised); diff != "" { + t.Errorf("DiscoverResult.SupportedVersions mismatch (-want +got):\n%s", diff) + } + }) + + t.Run("initialize negotiates within the configured subset", func(t *testing.T) { + tests := []struct { + name string + supported []string + client string + want string + }{ + { + name: "requested version supported", + supported: []string{protocolVersion20251125, protocolVersion20250618}, + client: protocolVersion20250618, + want: protocolVersion20250618, + }, + { + name: "requested version excluded", + supported: []string{protocolVersion20251125, protocolVersion20250618}, + client: protocolVersion20241105, + want: protocolVersion20251125, + }, + { + name: "fallback is the newest configured version", + supported: []string{protocolVersion20250326}, + client: protocolVersion20241105, + want: protocolVersion20250326, + }, + { + // Nothing the server supports can carry an initialize + // handshake, so the answer names a handshake-era version the + // client can act on rather than one it could mistake for the + // new protocol. + name: "server with no handshake version", + supported: []string{protocolVersion20260728}, + client: protocolVersion20250618, + want: protocolVersion20251125, + }, + { + name: "unrestricted server keeps the default behavior", + supported: nil, + client: protocolVersion20241105, + want: protocolVersion20241105, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := NewServer(testImpl, &ServerOptions{SupportedProtocolVersions: test.supported}) + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + cs, err := NewClient(testImpl, nil).Connect(ctx, ct, &ClientSessionOptions{ + ProtocolVersion: test.client, + }) + if err != nil { + t.Fatalf("client.Connect: %v", err) + } + defer cs.Close() + + if got := cs.InitializeResult().ProtocolVersion; got != test.want { + t.Errorf("negotiated protocol version = %q, want %q", got, test.want) + } + }) + } + }) + + t.Run("unknown version panics", func(t *testing.T) { + defer func() { + if recover() == nil { + t.Error("NewServer did not panic on an unsupported protocol version") + } + }() + NewServer(testImpl, &ServerOptions{SupportedProtocolVersions: []string{"1999-01-01"}}) + }) +} + +// TestServerSupportedProtocolVersions_NewProtocol verifies that a request +// using the SEP-2575 per-request protocol is rejected with +// [CodeUnsupportedProtocolVersion] when its version is excluded by +// [ServerOptions.SupportedProtocolVersions]. +func TestServerSupportedProtocolVersions_NewProtocol(t *testing.T) { + ctx := context.Background() + server := NewServer(testImpl, &ServerOptions{ + SupportedProtocolVersions: []string{protocolVersion20251125}, + }) + ct, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + _ = ct + + params := fmt.Sprintf(`{"_meta":{%q:%q,%q:{}}}`, + MetaKeyProtocolVersion, protocolVersion20260728, MetaKeyClientCapabilities) + _, err = ss.handle(ctx, &jsonrpc.Request{ + ID: jsonrpc2.Int64ID(1), + Method: methodListTools, + Params: json.RawMessage(params), + }) + var jerr *jsonrpc.Error + if !errors.As(err, &jerr) { + t.Fatalf("handle returned %v, want a *jsonrpc.Error", err) + } + if jerr.Code != CodeUnsupportedProtocolVersion { + t.Fatalf("error code = %d, want %d", jerr.Code, CodeUnsupportedProtocolVersion) + } + var data UnsupportedProtocolVersionData + if err := json.Unmarshal(jerr.Data, &data); err != nil { + t.Fatalf("unmarshal error data: %v", err) + } + if diff := cmp.Diff([]string{protocolVersion20251125}, data.Supported); diff != "" { + t.Errorf("UnsupportedProtocolVersionData.Supported mismatch (-want +got):\n%s", diff) + } +} diff --git a/mcp/session.go b/mcp/session.go index dcf9888..08eccaf 100644 --- a/mcp/session.go +++ b/mcp/session.go @@ -22,6 +22,13 @@ type ServerSessionState struct { // InitializedParams are the parameters from 'notifications/initialized'. InitializedParams *InitializedParams `json:"initializedParams"` + // NegotiatedProtocolVersion is the protocol version agreed during + // 'initialize', which may differ from the version the client requested if + // the server does not support it. + // + // It is empty for sessions that never ran the initialize handshake. + NegotiatedProtocolVersion string `json:"negotiatedProtocolVersion,omitempty"` + // LogLevel is the logging level for the session. LogLevel LoggingLevel `json:"logLevel"` diff --git a/mcp/shared.go b/mcp/shared.go index 5069a47..5d0c1c2 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -63,18 +63,38 @@ var supportedProtocolVersions = []string{ protocolVersion20241105, } +// SupportedProtocolVersions returns the protocol versions supported by this +// version of the SDK, newest first. +// +// Use it to discover the values accepted by +// [ServerOptions.SupportedProtocolVersions]. The returned slice is a copy: +// modifying it does not change what the SDK supports. +func SupportedProtocolVersions() []string { return slices.Clone(supportedProtocolVersions) } + // negotiatedVersion returns the effective protocol version to use, given a -// client version. -func negotiatedVersion(clientVersion string) string { - // In general, prefer to use the clientVersion, but if we don't support the - // client's version, use the latest version. +// client version and the versions the server supports, newest first. +func negotiatedVersion(clientVersion string, supported []string) string { + // In general, prefer to use the clientVersion, but if not supported + // by the server, use the latest version the server supports. // // Cap the supported versions at the legacy protocolVersion20251125, as this // method is used by the initialize method which is deprecated in // version protocolVersion20260728. - if slices.Contains(supportedProtocolVersions, clientVersion) && clientVersion < protocolVersion20260728 { + if slices.Contains(supported, clientVersion) && clientVersion < protocolVersion20260728 { return clientVersion } + for _, v := range supported { + if v < protocolVersion20260728 { + return v + } + } + // If the server was enforced to support only protocolVersion20260728 and + // later, the initialize function should not be called. + // If a client calls the initialize function, return the protocolVersion20251125. + // As according to spec (https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation): + // "If the server supports the requested protocol version, it MUST respond with the same version." + // "Otherwise, the server MUST respond with another protocol version it supports." + // "This SHOULD be the latest version supported by the server." return protocolVersion20251125 } diff --git a/mcp/sse_test.go b/mcp/sse_test.go index c0ff445..9186a9c 100644 --- a/mcp/sse_test.go +++ b/mcp/sse_test.go @@ -24,7 +24,7 @@ import ( // stdio and Streamable HTTP (see #1112). func TestSSEServerTransport_SupportedVersions(t *testing.T) { var tr SSEServerTransport - versions := filterSupportedVersions(&tr) + versions := filterSupportedVersions(&tr, supportedProtocolVersions) if slices.Contains(versions, protocolVersion20260728) { t.Errorf("filterSupportedVersions(SSEServerTransport) = %v, must not include %q", versions, protocolVersion20260728) diff --git a/mcp/streamable.go b/mcp/streamable.go index 8729bb7..b4a2a32 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -363,13 +363,21 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque } // [§2.7] of the spec (2025-06-18): validate the MCP-Protocol-Version - // header. If provided, it must be a supported version. If absent, the - // version is unknown (the request may be an initialize for any version). + // header. If provided, it must be a version the server negotiates, which + // [ServerOptions.SupportedProtocolVersions] may have narrowed. If absent, + // the version is unknown (the request may be an initialize for any + // version). // // [§2.7]: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#protocol-version-header protocolVersion := req.Header.Get(protocolVersionHeader) - if protocolVersion != "" && !slices.Contains(supportedProtocolVersions, protocolVersion) && protocolVersion < protocolVersion20260728 { - http.Error(w, fmt.Sprintf("Bad Request: Unsupported protocol version (supported versions: %s)", strings.Join(supportedProtocolVersions, ",")), http.StatusBadRequest) + var supportedVersions []string + if server := h.getServer(req); server != nil { + supportedVersions = server.protocolVersions + } else { + supportedVersions = supportedProtocolVersions + } + if protocolVersion != "" && !slices.Contains(supportedVersions, protocolVersion) && protocolVersion < protocolVersion20260728 { + http.Error(w, fmt.Sprintf("Bad Request: Unsupported protocol version (supported versions: %s)", strings.Join(supportedVersions, ",")), http.StatusBadRequest) return } req = req.WithContext(context.WithValue(req.Context(), protocolVersionContextKey{}, protocolVersion)) @@ -1541,7 +1549,11 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques // Advertise only the legacy versions this stateful server accepts // (i.e. exclude 2026-07-28, since that's what the request asked for // and what we're rejecting). - legacyVersions := slices.DeleteFunc(slices.Clone(supportedProtocolVersions), func(v string) bool { + advertised := supportedProtocolVersions + if c.server != nil { + advertised = c.server.protocolVersions + } + legacyVersions := slices.DeleteFunc(slices.Clone(advertised), func(v string) bool { return v >= protocolVersion20260728 }) data, _ := json.Marshal(UnsupportedProtocolVersionData{ diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 504cda6..ff96f61 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -4141,3 +4141,80 @@ func TestStreamableMaxRequestBodyBytes(t *testing.T) { }) } } + +// TestStreamableSupportedProtocolVersions_Header verifies that the +// MCP-Protocol-Version header is validated against +// [ServerOptions.SupportedProtocolVersions], and not merely against the +// versions the SDK knows about. +func TestStreamableSupportedProtocolVersions_Header(t *testing.T) { + const initBody = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}` + + newServer := func(t *testing.T, stateless bool) *httptest.Server { + server := NewServer(testImpl, &ServerOptions{ + SupportedProtocolVersions: []string{protocolVersion20251125}, + }) + handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, + &StreamableHTTPOptions{Stateless: stateless}) + httpServer := httptest.NewServer(handler) + t.Cleanup(httpServer.Close) + return httpServer + } + post := func(t *testing.T, url, version, sessionID string) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(initBody)) + 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, version) + if sessionID != "" { + req.Header.Set(sessionIDHeader, sessionID) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + return resp + } + + for _, stateless := range []bool{false, true} { + for _, test := range []struct { + version string + wantStatus int + }{ + {protocolVersion20251125, http.StatusOK}, + // Supported by the SDK, but excluded by the server. + {protocolVersion20250618, http.StatusBadRequest}, + // Unknown to the SDK: rejected before any server is consulted. + {"1999-01-01", http.StatusBadRequest}, + } { + t.Run(fmt.Sprintf("stateless=%v/%s", stateless, test.version), func(t *testing.T) { + resp := post(t, newServer(t, stateless).URL, test.version, "") + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != test.wantStatus { + t.Errorf("status = %d, want %d; body = %s", resp.StatusCode, test.wantStatus, body) + } + }) + } + } + + t.Run("existing session", func(t *testing.T) { + httpServer := newServer(t, false) + resp := post(t, httpServer.URL, protocolVersion20251125, "") + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + sessionID := resp.Header.Get(sessionIDHeader) + if sessionID == "" { + t.Fatalf("initialize response missing %s header", sessionIDHeader) + } + + resp = post(t, httpServer.URL, protocolVersion20250618, sessionID) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("status = %d, want %d; body = %s", resp.StatusCode, http.StatusBadRequest, body) + } + }) +} diff --git a/mcp/transport.go b/mcp/transport.go index d72f9d1..1cf0325 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -114,6 +114,8 @@ type clientConnection interface { // TODO: should this interface be exported? type serverConnection interface { Connection + + // sessionUpdated is called whenever the server session state changes. sessionUpdated(ServerSessionState) } @@ -525,17 +527,13 @@ func newIOConn(rwc io.ReadWriteCloser) *ioConn { func (c *ioConn) SessionID() string { return "" } func (c *ioConn) sessionUpdated(state ServerSessionState) { - protocolVersion := "" - if state.InitializeParams != nil { - protocolVersion = state.InitializeParams.ProtocolVersion - } + protocolVersion := state.NegotiatedProtocolVersion if protocolVersion == "" { // 2025-03-26 is used, because it's the last spec version // where specifying the protocol version in the HTTP header // was not required. protocolVersion = protocolVersion20250326 } - protocolVersion = negotiatedVersion(protocolVersion) c.sessionMu.Lock() c.protocolVersion = protocolVersion c.sessionMu.Unlock() diff --git a/mcp/transport_test.go b/mcp/transport_test.go index 730400e..52228d0 100644 --- a/mcp/transport_test.go +++ b/mcp/transport_test.go @@ -56,10 +56,15 @@ func TestBatchFraming(t *testing.T) { func TestIOConnRead(t *testing.T) { tests := []struct { - name string - input string - want string + name string + input string + want string + // protocolVersion is the version negotiated by 'initialize'; empty + // means no session state was pushed to the connection. protocolVersion string + // requested is the version the client asked for, which differs from + // protocolVersion when the server counter-offered. + requested string }{ { name: "valid json input", @@ -100,6 +105,16 @@ func TestIOConnRead(t *testing.T) { want: "JSON-RPC batching is not supported in 2025-06-18 and later (request version: 2025-06-18)", protocolVersion: protocolVersion20250618, }, + { + // The client asked for a version the server does not negotiate, so + // the connection must follow the server's counter-offer (which + // forbids batching) rather than the client's request. + name: "batching at a counter-offered version", + input: `[{"jsonrpc":"2.0","id":1,"method":"test1"},{"jsonrpc":"2.0","id":2,"method":"test2"}]`, + want: "JSON-RPC batching is not supported in 2025-06-18 and later (request version: 2025-11-25)", + requested: protocolVersion20241105, + protocolVersion: protocolVersion20251125, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -108,10 +123,15 @@ func TestIOConnRead(t *testing.T) { }) t.Cleanup(func() { tr.Close() }) if tt.protocolVersion != "" { + requested := tt.requested + if requested == "" { + requested = tt.protocolVersion + } tr.sessionUpdated(ServerSessionState{ InitializeParams: &InitializeParams{ - ProtocolVersion: tt.protocolVersion, + ProtocolVersion: requested, }, + NegotiatedProtocolVersion: tt.protocolVersion, }) } _, err := tr.Read(context.Background()) From d19bfebab9914f4cc2f5480fd53018e238f9b1a5 Mon Sep 17 00:00:00 2001 From: Minghui Wang <143184745+wmh123wmh1@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:00:32 +0800 Subject: [PATCH 390/418] mcp: preserve single-request batch responses (#1214) ## What does this PR do? Preserve JSON-RPC batch response arrays when a batch contains a single request. ## Why is this needed? A single-request legacy batch must be returned as a one-element JSON array, but the JSON response path currently unwraps it into a single JSON object. ## Changes - Preserve the original batch state on the logical stream - Keep single-request batch responses as arrays - Add a regression test for legacy initialization batches ## Testing - `go test ./mcp -count=1` - `go test ./... -count=1` - `git diff --check` Fixes #1211 --- mcp/streamable.go | 7 ++++++- mcp/streamable_test.go | 46 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index b4a2a32..c8cd28b 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -988,6 +988,10 @@ type stream struct { // Note: if we remove support for batching, this could just be a bool. pendingJSONMessages []json.RawMessage + // isBatch reports whether the request that opened the stream was a JSON-RPC + // batch. Batch responses must remain arrays even when they contain one item. + isBatch bool + // w is the HTTP response writer for this stream. A non-nil w indicates // that the stream is claimed by an HTTP request (the hanging POST or GET); // it is set to nil when the request completes. @@ -1138,7 +1142,7 @@ func (s *stream) deliverLocked(data []byte, eventID string, responseTo jsonrpc.I if done { // Flush all pending messages as JSON response. var toWrite []byte - if len(s.pendingJSONMessages) == 1 { + if len(s.pendingJSONMessages) == 1 && !s.isBatch { toWrite = s.pendingJSONMessages[0] } else { toWrite, err = json.Marshal(s.pendingJSONMessages) @@ -1691,6 +1695,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques return } stream.isListen = isSubscriptionsListen + stream.isBatch = isBatch // subscriptions/listen is inherently a long-lived SSE endpoint (SEP-2575): // it has no synchronous result, the response stream stays open until the diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index ff96f61..58e5ca7 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -4040,6 +4040,52 @@ func TestStreamableServerRejectsDuplicateInFlightRequestID(t *testing.T) { } } +// TestStreamableServerPreservesSingleRequestBatchResponse verifies that a +// single-request JSON-RPC batch is returned as a one-element JSON array. +func TestStreamableServerPreservesSingleRequestBatchResponse(t *testing.T) { + server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return server }, + &StreamableHTTPOptions{JSONResponse: true}, + ) + defer handler.closeAll() + + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + body := []byte(`[{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0"}}}]`) + 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") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d; body = %s", resp.StatusCode, http.StatusOK, data) + } + + var messages []json.RawMessage + if err := json.Unmarshal(data, &messages); err != nil { + t.Fatalf("response is not a JSON array: %v; body = %s", err, data) + } + if len(messages) != 1 { + t.Fatalf("response contains %d messages, want 1; body = %s", len(messages), data) + } + if _, err := jsonrpc2.DecodeMessage(messages[0]); err != nil { + t.Fatalf("response item is not a JSON-RPC message: %v", err) + } +} + // TestStreamableMaxRequestBodyBytes verifies that the streamable HTTP handler // enforces StreamableHTTPOptions.MaxRequestBodyBytes on incoming request // bodies. The limit must apply uniformly regardless of transfer encoding: From 69c17de411d302a5d4488dbb123fdaa0619211cb Mon Sep 17 00:00:00 2001 From: Haoxin Yang Date: Mon, 31 Aug 2026 19:53:34 +0800 Subject: [PATCH 391/418] mcp: don't mutate caller params on multi-round-trip retries (#1145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1144. `clientMultiRoundTripMiddleware` calls `setMultiRoundTripRetryParams(req, ...)`, which writes `InputResponses` and `RequestState` through the caller's params pointer and never clears them. A `*CallToolParams` (or `GetPromptParams` / `ReadResourceParams`) struct reused across calls then carries the first call's answers into the next call: a server gating on `len(req.Params.InputResponses) == 0` — the idiom in this repo's own tests and examples — treats the fresh call as already answered, so the client's `ElicitationHandler` never fires and an elicitation-gated (e.g. destructive) tool runs without confirmation. This change makes the retry loop carry the fulfilled responses and echoed state on a shallow copy of the params (`multiRoundTripRetryRequest`) instead of mutating the original request. Caller-owned params are now left untouched by `CallTool`, `GetPrompt`, and `ReadResource`. The server-side shim (`serverMultiRoundTripMiddleware`) is unchanged: the params it mutates are SDK-owned, decoded from the wire. Tests: `TestMultiRoundTrip_AutoRetryDoesNotMutateCallerParams` (CallTool: params unchanged after the call, and reusing the same struct fulfills the input requests again instead of silently replaying the first call's answers — it failed with `elicitations after reuse = 1, want 2` before the fix), plus GetPrompt/ReadResource analogues asserting params are left clean. All three fail on main and pass with this change; `go test ./...` and `go vet ./...` are clean. Note on observable behavior: callers can no longer read the fulfilled responses off their own params struct after a call returns. That side effect was undocumented, and relying on it is exactly what makes the replay hazard above possible. --------- Co-authored-by: Guglielmo Colombo --- mcp/mrtr.go | 62 ++++++-- mcp/mrtr_test.go | 386 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 437 insertions(+), 11 deletions(-) diff --git a/mcp/mrtr.go b/mcp/mrtr.go index 5f65f56..008c0f8 100644 --- a/mcp/mrtr.go +++ b/mcp/mrtr.go @@ -108,7 +108,10 @@ func clientMultiRoundTripMiddleware() Middleware { if err != nil { return nil, err } - setMultiRoundTripRetryParams(req, responses, mrtrResult.requestState()) + req, err = setMultiRoundTripRetryParams(method, req, responses, mrtrResult.requestState()) + if err != nil { + return nil, err + } } } } @@ -152,7 +155,10 @@ func serverMultiRoundTripMiddleware() Middleware { if err != nil { return nil, err } - setMultiRoundTripRetryParams(req, responses, mrtrResult.requestState()) + req, err = setMultiRoundTripRetryParams(method, req, responses, mrtrResult.requestState()) + if err != nil { + return nil, err + } return next(ctx, method, req) } } @@ -213,20 +219,54 @@ func createMessageParamsToWithTools(p *CreateMessageParams) *CreateMessageWithTo } } -func setMultiRoundTripRetryParams(req Request, responses InputResponseMap, state string) { +// setMultiRoundTripRetryParams returns the request for the next round of a +// multi-round-trip retry: a request whose params are a shallow copy of the +// original, carrying the fulfilled responses and the request state to echo. +// The original params are never mutated: on the client side they are +// caller-owned, and a params struct reused across calls must not carry one +// call's inputResponses and requestState into the next, where a server +// gating on them (for example an elicitation confirmation) would treat the +// new call as already answered. +// +// An unrecognized params type is an error. It cannot occur on the server +// side, but client sending middleware runs before the retry loop and may +// substitute its own params. +func setMultiRoundTripRetryParams(method string, req Request, responses InputResponseMap, state string) (Request, error) { + var retryParams Params switch p := req.GetParams().(type) { case *CallToolParams: - p.InputResponses = responses - p.RequestState = state + cp := *p + cp.InputResponses = responses + cp.RequestState = state + retryParams = &cp case *CallToolParamsRaw: - p.InputResponses = responses - p.RequestState = state + cp := *p + cp.InputResponses = responses + cp.RequestState = state + retryParams = &cp case *GetPromptParams: - p.InputResponses = responses - p.RequestState = state + cp := *p + cp.InputResponses = responses + cp.RequestState = state + retryParams = &cp case *ReadResourceParams: - p.InputResponses = responses - p.RequestState = state + cp := *p + cp.InputResponses = responses + cp.RequestState = state + retryParams = &cp + default: + return nil, fmt.Errorf("multi-round-trip: unsupported params type %T", p) + } + switch s := req.GetSession().(type) { + case *ClientSession: + return newClientRequest(s, retryParams), nil + case *ServerSession: + // Rebuild with the same constructor handleReceive uses: the + // receiving dispatch type-asserts on the request's concrete + // *ServerRequest[P] type, and Extra must carry over. + return s.receivingMethodInfos()[method].newRequest(s, retryParams, req.GetExtra()), nil + default: + return nil, fmt.Errorf("multi-round-trip: unsupported session type %T", s) } } diff --git a/mcp/mrtr_test.go b/mcp/mrtr_test.go index 2ae4fd9..d966219 100644 --- a/mcp/mrtr_test.go +++ b/mcp/mrtr_test.go @@ -10,6 +10,7 @@ import ( "context" "fmt" "slices" + "strings" "sync/atomic" "testing" @@ -196,6 +197,198 @@ func TestMultiRoundTrip_AutoRetry(t *testing.T) { } } +// TestMultiRoundTrip_AutoRetryDoesNotMutateCallerParams verifies that +// the client middleware carries inputResponses and requestState on a +// copy of the caller's params: after CallTool returns, the caller's +// struct is unchanged, and reusing it for a second call fulfills the +// input requests again instead of silently replaying the first call's +// answers against a server that gates on them. +func TestMultiRoundTrip_AutoRetryDoesNotMutateCallerParams(t *testing.T) { + ctx := context.Background() + + srv := NewServer(testImpl, nil) + AddTool(srv, &Tool{Name: "act"}, func(ctx context.Context, req *CallToolRequest, input struct{}) (*CallToolResult, any, error) { + if len(req.Params.InputResponses) == 0 { + return &CallToolResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Sure?"}}, + RequestState: "state-1", + }, nil, nil + } + return &CallToolResult{}, map[string]any{"ok": true}, nil + }) + + var elicitations atomic.Int32 + conn := mustConnect(t, srv, &ClientOptions{ + ElicitationHandler: func(_ context.Context, _ *ElicitRequest) (*ElicitResult, error) { + elicitations.Add(1) + return &ElicitResult{Action: "accept"}, nil + }, + }) + + params := &CallToolParams{Name: "act"} + res, err := conn.CallTool(ctx, params) + if err != nil { + t.Fatalf("CallTool() error = %v", err) + } + if res.NeedsInput() { + t.Fatal("NeedsInput() = true after auto-retry, want false") + } + if params.InputResponses != nil { + t.Errorf("params.InputResponses = %v after CallTool, want nil (caller params must not be mutated)", params.InputResponses) + } + if params.RequestState != "" { + t.Errorf("params.RequestState = %q after CallTool, want empty (caller params must not be mutated)", params.RequestState) + } + if got := elicitations.Load(); got != 1 { + t.Fatalf("elicitations = %d, want 1", got) + } + + // Reusing the same params struct must fulfill the input requests + // again, not replay the first call's responses. + if _, err := conn.CallTool(ctx, params); err != nil { + t.Fatalf("CallTool() reuse error = %v", err) + } + if got := elicitations.Load(); got != 2 { + t.Errorf("elicitations after reuse = %d, want 2 (stale responses must not be replayed)", got) + } +} + +// TestMultiRoundTrip_GetPrompt_AutoRetryDoesNotMutateCallerParams is the +// GetPrompt analogue of the CallTool caller-params test. +func TestMultiRoundTrip_GetPrompt_AutoRetryDoesNotMutateCallerParams(t *testing.T) { + ctx := context.Background() + + srv := NewServer(testImpl, nil) + srv.AddPrompt(&Prompt{Name: "review"}, func(_ context.Context, req *GetPromptRequest) (*GetPromptResult, error) { + if len(req.Params.InputResponses) == 0 { + return &GetPromptResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Include sensitive data?"}}, + RequestState: "prompt-state", + }, nil + } + return &GetPromptResult{ + Messages: []*PromptMessage{{Role: "user", Content: &TextContent{Text: "review this code"}}}, + }, nil + }) + + conn := mustConnect(t, srv, &ClientOptions{ + ElicitationHandler: func(_ context.Context, _ *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + }) + + params := &GetPromptParams{Name: "review"} + res, err := conn.GetPrompt(ctx, params) + if err != nil { + t.Fatalf("GetPrompt() error = %v", err) + } + if res.NeedsInput() { + t.Fatal("NeedsInput() = true after auto-retry, want false") + } + if params.InputResponses != nil { + t.Errorf("params.InputResponses = %v after GetPrompt, want nil (caller params must not be mutated)", params.InputResponses) + } + if params.RequestState != "" { + t.Errorf("params.RequestState = %q after GetPrompt, want empty (caller params must not be mutated)", params.RequestState) + } +} + +// TestMultiRoundTrip_ReadResource_AutoRetryDoesNotMutateCallerParams is +// the ReadResource analogue of the CallTool caller-params test. +func TestMultiRoundTrip_ReadResource_AutoRetryDoesNotMutateCallerParams(t *testing.T) { + ctx := context.Background() + + srv := NewServer(testImpl, nil) + srv.AddResource(&Resource{URI: "test://data", Name: "data"}, func(_ context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) { + if len(req.Params.InputResponses) == 0 { + return &ReadResourceResult{ + InputRequests: InputRequestMap{"auth": &ElicitParams{Message: "Authenticate?"}}, + RequestState: "resource-state", + }, nil + } + return &ReadResourceResult{ + Contents: []*ResourceContents{{URI: "test://data", Text: "resource data"}}, + }, nil + }) + + conn := mustConnect(t, srv, &ClientOptions{ + ElicitationHandler: func(_ context.Context, _ *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + }) + + params := &ReadResourceParams{URI: "test://data"} + res, err := conn.ReadResource(ctx, params) + if err != nil { + t.Fatalf("ReadResource() error = %v", err) + } + if res.NeedsInput() { + t.Fatal("NeedsInput() = true after auto-retry, want false") + } + if params.InputResponses != nil { + t.Errorf("params.InputResponses = %v after ReadResource, want nil (caller params must not be mutated)", params.InputResponses) + } + if params.RequestState != "" { + t.Errorf("params.RequestState = %q after ReadResource, want empty (caller params must not be mutated)", params.RequestState) + } +} + +// wrappedCallToolParams embeds *CallToolParams so it satisfies Params and +// marshals identically, while being a concrete type the multi-round-trip +// retry does not recognize. +type wrappedCallToolParams struct{ *CallToolParams } + +// TestMultiRoundTrip_UnsupportedRetryParamsType verifies that the client +// retry loop reports an explicit error when the params carry a type it +// cannot build retry params for, instead of silently resending the request +// unchanged until the retry cap. +func TestMultiRoundTrip_UnsupportedRetryParamsType(t *testing.T) { + ctx := context.Background() + + srv := NewServer(testImpl, nil) + AddTool(srv, &Tool{Name: "act"}, func(ctx context.Context, req *CallToolRequest, input struct{}) (*CallToolResult, any, error) { + return &CallToolResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Sure?"}}, + RequestState: "state-1", + }, nil, nil + }) + + st, ct := NewInMemoryTransports() + ss, err := srv.Connect(t.Context(), st, nil) + if err != nil { + t.Fatalf("server.Connect() error = %v", err) + } + t.Cleanup(func() { _ = ss.Close() }) + + c := NewClient(testImpl, &ClientOptions{ + ElicitationHandler: func(_ context.Context, _ *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + }) + // Sending middleware added here wraps outside the multi-round-trip + // middleware installed at client construction, so the retry loop sees + // the substituted params type. + c.AddSendingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == methodCallTool { + cs := req.GetSession().(*ClientSession) + req = newClientRequest[Params](cs, wrappedCallToolParams{req.GetParams().(*CallToolParams)}) + } + return next(ctx, method, req) + } + }) + cs, err := c.Connect(t.Context(), ct, nil) + if err != nil { + t.Fatalf("client.Connect() error = %v", err) + } + t.Cleanup(func() { _ = cs.Close() }) + + _, err = cs.CallTool(ctx, &CallToolParams{Name: "act"}) + if err == nil || !strings.Contains(err.Error(), "unsupported params type") { + t.Fatalf("CallTool() error = %v, want unsupported params type error", err) + } +} + func TestMultiRoundTrip_MaxRetries(t *testing.T) { testCases := []struct { name string @@ -329,6 +522,41 @@ func TestMultiRoundTrip_ServerMiddleware(t *testing.T) { } } +// TestMultiRoundTrip_ServerMiddleware_GetPrompt verifies the server-side +// shim retry for prompts: the rebuilt retry request must keep its concrete +// *ServerRequest[*GetPromptParams] type for the receiving dispatch. +func TestMultiRoundTrip_ServerMiddleware_GetPrompt(t *testing.T) { + ctx := context.Background() + + srv := NewServer(testImpl, nil) + srv.AddPrompt(&Prompt{Name: "review"}, func(_ context.Context, req *GetPromptRequest) (*GetPromptResult, error) { + if len(req.Params.InputResponses) == 0 { + return &GetPromptResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Sure?"}}, + RequestState: "prompt-state", + }, nil + } + return &GetPromptResult{ + Messages: []*PromptMessage{{Role: "user", Content: &TextContent{Text: "approved"}}}, + }, nil + }) + + conn := mustConnectOldProtocol(t, srv, &ClientOptions{ + MultiRoundTrip: &MultiRoundTripOptions{Disabled: true}, + ElicitationHandler: func(_ context.Context, _ *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + }) + + res, err := conn.GetPrompt(ctx, &GetPromptParams{Name: "review"}) + if err != nil { + t.Fatalf("GetPrompt() error = %v", err) + } + if got := res.Messages[0].Content.(*TextContent).Text; got != "approved" { + t.Errorf("message text = %q, want %q", got, "approved") + } +} + func TestMultiRoundTrip_GetPrompt_AutoRetry(t *testing.T) { ctx := context.Background() @@ -503,6 +731,138 @@ func TestMultiRoundTrip_ReadResource_ManualRetry(t *testing.T) { } } +// TestSetMultiRoundTripRetryParams pins the properties the retry-request +// builder must uphold: the original params are never mutated, the copy +// carries the responses and state, the server-side rebuild preserves the +// request's concrete *ServerRequest[P] type (the receiving dispatch +// type-asserts on it) and its Extra, and an unrecognized params type is an +// explicit error. The server readResource case matters here because the +// end-to-end shim path cannot reach it (the read handler's nil-contents +// validation rejects input-required results for old-protocol clients). +func TestSetMultiRoundTripRetryParams(t *testing.T) { + ss := &ServerSession{server: NewServer(testImpl, nil)} + extra := &RequestExtra{} + responses := InputResponseMap{"confirm": &ElicitResult{Action: "accept"}} + + // checkRetry asserts the returned params carry the retry fields while + // the original params were left untouched. + checkRetry := func(t *testing.T, orig, retry Params) { + t.Helper() + switch p := retry.(type) { + case *CallToolParams: + if len(p.InputResponses) != 1 || p.RequestState != "s1" { + t.Errorf("retry params = %+v, want responses and state carried", p) + } + case *CallToolParamsRaw: + if len(p.InputResponses) != 1 || p.RequestState != "s1" { + t.Errorf("retry params = %+v, want responses and state carried", p) + } + case *GetPromptParams: + if len(p.InputResponses) != 1 || p.RequestState != "s1" { + t.Errorf("retry params = %+v, want responses and state carried", p) + } + case *ReadResourceParams: + if len(p.InputResponses) != 1 || p.RequestState != "s1" { + t.Errorf("retry params = %+v, want responses and state carried", p) + } + default: + t.Errorf("retry params type = %T, want one of the multi-round-trip params types", p) + } + switch p := orig.(type) { + case *CallToolParams: + if p.InputResponses != nil || p.RequestState != "" { + t.Errorf("original params mutated: %+v", p) + } + case *CallToolParamsRaw: + if p.InputResponses != nil || p.RequestState != "" { + t.Errorf("original params mutated: %+v", p) + } + case *GetPromptParams: + if p.InputResponses != nil || p.RequestState != "" { + t.Errorf("original params mutated: %+v", p) + } + case *ReadResourceParams: + if p.InputResponses != nil || p.RequestState != "" { + t.Errorf("original params mutated: %+v", p) + } + } + } + + t.Run("server callTool", func(t *testing.T) { + orig := &CallToolParamsRaw{Name: "act"} + req, err := setMultiRoundTripRetryParams(methodCallTool, &ServerRequest[*CallToolParamsRaw]{Session: ss, Params: orig, Extra: extra}, responses, "s1") + if err != nil { + t.Fatalf("setMultiRoundTripRetryParams() error = %v", err) + } + got, ok := req.(*ServerRequest[*CallToolParamsRaw]) + if !ok { + t.Fatalf("retry request type = %T, want *ServerRequest[*CallToolParamsRaw]", req) + } + if got.Extra != extra { + t.Error("retry request lost Extra") + } + checkRetry(t, orig, got.Params) + }) + + t.Run("server getPrompt", func(t *testing.T) { + orig := &GetPromptParams{Name: "review"} + req, err := setMultiRoundTripRetryParams(methodGetPrompt, &ServerRequest[*GetPromptParams]{Session: ss, Params: orig, Extra: extra}, responses, "s1") + if err != nil { + t.Fatalf("setMultiRoundTripRetryParams() error = %v", err) + } + got, ok := req.(*ServerRequest[*GetPromptParams]) + if !ok { + t.Fatalf("retry request type = %T, want *ServerRequest[*GetPromptParams]", req) + } + if got.Extra != extra { + t.Error("retry request lost Extra") + } + checkRetry(t, orig, got.Params) + }) + + t.Run("server readResource", func(t *testing.T) { + orig := &ReadResourceParams{URI: "test://data"} + req, err := setMultiRoundTripRetryParams(methodReadResource, &ServerRequest[*ReadResourceParams]{Session: ss, Params: orig, Extra: extra}, responses, "s1") + if err != nil { + t.Fatalf("setMultiRoundTripRetryParams() error = %v", err) + } + got, ok := req.(*ServerRequest[*ReadResourceParams]) + if !ok { + t.Fatalf("retry request type = %T, want *ServerRequest[*ReadResourceParams]", req) + } + if got.Extra != extra { + t.Error("retry request lost Extra") + } + checkRetry(t, orig, got.Params) + }) + + t.Run("client params", func(t *testing.T) { + for _, orig := range []Params{ + &CallToolParams{Name: "act"}, + &GetPromptParams{Name: "review"}, + &ReadResourceParams{URI: "test://data"}, + } { + req, err := setMultiRoundTripRetryParams(methodCallTool, newClientRequest[Params](nil, orig), responses, "s1") + if err != nil { + t.Fatalf("setMultiRoundTripRetryParams(%T) error = %v", orig, err) + } + got, ok := req.(*ClientRequest[Params]) + if !ok { + t.Fatalf("retry request type = %T, want *ClientRequest[Params]", req) + } + checkRetry(t, orig, got.Params) + } + }) + + t.Run("unsupported params type", func(t *testing.T) { + wrapped := wrappedCallToolParams{&CallToolParams{Name: "act"}} + _, err := setMultiRoundTripRetryParams(methodCallTool, newClientRequest[Params](nil, wrapped), responses, "s1") + if err == nil || !strings.Contains(err.Error(), "unsupported params type") { + t.Fatalf("setMultiRoundTripRetryParams() error = %v, want unsupported params type error", err) + } + }) +} + func mustConnect(t *testing.T, s *Server, clientOpts *ClientOptions) *ClientSession { t.Helper() @@ -525,3 +885,29 @@ func mustConnect(t *testing.T, s *Server, clientOpts *ClientOptions) *ClientSess }) return cs } + +// mustConnectOldProtocol is mustConnect for a client on a protocol version +// that predates multi-round-trip: the server-side shim, not the client +// middleware, fulfills input requests. +func mustConnectOldProtocol(t *testing.T, s *Server, clientOpts *ClientOptions) *ClientSession { + t.Helper() + + st, ct := NewInMemoryTransports() + ss, err := s.Connect(t.Context(), st, nil) + if err != nil { + t.Fatalf("server.Connect() error = %v", err) + } + t.Cleanup(func() { + _ = ss.Close() + }) + + c := NewClient(testImpl, clientOpts) + cs, err := c.Connect(t.Context(), ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + if err != nil { + t.Fatalf("client.Connect() error = %v", err) + } + t.Cleanup(func() { + _ = cs.Close() + }) + return cs +} From ce3a92a0a8aa1d6298660180a6a3a8e798b7550d Mon Sep 17 00:00:00 2001 From: Qinghong Zhu <105350380+qhzhu@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:09:19 +0800 Subject: [PATCH 392/418] mcp: fix ListChanged doc comments on server capabilities (#1219) The doc comments on `ToolCapabilities.ListChanged` and `ResourceCapabilities.ListChanged` say the flag reports whether **the client** supports list-change notifications. Both types only ever appear inside `ServerCapabilities`, though, and the spec defines the field the other way around: whether **this server** supports notifications for changes to the list. `PromptCapabilities` in the same file already words it correctly. The "client" wording looks like it was copied from `RootCapabilities`, the one listChanged capability that genuinely belongs to the client. Docs only, no behavior change. --- mcp/protocol.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mcp/protocol.go b/mcp/protocol.go index 47cb9cb..7a881d4 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -2246,7 +2246,7 @@ type PromptCapabilities struct { // ResourceCapabilities describes the server's support for resources. type ResourceCapabilities struct { - // ListChanged reports whether the client supports notifications for + // ListChanged reports whether this server supports notifications for // changes to the resource list. ListChanged bool `json:"listChanged,omitempty"` // Subscribe reports whether this server supports subscribing to resource @@ -2256,7 +2256,7 @@ type ResourceCapabilities struct { // ToolCapabilities describes the server's support for tools. type ToolCapabilities struct { - // ListChanged reports whether the client supports notifications for + // ListChanged reports whether this server supports notifications for // changes to the tool list. ListChanged bool `json:"listChanged,omitempty"` } From f47d6ce156fa80452445877c393bae5635ccf1e4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 10:12:12 +0000 Subject: [PATCH 393/418] build(deps): bump the codeql-action group with 3 updates (#1216) Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action). Updates `github/codeql-action/init` from 4.37.3 to 4.37.9
Release notes

Sourced from github/codeql-action/init's releases.

v4.37.9

  • Update default CodeQL bundle version to 2.26.4. #4106

v4.37.8

No user facing changes.

v4.37.7

  • Update default CodeQL bundle version to 2.26.3. #4085

v4.37.6

  • Changed the default filepath for the new remote file address format that was introduced in CodeQL Action 4.37.0 / 3.37.0 to .github/codeql-config.yml to align it with the suggested path that is used elsewhere. #4070

v4.37.5

  • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061

v4.37.4

  • This version of the CodeQL Action adds support for the tools input for the codeql-action/init step to be specified using a github-codeql-tools repository property. This feature will gradually be rolled out following the release of this version. Once rolled out, this allows for the CodeQL CLI version that is used in GitHub-managed workflows, such as Default Setup, to be set to a custom value. For example, customers who run into issues with rate limits when a new CodeQL CLI version is released can set the value to toolcache to always use the CodeQL CLI version that is available in the runner toolcache. For Advanced Setup workflows, the value provided for tools in the workflow definition always takes precedence unless the value of the repository property starts with !. #4037
  • Update default CodeQL bundle version to 2.26.2. #4051
Changelog

Sourced from github/codeql-action/init's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

No user facing changes.

4.37.9 - 26 Aug 2026

  • Update default CodeQL bundle version to 2.26.4. #4106

4.37.8 - 21 Aug 2026

No user facing changes.

4.37.7 - 13 Aug 2026

  • Update default CodeQL bundle version to 2.26.3. #4085

4.37.6 - 04 Aug 2026

  • Changed the default filepath for the new remote file address format that was introduced in CodeQL Action 4.37.0 / 3.37.0 to .github/codeql-config.yml to align it with the suggested path that is used elsewhere. #4070

4.37.5 - 03 Aug 2026

  • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061

4.37.4 - 29 Jul 2026

  • This version of the CodeQL Action adds support for the tools input for the codeql-action/init step to be specified using a github-codeql-tools repository property. This feature will gradually be rolled out following the release of this version. Once rolled out, this allows for the CodeQL CLI version that is used in GitHub-managed workflows, such as Default Setup, to be set to a custom value. For example, customers who run into issues with rate limits when a new CodeQL CLI version is released can set the value to toolcache to always use the CodeQL CLI version that is available in the runner toolcache. For Advanced Setup workflows, the value provided for tools in the workflow definition always takes precedence unless the value of the repository property starts with !. #4037
  • Update default CodeQL bundle version to 2.26.2. #4051

4.37.3 - 22 Jul 2026

No user facing changes.

4.37.2 - 21 Jul 2026

  • The new address format for the config-file input that was introduced in CodeQL Action 4.37.0 is now enabled by default. In addition to the format described there, the remote= prefix can now be used to explicitly indicate that the input refers to a remote file. All previous input formats continue to be accepted as well. #4023
  • The CodeQL Action can now make use of configured private registries in Default Setup to retrieve CodeQL configuration files from remote repositories that require authentication. This will allow customers to store their CodeQL configuration in a single repository that can then be referenced by Default Setup workflows in other repositories. We expect to roll this and other, related changes out to everyone in July. #4007

4.37.1 - 16 Jul 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956
  • Update default CodeQL bundle version to 2.26.1. #4019

4.37.0 - 08 Jul 2026

  • Update default CodeQL bundle version to 2.26.0. #3995

... (truncated)

Commits
  • cdf488f Merge pull request #4107 from github/update-v4.37.9-920ba7cd1
  • 7243f38 Update changelog for v4.37.9
  • 920ba7c Merge pull request #4106 from github/update-bundle/codeql-bundle-v2.26.4
  • ecfa6e1 Add changelog note
  • adcdf4a Update default bundle to codeql-bundle-v2.26.4
  • 486fec2 Merge pull request #4099 from github/update-supported-enterprise-server-versions
  • 134624c Merge pull request #4101 from github/dependabot/npm_and_yarn/npm-minor-457d82...
  • ff43db8 Merge pull request #4103 from github/mergeback/v4.37.8-to-main-db488dde
  • 4605e03 Rebuild
  • 099c869 Update changelog and version after v4.37.8
  • Additional commits viewable in compare view

Updates `github/codeql-action/analyze` from 4.37.3 to 4.37.9
Release notes

Sourced from github/codeql-action/analyze's releases.

v4.37.9

  • Update default CodeQL bundle version to 2.26.4. #4106

v4.37.8

No user facing changes.

v4.37.7

  • Update default CodeQL bundle version to 2.26.3. #4085

v4.37.6

  • Changed the default filepath for the new remote file address format that was introduced in CodeQL Action 4.37.0 / 3.37.0 to .github/codeql-config.yml to align it with the suggested path that is used elsewhere. #4070

v4.37.5

  • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061

v4.37.4

  • This version of the CodeQL Action adds support for the tools input for the codeql-action/init step to be specified using a github-codeql-tools repository property. This feature will gradually be rolled out following the release of this version. Once rolled out, this allows for the CodeQL CLI version that is used in GitHub-managed workflows, such as Default Setup, to be set to a custom value. For example, customers who run into issues with rate limits when a new CodeQL CLI version is released can set the value to toolcache to always use the CodeQL CLI version that is available in the runner toolcache. For Advanced Setup workflows, the value provided for tools in the workflow definition always takes precedence unless the value of the repository property starts with !. #4037
  • Update default CodeQL bundle version to 2.26.2. #4051
Changelog

Sourced from github/codeql-action/analyze's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

No user facing changes.

4.37.9 - 26 Aug 2026

  • Update default CodeQL bundle version to 2.26.4. #4106

4.37.8 - 21 Aug 2026

No user facing changes.

4.37.7 - 13 Aug 2026

  • Update default CodeQL bundle version to 2.26.3. #4085

4.37.6 - 04 Aug 2026

  • Changed the default filepath for the new remote file address format that was introduced in CodeQL Action 4.37.0 / 3.37.0 to .github/codeql-config.yml to align it with the suggested path that is used elsewhere. #4070

4.37.5 - 03 Aug 2026

  • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061

4.37.4 - 29 Jul 2026

  • This version of the CodeQL Action adds support for the tools input for the codeql-action/init step to be specified using a github-codeql-tools repository property. This feature will gradually be rolled out following the release of this version. Once rolled out, this allows for the CodeQL CLI version that is used in GitHub-managed workflows, such as Default Setup, to be set to a custom value. For example, customers who run into issues with rate limits when a new CodeQL CLI version is released can set the value to toolcache to always use the CodeQL CLI version that is available in the runner toolcache. For Advanced Setup workflows, the value provided for tools in the workflow definition always takes precedence unless the value of the repository property starts with !. #4037
  • Update default CodeQL bundle version to 2.26.2. #4051

4.37.3 - 22 Jul 2026

No user facing changes.

4.37.2 - 21 Jul 2026

  • The new address format for the config-file input that was introduced in CodeQL Action 4.37.0 is now enabled by default. In addition to the format described there, the remote= prefix can now be used to explicitly indicate that the input refers to a remote file. All previous input formats continue to be accepted as well. #4023
  • The CodeQL Action can now make use of configured private registries in Default Setup to retrieve CodeQL configuration files from remote repositories that require authentication. This will allow customers to store their CodeQL configuration in a single repository that can then be referenced by Default Setup workflows in other repositories. We expect to roll this and other, related changes out to everyone in July. #4007

4.37.1 - 16 Jul 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956
  • Update default CodeQL bundle version to 2.26.1. #4019

4.37.0 - 08 Jul 2026

  • Update default CodeQL bundle version to 2.26.0. #3995

... (truncated)

Commits
  • cdf488f Merge pull request #4107 from github/update-v4.37.9-920ba7cd1
  • 7243f38 Update changelog for v4.37.9
  • 920ba7c Merge pull request #4106 from github/update-bundle/codeql-bundle-v2.26.4
  • ecfa6e1 Add changelog note
  • adcdf4a Update default bundle to codeql-bundle-v2.26.4
  • 486fec2 Merge pull request #4099 from github/update-supported-enterprise-server-versions
  • 134624c Merge pull request #4101 from github/dependabot/npm_and_yarn/npm-minor-457d82...
  • ff43db8 Merge pull request #4103 from github/mergeback/v4.37.8-to-main-db488dde
  • 4605e03 Rebuild
  • 099c869 Update changelog and version after v4.37.8
  • Additional commits viewable in compare view

Updates `github/codeql-action/upload-sarif` from 4.37.3 to 4.37.9
Release notes

Sourced from github/codeql-action/upload-sarif's releases.

v4.37.9

  • Update default CodeQL bundle version to 2.26.4. #4106

v4.37.8

No user facing changes.

v4.37.7

  • Update default CodeQL bundle version to 2.26.3. #4085

v4.37.6

  • Changed the default filepath for the new remote file address format that was introduced in CodeQL Action 4.37.0 / 3.37.0 to .github/codeql-config.yml to align it with the suggested path that is used elsewhere. #4070

v4.37.5

  • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061

v4.37.4

  • This version of the CodeQL Action adds support for the tools input for the codeql-action/init step to be specified using a github-codeql-tools repository property. This feature will gradually be rolled out following the release of this version. Once rolled out, this allows for the CodeQL CLI version that is used in GitHub-managed workflows, such as Default Setup, to be set to a custom value. For example, customers who run into issues with rate limits when a new CodeQL CLI version is released can set the value to toolcache to always use the CodeQL CLI version that is available in the runner toolcache. For Advanced Setup workflows, the value provided for tools in the workflow definition always takes precedence unless the value of the repository property starts with !. #4037
  • Update default CodeQL bundle version to 2.26.2. #4051
Changelog

Sourced from github/codeql-action/upload-sarif's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

No user facing changes.

4.37.9 - 26 Aug 2026

  • Update default CodeQL bundle version to 2.26.4. #4106

4.37.8 - 21 Aug 2026

No user facing changes.

4.37.7 - 13 Aug 2026

  • Update default CodeQL bundle version to 2.26.3. #4085

4.37.6 - 04 Aug 2026

  • Changed the default filepath for the new remote file address format that was introduced in CodeQL Action 4.37.0 / 3.37.0 to .github/codeql-config.yml to align it with the suggested path that is used elsewhere. #4070

4.37.5 - 03 Aug 2026

  • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061

4.37.4 - 29 Jul 2026

  • This version of the CodeQL Action adds support for the tools input for the codeql-action/init step to be specified using a github-codeql-tools repository property. This feature will gradually be rolled out following the release of this version. Once rolled out, this allows for the CodeQL CLI version that is used in GitHub-managed workflows, such as Default Setup, to be set to a custom value. For example, customers who run into issues with rate limits when a new CodeQL CLI version is released can set the value to toolcache to always use the CodeQL CLI version that is available in the runner toolcache. For Advanced Setup workflows, the value provided for tools in the workflow definition always takes precedence unless the value of the repository property starts with !. #4037
  • Update default CodeQL bundle version to 2.26.2. #4051

4.37.3 - 22 Jul 2026

No user facing changes.

4.37.2 - 21 Jul 2026

  • The new address format for the config-file input that was introduced in CodeQL Action 4.37.0 is now enabled by default. In addition to the format described there, the remote= prefix can now be used to explicitly indicate that the input refers to a remote file. All previous input formats continue to be accepted as well. #4023
  • The CodeQL Action can now make use of configured private registries in Default Setup to retrieve CodeQL configuration files from remote repositories that require authentication. This will allow customers to store their CodeQL configuration in a single repository that can then be referenced by Default Setup workflows in other repositories. We expect to roll this and other, related changes out to everyone in July. #4007

4.37.1 - 16 Jul 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956
  • Update default CodeQL bundle version to 2.26.1. #4019

4.37.0 - 08 Jul 2026

  • Update default CodeQL bundle version to 2.26.0. #3995

... (truncated)

Commits
  • cdf488f Merge pull request #4107 from github/update-v4.37.9-920ba7cd1
  • 7243f38 Update changelog for v4.37.9
  • 920ba7c Merge pull request #4106 from github/update-bundle/codeql-bundle-v2.26.4
  • ecfa6e1 Add changelog note
  • adcdf4a Update default bundle to codeql-bundle-v2.26.4
  • 486fec2 Merge pull request #4099 from github/update-supported-enterprise-server-versions
  • 134624c Merge pull request #4101 from github/dependabot/npm_and_yarn/npm-minor-457d82...
  • ff43db8 Merge pull request #4103 from github/mergeback/v4.37.8-to-main-db488dde
  • 4605e03 Rebuild
  • 099c869 Update changelog and version after v4.37.8
  • Additional commits viewable in compare view

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Guglielmo Colombo --- .github/workflows/codeql.yml | 4 ++-- .github/workflows/scorecard.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0f63536..c4aa91c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -38,11 +38,11 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index a29d098..4dee6b7 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -73,6 +73,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: results.sarif From 8227246472e3cd0bb58b2fcf3cccb6d88ad9f68b Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Tue, 1 Sep 2026 14:43:23 +0200 Subject: [PATCH 394/418] fix: only subscribe when server advertises capability (#1221) --- mcp/client.go | 10 +++++-- mcp/mcp_test.go | 77 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index 7403799..c11f0c8 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -334,13 +334,17 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp subscribeParams := &SubscriptionsListenParams{ Notifications: &NotificationSubscriptions{}, } - if c.opts.ToolListChangedHandler != nil { + caps := discRes.Capabilities + if caps == nil { + caps = &ServerCapabilities{} + } + if c.opts.ToolListChangedHandler != nil && caps.Tools != nil && caps.Tools.ListChanged { subscribeParams.Notifications.ToolsListChanged = true } - if c.opts.PromptListChangedHandler != nil { + if c.opts.PromptListChangedHandler != nil && caps.Prompts != nil && caps.Prompts.ListChanged { subscribeParams.Notifications.PromptsListChanged = true } - if c.opts.ResourceListChangedHandler != nil { + if c.opts.ResourceListChangedHandler != nil && caps.Resources != nil && caps.Resources.ListChanged { subscribeParams.Notifications.ResourcesListChanged = true } if subscribeParams.Notifications.ToolsListChanged || diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 699d32f..e1f8c0f 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -3298,6 +3298,83 @@ func TestSubscriptionsListen_DisconnectScrubsMaps(t *testing.T) { } } +// TestSubscriptionsListen_RespectsServerCapabilities verifies that during +// Connect the client only opens a SEP-2575 subscriptions/listen stream for the +// change notifications the server advertised during capability negotiation. +func TestSubscriptionsListen_RespectsServerCapabilities(t *testing.T) { + for _, tc := range []struct { + name string + caps *ServerCapabilities + wantListen bool + }{ + { + name: "advertised", + caps: &ServerCapabilities{Tools: &ToolCapabilities{ListChanged: true}}, + wantListen: true, + }, + { + name: "not advertised", + caps: &ServerCapabilities{Tools: &ToolCapabilities{}}, + wantListen: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + server := NewServer(testImpl, &ServerOptions{Capabilities: tc.caps}) + AddTool(server, &Tool{Name: "t1"}, sayHi) + + listenSeen := make(chan struct{}, 1) + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == methodSubscriptionsListen { + select { + case listenSeen <- struct{}{}: + default: + } + } + return next(ctx, method, req) + } + }) + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + defer ss.Close() + + c := NewClient(testImpl, &ClientOptions{ + ToolListChangedHandler: func(context.Context, *ToolListChangedRequest) {}, + }) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer cs.Close() + + if _, err := cs.ListTools(ctx, nil); err != nil { + t.Fatalf("ListTools: %v", err) + } + + if tc.wantListen { + select { + case <-listenSeen: + case <-time.After(5 * time.Second): + t.Fatal("expected subscriptions/listen, but none was sent") + } + return + } + + select { + case <-listenSeen: + t.Fatal("client sent subscriptions/listen for an unadvertised capability") + case <-time.After(notificationDelay * 20): + } + }) + } +} + // TestServerSessionCloseWithActiveListen is a regression test for // modelcontextprotocol/go-sdk#1160: ServerSession.Close must not deadlock // when the client has an active subscriptions/listen stream. Previously, From a6764cf83885ab96bd59211c97e435b1b4df227e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:45:16 +0000 Subject: [PATCH 395/418] build(deps): bump golang/govulncheck-action from 1.0.4 to 1.1.0 (#1218) Bumps [golang/govulncheck-action](https://github.com/golang/govulncheck-action) from 1.0.4 to 1.1.0.
Release notes

Sourced from golang/govulncheck-action's releases.

v1.1.0

Full Changelog: https://github.com/golang/govulncheck-action/compare/v1.0.4...v1.1.0

Commits
  • 032d455 action.yml: use step-level env vars instead of direct vars in commands
  • 3fa7bd9 action.yml: unset GOOS/GOARCH when installing govulncheck
  • 31f7c54 action.yml: pin action dependencies to full commit SHAs
  • 5accf6f action.yml: update dependencies
  • 357ea4a action.yml: specify cache-dependency-path
  • 1ea7606 action.yml: update actions to fix caching issue
  • f03a9d4 README.md: fix typo
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=golang/govulncheck-action&package-manager=github_actions&previous-version=1.0.4&new-version=1.1.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/nightly.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index d60b6ff..2daa939 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Run govulncheck - uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4 + uses: golang/govulncheck-action@032d45514ae346b1db93c04b0c90b841c370344f # v1.1.0 with: go-version-file: go.mod go-package: ./... From 59185e69690c99f383e3150f7cc9808d10e16571 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:49:57 +0000 Subject: [PATCH 396/418] build(deps): bump ossf/scorecard-action from 2.4.3 to 2.4.4 (#1217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [ossf/scorecard-action](https://github.com/ossf/scorecard-action) from 2.4.3 to 2.4.4.
Release notes

Sourced from ossf/scorecard-action's releases.

v2.4.4

What's Changed

This update bumps the Scorecard version to the v5.5.0 release. For a complete list of changes, please refer to the Scorecard v5.4.0 release notes and the Scorecard v5.5.0 release notes.

Full Changelog: https://github.com/ossf/scorecard-action/compare/v2.4.3...v2.4.4

Commits
  • 2d11466 Bump action tag for v2.4.4 release (#1688)
  • 1bd3285 :seedling: Bump the docker-images group across 1 directory with 2 updates (#1...
  • 913edce :seedling: Bump github.com/containerd/containerd from 1.7.32 to 1.7.33 (#1671)
  • 0957b8f :seedling: Bump golang.org/x/net from 0.56.0 to 0.57.0 (#1680)
  • f0061eb :seedling: Bump google.golang.org/grpc from 1.81.1 to 1.82.1 (#1687)
  • 20ee732 :seedling: Bump github.com/sigstore/cosign/v2 from 2.6.3 to 2.6.4 (#1685)
  • 9f295ef :seedling: Bump the github-actions group with 6 updates (#1686)
  • 69bf556 :seedling: Bump github.com/sigstore/sigstore-go from 1.1.4 to 1.2.0 (#1681)
  • 94e8b96 :seedling: Bump github.com/sigstore/rekor from 1.5.0 to 1.5.2 (#1673)
  • c7a1b37 :seedling: Bump github.com/sigstore/fulcio from 1.8.5 to 1.8.6 (#1675)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ossf/scorecard-action&package-manager=github_actions&previous-version=2.4.3&new-version=2.4.4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/scorecard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 4dee6b7..98db4c4 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -39,7 +39,7 @@ jobs: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 with: results_file: results.sarif results_format: sarif From 2fdabde1396adc95e195cbefc17ca1ec3a3f8bfd Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Tue, 1 Sep 2026 15:30:20 +0200 Subject: [PATCH 397/418] mcp: do not check metatada on notifications (#1215) Fixes #1212 --- mcp/server.go | 2 +- mcp/shared.go | 8 +++ mcp/streamable.go | 5 +- mcp/streamable_test.go | 120 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 2 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index b935385..9e8a1b5 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1962,7 +1962,7 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, } } default: - if !initialized && !validatedMeta.usesNewProtocol { + if !initialized && !validatedMeta.usesNewProtocol && req.IsCall() { 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) } diff --git a/mcp/shared.go b/mcp/shared.go index 5d0c1c2..2258752 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -560,7 +560,15 @@ type validatedMeta struct { // 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 // initializeParams. +// Notifications always report usesNewProtocol false and never 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) if meta == nil { return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil diff --git a/mcp/streamable.go b/mcp/streamable.go index c8cd28b..8ce5e01 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -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 // 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. var metaVersion string if meta := extractRequestMeta(jreq.Params); meta != nil { 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 // is advertising in the Mcp-Protocol-Version HTTP header. headerVersion := protocolVersionFromContext(req.Context()) diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 58e5ca7..fed3522 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -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 // SEP-2575 fallback. The client requests protocolVersion20260728, which the // server is configured to NOT advertise in supportedProtocolVersions for the From cb0de6413682bfbefb300a6c1e4d8046e2627b33 Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Tue, 1 Sep 2026 17:28:35 +0200 Subject: [PATCH 398/418] mcp: add a max request body size of the old transport (#1224) added for consistency with the streamable transport because sse is not officially deprecated in the SDK Co-authored-by: Guglielmo Colombo --- mcp/sse.go | 32 +++++++++++++++++++++++++++++++- mcp/sse_test.go | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/mcp/sse.go b/mcp/sse.go index fb32756..81b0c0b 100644 --- a/mcp/sse.go +++ b/mcp/sse.go @@ -8,6 +8,7 @@ import ( "bytes" "context" "crypto/rand" + "errors" "fmt" "io" "mime" @@ -65,6 +66,12 @@ type SSEOptions struct { // Only disable this if you understand the security implications. // See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise DisableLocalhostProtection bool + + // MaxRequestBodyBytes limits the number of bytes read from an incoming + // POST body before the server responds with 413 Request Entity Too Large. + // It matches [StreamableHTTPOptions.MaxRequestBodyBytes]: if zero, + // [DefaultMaxRequestBodyBytes] is used; a negative value disables the limit. + MaxRequestBodyBytes int64 } // NewSSEHandler returns a new [SSEHandler] that creates and manages MCP @@ -122,6 +129,12 @@ type SSEServerTransport struct { // Response is the hanging response body to the incoming GET request. Response http.ResponseWriter + // MaxRequestBodyBytes limits the number of bytes read from a POSTed + // message body before [SSEServerTransport.ServeHTTP] responds with 413 + // Request Entity Too Large. If zero, [DefaultMaxRequestBodyBytes] is used; + // a negative value disables the limit. See [SSEOptions.MaxRequestBodyBytes]. + MaxRequestBodyBytes int64 + // incoming is the queue of incoming messages. // It is never closed, and by convention, incoming is non-nil if and only if // the transport is connected. @@ -143,9 +156,22 @@ func (t *SSEServerTransport) ServeHTTP(w http.ResponseWriter, req *http.Request) return } + limit := t.MaxRequestBodyBytes + if limit == 0 { + limit = DefaultMaxRequestBodyBytes + } + if limit > 0 && req.Body != nil { + req.Body = http.MaxBytesReader(w, req.Body, limit) + } + // Read and parse the message. data, err := io.ReadAll(req.Body) if err != nil { + var mbe *http.MaxBytesError + if errors.As(err, &mbe) { + http.Error(w, fmt.Sprintf("request body exceeds %d bytes", mbe.Limit), http.StatusRequestEntityTooLarge) + return + } http.Error(w, "failed to read body", http.StatusBadRequest) return } @@ -260,7 +286,11 @@ func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - transport := &SSEServerTransport{Endpoint: endpoint.RequestURI(), Response: w} + transport := &SSEServerTransport{ + Endpoint: endpoint.RequestURI(), + Response: w, + MaxRequestBodyBytes: h.opts.MaxRequestBodyBytes, + } // The session is terminated when the request exits. h.mu.Lock() diff --git a/mcp/sse_test.go b/mcp/sse_test.go index 9186a9c..de10eb3 100644 --- a/mcp/sse_test.go +++ b/mcp/sse_test.go @@ -383,3 +383,52 @@ func TestSSELocalhostProtection(t *testing.T) { }) } } + +// TestSSEServerTransportMaxRequestBodyBytes exercises the limit on the exported +// [SSEServerTransport] directly, since it is documented as usable on its own. +func TestSSEServerTransportMaxRequestBodyBytes(t *testing.T) { + requestOfSize := func(n int) []byte { + b := bytes.Repeat([]byte(" "), n) + b[0], b[n-1] = '{', '}' + return b + } + testCases := []struct { + name string + limit int64 + request []byte + wantCode int + }{ + { + name: "over limit returns", + limit: 1024, + request: requestOfSize(2048), + wantCode: http.StatusRequestEntityTooLarge, + }, + { + name: "under limit accepted", + limit: 1024, + request: []byte(`{"jsonrpc":"2.0","id":1,"method":"ping"}`), + wantCode: http.StatusAccepted, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tr := &SSEServerTransport{ + Endpoint: "/msg", + Response: httptest.NewRecorder(), + MaxRequestBodyBytes: tc.limit, + } + if _, err := tr.Connect(context.Background()); err != nil { + t.Fatalf("Connect: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/msg", bytes.NewReader(tc.request)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + tr.ServeHTTP(rec, req) + if rec.Code != tc.wantCode { + t.Errorf("status = %d, want %d", rec.Code, tc.wantCode) + } + }) + } +} From 0d3036f785b6c122323a24e39399f9abd3641a7c Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Tue, 1 Sep 2026 20:57:51 +0200 Subject: [PATCH 399/418] mcp: allow per request Cacheable customization (#1203) Fixes #1094 --- docs/server.md | 36 ++++++++++------- internal/docs/server.src.md | 36 ++++++++++------- mcp/protocol.go | 13 ++++-- mcp/server.go | 46 ++++++++++++++++----- mcp/server_test.go | 80 +++++++++++++++++++++++++++++++++++++ 5 files changed, 167 insertions(+), 44 deletions(-) diff --git a/docs/server.md b/docs/server.md index 966da75..d7bc314 100644 --- a/docs/server.md +++ b/docs/server.md @@ -975,27 +975,33 @@ results: clients use `ttlMs` as a freshness hint to reduce polling, and `cacheScope` (`"public"` or `"private"`) controls whether shared intermediaries may cache the response. -**Server-side defaults.** After paginating, the SDK sets `CacheScope = "public"` -and leaves `TTLMs = 0` (which the client cache treats as "immediately -stale"). A handler that wants its responses cached must set `TTLMs` -explicitly on the returned result. +**Server-side defaults.** The SDK fills in `CacheScope = "public"` whenever a +result leaves it empty, since the field is required on the wire. Everything +else is left as produced: a `ResourceHandler` that sets `TTLMs` or `CacheScope` +on its `ReadResourceResult` keeps those values. The list results and +`server/discover` are built by the SDK itself, so they start out with +`TTLMs = 0`, which a client cache treats as "immediately stale". + +**Setting a policy.** `ServerOptions.SetCacheable` decides the fields for every +result that carries them. It runs after the corresponding handler, receives +what that handler produced, and may keep, adjust, or replace it. The request is +passed in so that policy can vary by method, session, or authorization context. ```go -mcp.AddTool(server, &mcp.Tool{Name: "expensive"}, func(...) (*mcp.CallToolResult, any, error) { - // ... -}) -// Override the default for tools/list: -server.AddSendingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { - return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { - res, err := next(ctx, method, req) - if lr, ok := res.(*mcp.ListToolsResult); ok { - lr.TTLMs = 30_000 // 30s cache freshness hint +server := mcp.NewServer(impl, &mcp.ServerOptions{ + SetCacheable: func(_ context.Context, req mcp.Request, c *mcp.Cacheable) { + // A 30s freshness hint, except where the handler chose its own. + if c.TTLMs == 0 { + c.TTLMs = 30_000 } - return res, err - } + }, }) ``` +`SetCacheable` may run with the server's lock held, so it must not call back +into the `Server`: adding or removing a feature, or ranging over +`Server.Sessions`, deadlocks. + ## Utilities ### Completion diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index 8ad704a..82b956e 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -374,27 +374,33 @@ results: clients use `ttlMs` as a freshness hint to reduce polling, and `cacheScope` (`"public"` or `"private"`) controls whether shared intermediaries may cache the response. -**Server-side defaults.** After paginating, the SDK sets `CacheScope = "public"` -and leaves `TTLMs = 0` (which the client cache treats as "immediately -stale"). A handler that wants its responses cached must set `TTLMs` -explicitly on the returned result. +**Server-side defaults.** The SDK fills in `CacheScope = "public"` whenever a +result leaves it empty, since the field is required on the wire. Everything +else is left as produced: a `ResourceHandler` that sets `TTLMs` or `CacheScope` +on its `ReadResourceResult` keeps those values. The list results and +`server/discover` are built by the SDK itself, so they start out with +`TTLMs = 0`, which a client cache treats as "immediately stale". + +**Setting a policy.** `ServerOptions.SetCacheable` decides the fields for every +result that carries them. It runs after the corresponding handler, receives +what that handler produced, and may keep, adjust, or replace it. The request is +passed in so that policy can vary by method, session, or authorization context. ```go -mcp.AddTool(server, &mcp.Tool{Name: "expensive"}, func(...) (*mcp.CallToolResult, any, error) { - // ... -}) -// Override the default for tools/list: -server.AddSendingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { - return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { - res, err := next(ctx, method, req) - if lr, ok := res.(*mcp.ListToolsResult); ok { - lr.TTLMs = 30_000 // 30s cache freshness hint +server := mcp.NewServer(impl, &mcp.ServerOptions{ + SetCacheable: func(_ context.Context, req mcp.Request, c *mcp.Cacheable) { + // A 30s freshness hint, except where the handler chose its own. + if c.TTLMs == 0 { + c.TTLMs = 30_000 } - return res, err - } + }, }) ``` +`SetCacheable` may run with the server's lock held, so it must not call back +into the `Server`: adding or removing a feature, or ranging over +`Server.Sessions`, deadlocks. + ## Utilities ### Completion diff --git a/mcp/protocol.go b/mcp/protocol.go index 7a881d4..4867e54 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -1191,9 +1191,16 @@ func (c Cacheable) GetTTLMs() int { return c.TTLMs } // GetCacheScope returns the cache scope. func (c Cacheable) GetCacheScope() string { return c.CacheScope } -// setDefaultCacheableValues sets the default values for the cacheable fields. -func (c *Cacheable) setDefaultCacheableValues() { - c.CacheScope = "public" +// normalize fills in the protocol default for any cache field left +// unset. An absent cacheScope means "public", but the field is required on the +// wire, so the default is materialized here rather than sent empty. +// +// Values already present are preserved: this must not undo a decision made by +// a resource handler or by [ServerOptions.SetCacheable]. +func (c *Cacheable) normalize() { + if c.CacheScope == "" { + c.CacheScope = "public" + } } // The server's response to a prompts/list request from the client. diff --git a/mcp/server.go b/mcp/server.go index 9e8a1b5..5014a4f 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -174,6 +174,14 @@ type ServerOptions struct { // true, since stateless servers do not maintain sessions. GetSessionID func() string + // SetCacheable, if non-nil, decides the cache-control fields (ttlMs and + // cacheScope) of every result that carries them: server/discover, the + // four list methods, and resources/read. + // + // It is called once per such result, after the corresponding handler has + // returned, with c holding the values that handler produced. + SetCacheable func(ctx context.Context, req Request, c *Cacheable) + // SupportedProtocolVersions, if non-empty, restricts the protocol versions // this server advertises. If empty, every version returned by // [SupportedProtocolVersions] is used. @@ -870,7 +878,7 @@ func (s *Server) Sessions() iter.Seq[*ServerSession] { return slices.Values(clients) } -func (s *Server) listPrompts(_ context.Context, req *ListPromptsRequest) (*ListPromptsResult, error) { +func (s *Server) listPrompts(ctx context.Context, req *ListPromptsRequest) (*ListPromptsResult, error) { s.mu.Lock() defer s.mu.Unlock() if req.Params == nil { @@ -885,7 +893,7 @@ func (s *Server) listPrompts(_ context.Context, req *ListPromptsRequest) (*ListP if err != nil { return nil, err } - res.setDefaultCacheableValues() + s.resolveCacheable(ctx, req, &res.Cacheable) return res, nil } @@ -915,7 +923,7 @@ func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetProm // the server's capabilities, the server's identity, and the server's // instructions, allowing clients to negotiate without performing the legacy // initialize handshake. -func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams]) (*DiscoverResult, error) { +func (s *Server) discover(ctx context.Context, req *ServerRequest[*DiscoverParams]) (*DiscoverResult, error) { req.Session.mu.Lock() versions := req.Session.supportedVersions req.Session.mu.Unlock() @@ -947,7 +955,7 @@ func (s *Server) discover(_ context.Context, req *ServerRequest[*DiscoverParams] Capabilities: s.capabilities(), Instructions: s.opts.Instructions, } - res.setDefaultCacheableValues() + s.resolveCacheable(ctx, req, &res.Cacheable) return res, nil } @@ -968,7 +976,7 @@ func filterSupportedVersions(t Transport, versions []string) []string { return out } -func (s *Server) listTools(_ context.Context, req *ListToolsRequest) (*ListToolsResult, error) { +func (s *Server) listTools(ctx context.Context, req *ListToolsRequest) (*ListToolsResult, error) { s.mu.Lock() defer s.mu.Unlock() if req.Params == nil { @@ -983,7 +991,7 @@ func (s *Server) listTools(_ context.Context, req *ListToolsRequest) (*ListTools if err != nil { return nil, err } - res.setDefaultCacheableValues() + s.resolveCacheable(ctx, req, &res.Cacheable) return res, nil } @@ -1016,7 +1024,7 @@ func (s *Server) callTool(ctx context.Context, req *CallToolRequest) (*CallToolR return res, err } -func (s *Server) listResources(_ context.Context, req *ListResourcesRequest) (*ListResourcesResult, error) { +func (s *Server) listResources(ctx context.Context, req *ListResourcesRequest) (*ListResourcesResult, error) { s.mu.Lock() defer s.mu.Unlock() if req.Params == nil { @@ -1031,11 +1039,11 @@ func (s *Server) listResources(_ context.Context, req *ListResourcesRequest) (*L if err != nil { return nil, err } - res.setDefaultCacheableValues() + s.resolveCacheable(ctx, req, &res.Cacheable) return res, nil } -func (s *Server) listResourceTemplates(_ context.Context, req *ListResourceTemplatesRequest) (*ListResourceTemplatesResult, error) { +func (s *Server) listResourceTemplates(ctx context.Context, req *ListResourceTemplatesRequest) (*ListResourceTemplatesResult, error) { s.mu.Lock() defer s.mu.Unlock() if req.Params == nil { @@ -1051,7 +1059,7 @@ func (s *Server) listResourceTemplates(_ context.Context, req *ListResourceTempl if err != nil { return nil, err } - res.setDefaultCacheableValues() + s.resolveCacheable(ctx, req, &res.Cacheable) return res, nil } @@ -1075,7 +1083,7 @@ func (s *Server) readResource(ctx context.Context, req *ReadResourceRequest) (*R if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil { return nil, err } - res.setDefaultCacheableValues() + s.resolveCacheable(ctx, req, &res.Cacheable) if res.resultType == resultTypeInputRequired { return res, nil } @@ -1094,6 +1102,22 @@ func (s *Server) readResource(ctx context.Context, req *ReadResourceRequest) (*R return res, nil } +// resolveCacheable settles the cache-control fields of an outgoing result. +// +// On entry c holds whatever the feature handler produced, which is the zero +// value for results the SDK builds itself. [ServerOptions.SetCacheable] may +// then rewrite it, and any cacheScope still unset is filled with the protocol +// default. +// +// The list methods call this with s.mu held, so SetCacheable must not call +// back into the server; see its documentation. +func (s *Server) resolveCacheable(ctx context.Context, req Request, c *Cacheable) { + if s.opts.SetCacheable != nil { + s.opts.SetCacheable(ctx, req, c) + } + c.normalize() +} + // lookupResourceHandler returns the resource handler and MIME type for the resource or // resource template matching uri. If none, the last return value is false. func (s *Server) lookupResourceHandler(uri string) (ResourceHandler, string, bool) { diff --git a/mcp/server_test.go b/mcp/server_test.go index 41f4951..11e75e2 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -1692,6 +1692,86 @@ func TestServerSession_RejectsServerInitiated(t *testing.T) { } } +func TestServerSetCacheable(t *testing.T) { + ctx := context.Background() + // The values an opinionated resource handler sets on its own result. + handlerValue := Cacheable{TTLMs: 9_000, CacheScope: "private"} + + for _, tc := range []struct { + name string + callback func(context.Context, Request, *Cacheable) + wantSDK Cacheable // for a result whose fields nobody set + wantHandler Cacheable // for a result the handler set fields on + }{ + { + name: "no callback", + wantSDK: Cacheable{TTLMs: 0, CacheScope: "public"}, + wantHandler: handlerValue, + }, + { + name: "callback overrules the handler", + callback: func(_ context.Context, _ Request, c *Cacheable) { + c.TTLMs, c.CacheScope = 60_000, "private" + }, + wantSDK: Cacheable{TTLMs: 60_000, CacheScope: "private"}, + wantHandler: Cacheable{TTLMs: 60_000, CacheScope: "private"}, + }, + { + // The callback is handed what the handler produced, so it can + // single out the results that expressed no opinion. The scope it + // leaves empty is still filled with the protocol default. + name: "callback defers to the handler", + callback: func(_ context.Context, _ Request, c *Cacheable) { + if c.CacheScope == "" { + c.TTLMs = 1_000 + } + }, + wantSDK: Cacheable{TTLMs: 1_000, CacheScope: "public"}, + wantHandler: handlerValue, + }, + } { + t.Run(tc.name, func(t *testing.T) { + s := NewServer(testImpl, &ServerOptions{SetCacheable: tc.callback}) + s.AddResource(&Resource{URI: "test://plain", Name: "plain"}, + func(_ context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) { + return &ReadResourceResult{Contents: []*ResourceContents{{URI: req.Params.URI, Text: "x"}}}, nil + }) + s.AddResource(&Resource{URI: "test://opinionated", Name: "opinionated"}, + func(_ context.Context, req *ReadResourceRequest) (*ReadResourceResult, error) { + return &ReadResourceResult{ + Cacheable: handlerValue, + Contents: []*ResourceContents{{URI: req.Params.URI, Text: "x"}}, + }, nil + }) + cs := mustConnect(t, s, nil) + + check := func(label string, got, want Cacheable) { + t.Helper() + if got != want { + t.Errorf("%s Cacheable = %+v, want %+v", label, got, want) + } + } + + tools, err := cs.ListTools(ctx, nil) // built by the SDK, never by a handler + if err != nil { + t.Fatal(err) + } + check("tools/list", tools.Cacheable, tc.wantSDK) + + for uri, want := range map[string]Cacheable{ + "test://plain": tc.wantSDK, + "test://opinionated": tc.wantHandler, + } { + res, err := cs.ReadResource(ctx, &ReadResourceParams{URI: uri}) + if err != nil { + t.Fatal(err) + } + check(uri, res.Cacheable, want) + } + }) + } +} + // TestServerSupportedProtocolVersions verifies that // [ServerOptions.SupportedProtocolVersions] narrows both the versions the // server advertises in server/discover and the versions it negotiates, From 363296740467cb60316e45f2f9a9e958cd998356 Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Wed, 2 Sep 2026 11:35:23 +0200 Subject: [PATCH 400/418] mcp: add an sse event size cap (#1205) update sse I/O code to limit the amount of memory a single non-terminated event can take --------- Co-authored-by: Guglielmo Colombo --- mcp/event.go | 58 +++++++++++++++--- mcp/event_test.go | 135 ++++++++++++++++++++++++++++++++++++++++- mcp/sse.go | 14 ++++- mcp/streamable.go | 12 +++- mcp/streamable_test.go | 67 +++++++++++++++++++- mcp/transport.go | 71 ++++++++++++++++++++-- mcp/transport_test.go | 84 +++++++++++++++++++++++++ 7 files changed, 423 insertions(+), 18 deletions(-) diff --git a/mcp/event.go b/mcp/event.go index 2b02a07..f0cbb2f 100644 --- a/mcp/event.go +++ b/mcp/event.go @@ -65,13 +65,21 @@ func writeEvent(w http.ResponseWriter, evt Event) (int, error) { return n, err } -// scanEvents iterates SSE events in the given scanner. The iterated error is -// terminal: if encountered, the stream is corrupt or broken and should no -// longer be used. +// DefaultMaxEventSize is the default maximum number of bytes buffered while +// reading a single server-sent event before the input is rejected. +const DefaultMaxEventSize = 16 << 20 // 16 MiB + +// scanEventsLimited iterates SSE events in the given reader, buffering at most +// maxEventSize bytes per event. When maxEventSize > 0, an event is reported as +// [errMalformedEvent] once its size exceeds it; a non-positive maxEventSize +// disables the cap. +// +// The iterated error is terminal: if encountered, the stream is corrupt or +// broken and should no longer be used. // // TODO(rfindley): consider a different API here that makes failure modes more // apparent. -func scanEvents(r io.Reader) iter.Seq2[Event, error] { +func scanEventsLimited(r io.Reader, maxEventSize int) iter.Seq2[Event, error] { reader := bufio.NewReader(r) // TODO: investigate proper behavior when events are out of order, or have @@ -94,8 +102,9 @@ func scanEvents(r io.Reader) iter.Seq2[Event, error] { // - Lines starting with ":" are ignored. // - Records are terminated with two consecutive newlines. var ( - evt Event - dataBuf *bytes.Buffer // if non-nil, preceding field was also data + evt Event + dataBuf *bytes.Buffer // if non-nil, preceding field was also data + eventBytes int // bytes read for the current, in-progress event ) yieldEvent := func() bool { if dataBuf != nil { @@ -112,11 +121,20 @@ func scanEvents(r io.Reader) iter.Seq2[Event, error] { return true } for { - line, err := reader.ReadBytes('\n') + budget := -1 + if maxEventSize > 0 { + budget = maxEventSize - eventBytes + } + line, err := readEventLine(reader, budget) + if errors.Is(err, errEventTooLarge) { + yield(Event{}, fmt.Errorf("%w: SSE event exceeded %d bytes without terminating", errMalformedEvent, maxEventSize)) + return + } if err != nil && !errors.Is(err, io.EOF) { yield(Event{}, fmt.Errorf("error reading event: %v", err)) return } + eventBytes += len(line) line = bytes.TrimRight(line, "\r\n") isEOF := errors.Is(err, io.EOF) @@ -124,6 +142,7 @@ func scanEvents(r io.Reader) iter.Seq2[Event, error] { if !yieldEvent() { return } + eventBytes = 0 // reset the budget between events if isEOF { return } @@ -322,6 +341,31 @@ var ErrEventsPurged = errors.New("data purged") // transient I/O errors which may be retryable. var errMalformedEvent = errors.New("malformed event") +// errEventTooLarge is returned by [readEventLine] when a single line would +// exceed the remaining per-event byte budget. +var errEventTooLarge = errors.New("SSE event exceeded maximum size") + +// readEventLine reads a single '\n'-terminated line from r. When budget >= 0 it +// reads at most budget bytes, returning [errEventTooLarge] once that budget is +// exceeded before a newline arrives. +func readEventLine(r *bufio.Reader, budget int) ([]byte, error) { + if budget < 0 { + return r.ReadBytes('\n') + } + var line []byte + for { + frag, err := r.ReadSlice('\n') + if len(line)+len(frag) > budget { + return nil, errEventTooLarge + } + line = append(line, frag...) + if errors.Is(err, bufio.ErrBufferFull) { + continue // looking for delim + } + return line, err + } +} + // After implements [EventStore.After]. func (s *MemoryEventStore) After(_ context.Context, sessionID, streamID string, index int) iter.Seq2[[]byte, error] { // Return the data items to yield. diff --git a/mcp/event_test.go b/mcp/event_test.go index fedc92c..d326415 100644 --- a/mcp/event_test.go +++ b/mcp/event_test.go @@ -5,9 +5,12 @@ package mcp import ( + "bytes" "context" "crypto/rand" + "errors" "fmt" + "io" "slices" "strings" "testing" @@ -134,7 +137,7 @@ func TestScanEvents(t *testing.T) { r := strings.NewReader(tt.input) var got []Event var err error - for e, err2 := range scanEvents(r) { + for e, err2 := range scanEventsLimited(r, DefaultMaxEventSize) { if err2 != nil { err = err2 break @@ -175,6 +178,136 @@ func TestScanEvents(t *testing.T) { } } +// endlessReader streams a fixed prefix once, then repeats fill forever. +type endlessReader struct { + prefix string + sent bool + repeat string + repIndex int +} + +func (r *endlessReader) Read(p []byte) (int, error) { + if !r.sent { + n := copy(p, r.prefix) + r.sent = true + return n, nil + } + for i := range p { + p[i] = r.repeat[r.repIndex%len(r.repeat)] + r.repIndex++ + } + return len(p), nil +} + +func (r *endlessReader) Close() error { return nil } + +// TestScanEventsMaxEventSize verifies that scanEvents bounds the bytes +// buffered for a single event. +func TestScanEventsMaxEventSize(t *testing.T) { + wantByte := byte('A') + eventOfSize := func(size int) []byte { + var buf bytes.Buffer + buf.WriteString("data: ") + buf.Write(bytes.Repeat([]byte{wantByte}, size)) + buf.WriteString("\n\n") + return buf.Bytes() + } + + tests := []struct { + name string + reader io.Reader + maxEventSize int + wantErr bool + wantDataLengths []int + }{ + { + name: "negative budget disables the cap", + reader: bytes.NewReader(eventOfSize(512)), + maxEventSize: -1, + wantDataLengths: []int{512}, + }, + { + name: "long line under cap", // bufio.Reader default buf size is 1 << 12 + reader: bytes.NewReader(eventOfSize(1 << 14)), + maxEventSize: 1 << 15, + wantDataLengths: []int{1 << 14}, + }, + { + name: "unbounded rejected", + reader: &endlessReader{prefix: "data: ", repeat: string(wantByte)}, + maxEventSize: 1024, + wantErr: true, + }, + + { + name: "consecutive data fields longer than limit", + reader: strings.NewReader(func() string { + var b strings.Builder + for range 2048 { + b.WriteString("data: \n") + } + return b.String() + }()), + maxEventSize: 1024, + wantErr: true, + }, + { + name: "budget resets between events", + reader: bytes.NewReader(append(eventOfSize(3*1024), eventOfSize(3*1024)...)), + maxEventSize: 4 * 1024, + wantDataLengths: []int{3 * 1024, 3 * 1024}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + type result struct { + events []Event + err error + } + + done := make(chan result, 1) + go func() { + var events []Event + for e, err := range scanEventsLimited(tt.reader, tt.maxEventSize) { + if err != nil { + done <- result{events, err} + return + } + events = append(events, e) + } + done <- result{events, nil} + }() + + var res result + select { + case res = <-done: + case <-time.After(5 * time.Second): + t.Fatal("scanEvents did not return") + } + + if tt.wantErr { + if !errors.Is(res.err, errMalformedEvent) { + t.Fatalf("got error %v, want errMalformedEvent", res.err) + } + return + } + if res.err != nil { + t.Fatalf("unexpected error: %v", res.err) + } + if len(res.events) != len(tt.wantDataLengths) { + t.Fatalf("got %d events, want %d", len(res.events), len(tt.wantDataLengths)) + } + for i, wantLen := range tt.wantDataLengths { + wantRepeated := string(wantByte) + if got := res.events[i].Data; string(got) != strings.Repeat(wantRepeated, wantLen) { + t.Errorf("event %d: got %d data bytes, want %d bytes of %s", i, len(got), wantLen, wantRepeated) + } + } + }) + } +} + func TestMemoryEventStoreState(t *testing.T) { ctx := context.Background() diff --git a/mcp/sse.go b/mcp/sse.go index 81b0c0b..c7926b9 100644 --- a/mcp/sse.go +++ b/mcp/sse.go @@ -397,6 +397,11 @@ type SSEClientTransport struct { // HTTPClient is the client to use for making HTTP requests. If nil, // http.DefaultClient is used. HTTPClient *http.Client + + // MaxEventSize bounds the number of bytes buffered while reading a single + // server-sent event. A value of 0 selects [DefaultMaxEventSize], a negative + // value disables the cap. + MaxEventSize int } // Connect connects through the client endpoint. @@ -427,9 +432,14 @@ func (c *SSEClientTransport) Connect(ctx context.Context) (Connection, error) { return nil, fmt.Errorf("failed to connect: %s", http.StatusText(resp.StatusCode)) } + maxEventSize := c.MaxEventSize + if maxEventSize == 0 { + maxEventSize = DefaultMaxEventSize + } + msgEndpoint, err := func() (*url.URL, error) { var evt Event - for evt, err = range scanEvents(resp.Body) { + for evt, err = range scanEventsLimited(resp.Body, maxEventSize) { break } if err != nil { @@ -458,7 +468,7 @@ func (c *SSEClientTransport) Connect(ctx context.Context) (Connection, error) { go func() { defer s.Close() // close the transport when the GET exits - for evt, err := range scanEvents(resp.Body) { + for evt, err := range scanEventsLimited(resp.Body, maxEventSize) { if err != nil { return } diff --git a/mcp/streamable.go b/mcp/streamable.go index 8ce5e01..d1462d9 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -2018,6 +2018,11 @@ type StreamableClientTransport struct { // OAuthHandler is an optional field that, if provided, will be used to authorize the requests. OAuthHandler auth.OAuthHandler + // MaxEventSize bounds the number of bytes buffered while reading a single + // server-sent event. A value of 0 selects [DefaultMaxEventSize], a negative + // value disables the cap. + MaxEventSize int + // TODO(rfindley): propose exporting these. // If strict is set, the transport is in 'strict mode', where any violation // of the MCP spec causes a failure. @@ -2105,6 +2110,7 @@ func (t *StreamableClientTransport) Connect(ctx context.Context) (Connection, er failed: make(chan struct{}), disableStandaloneSSE: t.DisableStandaloneSSE, oauthHandler: t.OAuthHandler, + maxEventSize: t.MaxEventSize, } return conn, nil } @@ -2126,6 +2132,10 @@ type streamableClientConn struct { // oauthHandler is the OAuth handler for the connection. oauthHandler auth.OAuthHandler // from [StreamableClientTransport.OAuthHandler] + // maxEventSize bounds the number of bytes buffered while reading a single + // server-sent event. Resolved from [StreamableClientTransport.MaxEventSize]. + maxEventSize int + // Guard calls to Close, as it may be called multiple times. closeOnce sync.Once closeErr error @@ -2635,7 +2645,7 @@ func (c *streamableClientConn) processStream(ctx context.Context, requestSummary io.Copy(io.Discard, resp.Body) resp.Body.Close() }() - for evt, err := range scanEvents(resp.Body) { + for evt, err := range scanEventsLimited(resp.Body, c.maxEventSize) { if err != nil { if ctx.Err() != nil { return "", 0, true // don't reconnect: client cancelled diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index fed3522..ca4d0a7 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -1544,7 +1544,7 @@ func (s streamableRequest) do(ctx context.Context, serverURL, sessionID string, var respBody []byte if contentType == "text/event-stream" { r := readerInto{resp.Body, new(bytes.Buffer)} - for evt, err := range scanEvents(r) { + for evt, err := range scanEventsLimited(r, DefaultMaxEventSize) { if err != nil { return newSessionID, resp.StatusCode, nil, fmt.Errorf("reading events: %v", err) } @@ -2856,6 +2856,69 @@ data: {"jsonrpc":"2.0","id":1,"result":{}} } } +// TestProcessStreamMaxEventSize verifies that a streamableClientConn honors its +// configured maxEventSize. +func TestProcessStreamMaxEventSize(t *testing.T) { + jsonrpcEventOfSize := func(padSize int) string { + msg := fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"result":{"pad":%q}}`, strings.Repeat("A", padSize)) + return "data: " + msg + "\n\n" + } + + tests := []struct { + name string + maxEventSize int + event string + wantFail bool + }{ + { + name: "event over cap is rejected", + event: jsonrpcEventOfSize(4096), + maxEventSize: 1024, + wantFail: true, + }, + { + name: "event under cap is accepted", + event: jsonrpcEventOfSize(1024), + maxEventSize: 4096, + wantFail: false, + }, + { + name: "negative cap disables the limit", + event: jsonrpcEventOfSize(4096), + maxEventSize: -1, + wantFail: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := t.Context() + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(tt.event)), + } + conn := &streamableClientConn{ + ctx: ctx, + done: make(chan struct{}), + incoming: make(chan jsonrpc.Message, 10), + failed: make(chan struct{}), + logger: ensureLogger(nil), + maxEventSize: tt.maxEventSize, + } + conn.processStream(ctx, "test", resp, nil) + + err := conn.failure() + if tt.wantFail && err == nil { + t.Fatal("failure() = nil, want a non-nil error for an oversized event") + } + if !tt.wantFail && err != nil { + t.Fatalf("failure() = %v, want nil", err) + } + }) + } +} + // TestScanEventsPingFiltering is a unit test for the low-level event scanning // with ping events to verify scanEvents properly parses all event types. func TestScanEventsPingFiltering(t *testing.T) { @@ -2878,7 +2941,7 @@ data: {"jsonrpc":"2.0","method":"test2","params":{}} var events []Event // Scan all events - for evt, err := range scanEvents(reader) { + for evt, err := range scanEventsLimited(reader, DefaultMaxEventSize) { if err != nil { if err != io.EOF { t.Fatalf("scanEvents error: %v", err) diff --git a/mcp/transport.go b/mcp/transport.go index 1cf0325..8b6cd14 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -119,13 +119,22 @@ type serverConnection interface { sessionUpdated(ServerSessionState) } +// DefaultMaxLineLength is the default maximum number of bytes buffered while +// decoding a single inbound JSON-RPC frame. +const DefaultMaxLineLength = 16 * 1024 * 1024 + // A StdioTransport is a [Transport] that communicates over stdin/stdout using // newline-delimited JSON. -type StdioTransport struct{} +type StdioTransport struct { + // MaxLineLength bounds the number of bytes that may be buffered while + // decoding a single inbound JSON-RPC frame. A value of 0 selects [DefaultMaxLineLength], + // a negative value disables the cap. + MaxLineLength int +} // Connect implements the [Transport] interface. -func (*StdioTransport) Connect(context.Context) (Connection, error) { - return newIOConn(rwc{os.Stdin, nopCloserWriter{os.Stdout}}), nil +func (t *StdioTransport) Connect(context.Context) (Connection, error) { + return newIOConnLimited(rwc{os.Stdin, nopCloserWriter{os.Stdout}}, t.MaxLineLength), nil } // nopCloserWriter is an io.WriteCloser with a trivial Close method. @@ -140,11 +149,15 @@ func (nopCloserWriter) Close() error { return nil } type IOTransport struct { Reader io.ReadCloser Writer io.WriteCloser + // MaxLineLength bounds the number of bytes that may be buffered while + // decoding a single inbound JSON-RPC frame. A value of 0 selects [DefaultMaxLineLength], + // a negative value disables the cap. + MaxLineLength int } // Connect implements the [Transport] interface. func (t *IOTransport) Connect(context.Context) (Connection, error) { - return newIOConn(rwc{t.Reader, t.Writer}), nil + return newIOConnLimited(rwc{t.Reader, t.Writer}, t.MaxLineLength), nil } // An InMemoryTransport is a [Transport] that communicates over an in-memory @@ -478,6 +491,17 @@ type msgOrErr struct { } func newIOConn(rwc io.ReadWriteCloser) *ioConn { + return newIOConnLimited(rwc, DefaultMaxLineLength) +} + +// newIOConnLimited builds an [ioConn] over rwc that bounds the number of bytes +// buffered while decoding a single inbound JSON-RPC frame to maxLineLength. +// maxLineLength == 0 selects [DefaultMaxLineLength], a negative value means no cap. +func newIOConnLimited(rwc io.ReadWriteCloser, maxLineLength int) *ioConn { + limit := maxLineLength + if limit == 0 { + limit = DefaultMaxLineLength + } var ( incoming = make(chan msgOrErr) closed = make(chan struct{}) @@ -489,7 +513,15 @@ func newIOConn(rwc io.ReadWriteCloser) *ioConn { // but that is unavoidable since AFAIK there is no (easy and portable) way to // guarantee that reads of stdin are unblocked when closed. go func() { - dec := json.NewDecoder(rwc) + var ( + reader io.Reader = rwc + limiter *frameLimitReader + ) + if limit > 0 { + limiter = &frameLimitReader{r: rwc, limit: limit} + reader = limiter + } + dec := json.NewDecoder(reader) for { var raw json.RawMessage err := dec.Decode(&raw) @@ -515,6 +547,9 @@ func newIOConn(rwc io.ReadWriteCloser) *ioConn { if err != nil { return } + if limiter != nil { + limiter.resetFrame() + } } }() return &ioConn{ @@ -524,6 +559,32 @@ func newIOConn(rwc io.ReadWriteCloser) *ioConn { } } +// errFrameTooLarge means that a single inbound JSON-RPC frame exceeded the configured byte +// limit before the value was completely received. +var errFrameTooLarge = errors.New("inbound JSON-RPC frame exceeded the configured maximum line length") + +// frameLimitReader bounds the number of bytes [json.Decoder] may buffer while +// decoding a single JSON value. Read returns [errFrameTooLarge] once the budget is exhausted. +type frameLimitReader struct { + r io.Reader + limit int + count int +} + +func (r *frameLimitReader) Read(p []byte) (int, error) { + if r.count >= r.limit { + return 0, errFrameTooLarge + } + if len(p) > r.limit-r.count { + p = p[:r.limit-r.count] + } + n, err := r.r.Read(p) + r.count += n + return n, err +} + +func (r *frameLimitReader) resetFrame() { r.count = 0 } + func (c *ioConn) SessionID() string { return "" } func (c *ioConn) sessionUpdated(state ServerSessionState) { diff --git a/mcp/transport_test.go b/mcp/transport_test.go index 52228d0..d4d7782 100644 --- a/mcp/transport_test.go +++ b/mcp/transport_test.go @@ -6,9 +6,12 @@ package mcp import ( "context" + "errors" + "fmt" "io" "strings" "testing" + "time" "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" "github.com/modelcontextprotocol/go-sdk/jsonrpc" @@ -167,3 +170,84 @@ func TestIOConnRead_EmptyMethod(t *testing.T) { t.Errorf("ID = %v, want 5", req.ID.Raw()) } } + +// TestIOConnFrameCap verifies single inbound frame cap enforcement. +func TestIOConnFrameCap(t *testing.T) { + tests := []struct { + name string + r io.ReadCloser + limit int + wantMessageCount int + wantErr bool + }{ + { + name: "infinite string", + r: &endlessReader{prefix: `"`, repeat: "A"}, + limit: 1024, + wantErr: true, + }, + { + name: "cap does not reset per new line", + r: &endlessReader{prefix: "[", repeat: "0,\n"}, + limit: 1024, + wantErr: true, + }, + { + name: "cap resets per message", + limit: 1024, // < 600 * 3 + r: io.NopCloser(strings.NewReader(func() string { + var b strings.Builder + for i := range 3 { + fmt.Fprintf(&b, `{"jsonrpc":"2.0","id":%d,"method":"test","params":{"pad":"`, i) + b.WriteString(strings.Repeat("A", 600)) + b.WriteString(`"}}`) + b.WriteByte('\n') + } + return b.String() + }())), + wantMessageCount: 3, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tr := newIOConnLimited(rwc{rc: tt.r}, tt.limit) + t.Cleanup(func() { tr.Close() }) + + read := func() (jsonrpc.Message, error) { + type result struct { + msg jsonrpc.Message + err error + } + done := make(chan result, 1) + + go func() { + msg, err := tr.Read(context.Background()) + done <- result{msg, err} + }() + + select { + case r := <-done: + return r.msg, r.err + case <-time.After(10 * time.Second): + t.Fatal("Read() did not return: frame cap failed to trip") + return nil, nil + } + } + + for i := range tt.wantMessageCount { + msg, err := read() + if err != nil { + t.Fatalf("Read() #%d error = %v, want nil", i, err) + } + if msg == nil { + t.Fatalf("Read() #%d returned nil message", i) + } + } + if tt.wantErr { + if _, err := read(); !errors.Is(err, errFrameTooLarge) { + t.Fatalf("Read() error = %v, want errFrameTooLarge", err) + } + } + }) + } +} From 12cbafe54df4cd9325d84f7a846100acddfdcc87 Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Wed, 2 Sep 2026 21:07:19 +0200 Subject: [PATCH 401/418] oauthex: oauth discovery checks (#1220) add extra checks for the default oauth discovery client: * disallow redirects which downgrade to http * disallow redirects to localhost or private ip * disallow the initial call to a private ip * proxy configuration opts out of dial time checking --- docs/protocol.md | 26 +++--- internal/docs/protocol.src.md | 26 +++--- internal/util/net.go | 23 +++++ internal/util/net_test.go | 54 +++++++++++- oauthex/discovery_test.go | 158 ++++++++++++++++++++++++++++++++++ oauthex/oauth2.go | 120 +++++++++++++++++++++++++- 6 files changed, 376 insertions(+), 31 deletions(-) create mode 100644 oauthex/discovery_test.go diff --git a/docs/protocol.md b/docs/protocol.md index f60371b..06c1b77 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -639,24 +639,24 @@ provided to ### Server-Side Request Forgery -The [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) are as follows: +The OAuth discovery helpers apply these [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) by default: -- _Enforce HTTPS_. The OAuth helpers provided by the SDK reject the `http://` URLs -except loopback addresses (`localhost`, `127.0.0.1`, `::1`). +- _Enforce HTTPS_. Reject `http://` URLs (except loopback: `localhost`, `127.0.0.1`, `::1`) +and redirects that downgrade `https` to a non-`https` scheme. -- _Block Private IP Ranges_. The OAuth helpers provided by the SDK allow passing -a custom `http.Client`. Developers are advised to customize the client it with -appropriate network protections, including IP range blocking. The SDK does not provide -this capability out of the box. +- _Block Private IP Ranges_. Reject targets that resolve to a non-public address +(private, link-local including the `169.254.169.254` metadata endpoint, CGNAT, multicast, +unspecified). The check runs on the initial URL and at dial time, guarding against DNS rebinding. -- _Validate Redirect Targets_. Similarly to previous point, customized `http.Client` -can be used to validate network hops. The SDK does not provide this capability out -of the box. +- _Validate Redirect Targets_. Reject redirects to non-public addresses and cap the redirect count. -- _Use Egress Proxies_. This is out of scope for the SDK and can be configured separately. +- _Use Egress Proxies_. Out of scope for the SDK; configure separately. -- _DNS Resolution Considerations_. The SDK has DNS rebinding protection on the server side which is enabled by default. For the client side, consider providing -a custom `http.Client` that would implement DNS pinning. +**Opting out.** Passing a custom `http.Client` can bypass these checks, making SSRF +protection your responsibility. Specifically: a custom `Transport.DialContext`/`DialTLSContext`, +a non-`*http.Transport`, or a configured `Proxy` disables the dial-time IP check; setting +`CheckRedirect` replaces all redirect validation. In these cases, implement your own IP +blocking, redirect validation, and DNS pinning. ### Session Hijacking diff --git a/internal/docs/protocol.src.md b/internal/docs/protocol.src.md index dbc8c6e..7d6b01b 100644 --- a/internal/docs/protocol.src.md +++ b/internal/docs/protocol.src.md @@ -534,24 +534,24 @@ provided to ### Server-Side Request Forgery -The [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) are as follows: +The OAuth discovery helpers apply these [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) by default: -- _Enforce HTTPS_. The OAuth helpers provided by the SDK reject the `http://` URLs -except loopback addresses (`localhost`, `127.0.0.1`, `::1`). +- _Enforce HTTPS_. Reject `http://` URLs (except loopback: `localhost`, `127.0.0.1`, `::1`) +and redirects that downgrade `https` to a non-`https` scheme. -- _Block Private IP Ranges_. The OAuth helpers provided by the SDK allow passing -a custom `http.Client`. Developers are advised to customize the client it with -appropriate network protections, including IP range blocking. The SDK does not provide -this capability out of the box. +- _Block Private IP Ranges_. Reject targets that resolve to a non-public address +(private, link-local including the `169.254.169.254` metadata endpoint, CGNAT, multicast, +unspecified). The check runs on the initial URL and at dial time, guarding against DNS rebinding. -- _Validate Redirect Targets_. Similarly to previous point, customized `http.Client` -can be used to validate network hops. The SDK does not provide this capability out -of the box. +- _Validate Redirect Targets_. Reject redirects to non-public addresses and cap the redirect count. -- _Use Egress Proxies_. This is out of scope for the SDK and can be configured separately. +- _Use Egress Proxies_. Out of scope for the SDK; configure separately. -- _DNS Resolution Considerations_. The SDK has DNS rebinding protection on the server side which is enabled by default. For the client side, consider providing -a custom `http.Client` that would implement DNS pinning. +**Opting out.** Passing a custom `http.Client` can bypass these checks, making SSRF +protection your responsibility. Specifically: a custom `Transport.DialContext`/`DialTLSContext`, +a non-`*http.Transport`, or a configured `Proxy` disables the dial-time IP check; setting +`CheckRedirect` replaces all redirect validation. In these cases, implement your own IP +blocking, redirect validation, and DNS pinning. ### Session Hijacking diff --git a/internal/util/net.go b/internal/util/net.go index 6858614..593012d 100644 --- a/internal/util/net.go +++ b/internal/util/net.go @@ -24,3 +24,26 @@ func IsLoopback(addr string) bool { } return ip.IsLoopback() } + +// cgnat is the RFC 6598 carrier-grade NAT range (100.64.0.0/10). netip's +// IsPrivate does not report it, but it is shared ISP space that is not publicly +// routable and could point at another tenant behind the same NAT. +var cgnat = netip.MustParsePrefix("100.64.0.0/10") + +// IsPrivateOrReserved reports whether ip belongs to a range that must not be +// reachable through a server-controlled URL, as a defense against SSRF: private +// (RFC 1918 / RFC 4193 ULA), link-local (including the 169.254.169.254 cloud +// metadata endpoint), carrier-grade NAT, multicast, and the unspecified address. +func IsPrivateOrReserved(ip netip.Addr) bool { + ip = ip.Unmap() + if !ip.IsValid() { + return true + } + if ip.IsPrivate() || + ip.IsLinkLocalUnicast() || + ip.IsMulticast() || + ip.IsUnspecified() { + return true + } + return cgnat.Contains(ip) +} diff --git a/internal/util/net_test.go b/internal/util/net_test.go index fd3187b..21b7179 100644 --- a/internal/util/net_test.go +++ b/internal/util/net_test.go @@ -3,7 +3,10 @@ // that can be found in the LICENSE file. package util -import "testing" +import ( + "net/netip" + "testing" +) // TestIsLoopback tests the IsLoopback helper function. func TestIsLoopback(t *testing.T) { @@ -33,3 +36,52 @@ func TestIsLoopback(t *testing.T) { }) } } + +func TestIsPrivateOrReserved(t *testing.T) { + tests := []struct { + ip string + want bool + }{ + // Private (RFC 1918) and IPv6 ULA (RFC 4193). + {"10.0.0.1", true}, + {"172.16.5.4", true}, + {"192.168.1.1", true}, + {"fd00::1", true}, + // Link-local, including the cloud metadata endpoint. + {"169.254.169.254", true}, + {"169.254.0.1", true}, + {"fe80::1", true}, + // Carrier-grade NAT (RFC 6598). + {"100.64.0.1", true}, + {"100.127.255.255", true}, + // Multicast and unspecified. + {"224.0.0.1", true}, + {"ff02::1", true}, + {"0.0.0.0", true}, + {"::", true}, + // IPv4-mapped IPv6 of a private address must also be caught. + {"::ffff:10.0.0.1", true}, + {"::ffff:192.168.0.1", true}, + // Loopback is intentionally NOT reserved here (policy handled elsewhere). + {"127.0.0.1", false}, + {"::1", false}, + // Public addresses are allowed. + {"8.8.8.8", false}, + {"1.1.1.1", false}, + {"2606:4700:4700::1111", false}, + // Just outside CGNAT. + {"100.63.255.255", false}, + {"100.128.0.0", false}, + } + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + ip, err := netip.ParseAddr(tt.ip) + if err != nil { + t.Fatalf("ParseAddr(%q): %v", tt.ip, err) + } + if got := IsPrivateOrReserved(ip); got != tt.want { + t.Errorf("IsPrivateOrReserved(%q) = %v, want %v", tt.ip, got, tt.want) + } + }) + } +} diff --git a/oauthex/discovery_test.go b/oauthex/discovery_test.go new file mode 100644 index 0000000..8b64d0c --- /dev/null +++ b/oauthex/discovery_test.go @@ -0,0 +1,158 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package oauthex + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestCheckHTTPSOrLoopback(t *testing.T) { + tests := []struct { + name string + addr string + wantErr bool + }{ + {name: "empty", addr: "", wantErr: false}, + {name: "https dns allowed", addr: "https://example.com/prm", wantErr: false}, + {name: "cleartext dns disallowed", addr: "http://example.com/prm", wantErr: true}, + {name: "https loopback ip allowed", addr: "http://127.0.0.1:8080/prm", wantErr: false}, + {name: "http localhost allowed", addr: "http://localhost/prm", wantErr: false}, + {name: "https private ip disallowed", addr: "https://10.0.0.1/prm", wantErr: true}, + {name: "link local metadata disallowed", addr: "https://169.254.169.254/", wantErr: true}, + {name: "mapped private address disallowed", addr: "https://[::ffff:10.0.0.1]/prm", wantErr: true}, + {name: "https public ip allowed", addr: "https://8.8.8.8/prm", wantErr: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := checkHTTPSOrLoopback(tt.addr) + if (err != nil) != tt.wantErr { + t.Errorf("checkHTTPSOrLoopback(%q) error = %v, wantErr %v", tt.addr, err, tt.wantErr) + } + }) + } +} + +func TestDiscoveryClientRedirect(t *testing.T) { + newRequestTo := func(u string) *http.Request { + t.Helper() + r, err := http.NewRequest(http.MethodGet, u, nil) + if err != nil { + t.Fatalf("NewRequest(%q): %v", u, err) + } + return r + } + testCases := []struct { + name string + custom *http.Client + req *http.Request + via []*http.Request + wantBlocked bool + }{ + { + name: "redirect to public allowed", + req: newRequestTo("https://8.8.8.8/"), + via: []*http.Request{newRequestTo("https://93.184.216.34/")}, + wantBlocked: false, + }, + { + name: "downgrade blocked", + req: newRequestTo("http://93.184.216.34/"), + via: []*http.Request{newRequestTo("https://93.184.216.34/")}, + wantBlocked: true, + }, + { + name: "redirect to loopback blocked", + req: newRequestTo("https://127.0.0.1/"), + via: []*http.Request{newRequestTo("https://93.184.216.34/")}, + wantBlocked: true, + }, + { + name: "redirect to hostname resolving to loopback blocked", + req: newRequestTo("https://localhost/"), + via: []*http.Request{newRequestTo("https://93.184.216.34/")}, + wantBlocked: true, + }, + { + name: "custom client with no custom check", + custom: &http.Client{}, + req: newRequestTo("https://127.0.0.1/"), + via: []*http.Request{newRequestTo("https://93.184.216.34/")}, + wantBlocked: true, + }, + { + name: "custom client check disable", + custom: &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return nil + }, + }, + req: newRequestTo("https://127.0.0.1/"), + via: []*http.Request{newRequestTo("https://93.184.216.34/")}, + wantBlocked: false, + }, + { + name: "too many redirects", + req: newRequestTo("https://8.8.8.8/"), + via: func() []*http.Request { + via := make([]*http.Request, maxDiscoveryRedirects) + for i := range via { + via[i] = newRequestTo("https://8.8.8.8/") + } + return via + }(), + wantBlocked: true, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + client := newDiscoveryClient(tc.custom) + gotErr := client.CheckRedirect(tc.req, tc.via) + if gotErr != nil && !tc.wantBlocked { + t.Fatalf("client.CheckRedirect() error = %v", gotErr) + } + if gotErr == nil && tc.wantBlocked { + t.Fatal("client.CheckRedirect() error = nil, want blocked") + } + }) + } +} + +func TestDiscoveryBlocksInternalTarget(t *testing.T) { + ctx := t.Context() + for _, target := range []string{ + "https://169.254.169.254/latest/meta-data/", + "https://10.0.0.1/.well-known/oauth-protected-resource", + } { + t.Run(target, func(t *testing.T) { + _, err := GetProtectedResourceMetadata(ctx, target, target, nil) + if err == nil { + t.Fatalf("GetProtectedResourceMetadata(%q) succeeded, want SSRF rejection", target) + } + if !strings.Contains(err.Error(), "non-public") { + t.Errorf("error = %v, want it to mention a non-public address", err) + } + }) + } +} + +func TestDiscoveryAllowsLoopbackInitial(t *testing.T) { + ctx := t.Context() + h := &fakeResourceHandler{} + server := httptest.NewTLSServer(h) + defer server.Close() + h.installHandlers(server.URL) + + metadataURL := server.URL + "/.well-known/oauth-protected-resource" + prm, err := GetProtectedResourceMetadata(ctx, metadataURL, server.URL, server.Client()) + if err != nil { + t.Fatalf("GetProtectedResourceMetadata on loopback failed: %v", err) + } + if prm == nil { + t.Fatal("nil prm") + } +} diff --git a/oauthex/oauth2.go b/oauthex/oauth2.go index 689f17a..caed92f 100644 --- a/oauthex/oauth2.go +++ b/oauthex/oauth2.go @@ -11,14 +11,22 @@ import ( "fmt" "io" "mime" + "net" "net/http" + "net/netip" "net/url" "strings" + "syscall" + "time" "github.com/modelcontextprotocol/go-sdk/internal/json" "github.com/modelcontextprotocol/go-sdk/internal/util" ) +const maxDiscoveryRedirects = 10 + +var defaultDiscoveryTransport = newDiscoveryTransport(http.DefaultTransport) + type httpStatusError struct { StatusCode int } @@ -35,10 +43,7 @@ func getJSON[T any](ctx context.Context, c *http.Client, url string, limit int64 if err != nil { return nil, err } - if c == nil { - c = http.DefaultClient - } - res, err := c.Do(req) + res, err := newDiscoveryClient(c).Do(req) if err != nil { return nil, err } @@ -91,5 +96,112 @@ func checkHTTPSOrLoopback(addr string) error { if !util.IsLoopback(u.Host) && u.Scheme != "https" { return fmt.Errorf("URL %q does not use HTTPS or is not a loopback address", addr) } + if ip, err := netip.ParseAddr(u.Hostname()); err == nil && util.IsPrivateOrReserved(ip) { + return fmt.Errorf("URL %q refers to a non-public address", addr) + } + return nil +} + +func newDiscoveryClient(c *http.Client) *http.Client { + transport := defaultDiscoveryTransport + if c != nil && c.Transport != nil { + // a caller that has taken over dialing or TLS opts out of the checks + if t, ok := c.Transport.(*http.Transport); ok && t.DialContext == nil && t.DialTLSContext == nil { + transport = newDiscoveryTransport(t) + } else { + transport = c.Transport + } + } + discoveryClient := &http.Client{ + Transport: transport, + + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if c != nil && c.CheckRedirect != nil { + return c.CheckRedirect(req, via) + } + if len(via) >= maxDiscoveryRedirects { + return fmt.Errorf("max redirects exceeded (%d)", maxDiscoveryRedirects) + } + if prev := via[len(via)-1]; prev.URL.Scheme == "https" && req.URL.Scheme != "https" { + return fmt.Errorf("redirect to non-HTTPS scheme %q is a security downgrade", req.URL.Scheme) + } + return checkLoopbackRedirect(req.Context(), req.URL.Hostname()) + }, + } + if c != nil { + discoveryClient.Timeout = c.Timeout + discoveryClient.Jar = c.Jar + } + return discoveryClient +} + +// newDiscoveryTransport creates an http.RoundTripper with extra protections enabled, +// unless the provided RoundTripper is not *http.Transport. +func newDiscoveryTransport(rt http.RoundTripper) http.RoundTripper { + base, ok := rt.(*http.Transport) + if !ok { + return rt + } + // If a proxy is configured Control checks should be disabled, because they will + // basically be running for proxy dials. + if proxyConfigured(base) { + return rt + } + result := base.Clone() + result.DialContext = (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + Control: func(_, address string, _ syscall.RawConn) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + host = address + } + ip, err := netip.ParseAddr(host) + if err != nil { + return fmt.Errorf("non-ip address: %w", err) + } + if util.IsPrivateOrReserved(ip) { + return fmt.Errorf("non-public ip address: %q", ip) + } + return nil + }, + }).DialContext + return result +} + +func proxyConfigured(t *http.Transport) bool { + if t.Proxy == nil { + return false + } + req, err := http.NewRequest(http.MethodGet, "https://check-proxy-existence.com", nil) + if err != nil { + return false + } + u, err := t.Proxy(req) + return err == nil && u != nil +} + +func checkLoopbackRedirect(ctx context.Context, host string) error { + doCheck := func(ip netip.Addr) error { + if ip.Unmap().IsLoopback() { + return fmt.Errorf("redirect into loopback address %q", host) + } + if util.IsPrivateOrReserved(ip) { + return fmt.Errorf("redirect into non-public address %q", host) + } + return nil + } + if ip, err := netip.ParseAddr(host); err == nil { + return doCheck(ip) + } + ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host) + if err != nil { + return fmt.Errorf("cannot resolve redirect host %q: %w", host, err) + } + for _, ip := range ips { + if err := doCheck(ip); err != nil { + return err + } + } return nil } From 830f0b76e7d4ed2f2b77ca9eb4c481d880771fff Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Thu, 3 Sep 2026 11:27:42 +0200 Subject: [PATCH 402/418] mcp: update conformance tests (#1231) Fixes #1230 --- .github/workflows/conformance.yml | 12 ++++---- conformance/everything-client/main.go | 41 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 7d6cca4..6f54df4 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -14,7 +14,7 @@ permissions: contents: read env: - CONFORMANCE_VERSION: "0.2.0-alpha.10" + CONFORMANCE_VERSION: "0.2.0-alpha.11" jobs: server-conformance: @@ -32,7 +32,7 @@ jobs: node-version: 22 - name: Build everything-server run: go build -o everything-server ./conformance/everything-server - - name: Run conformance tests (stateful, legacy spec versions) + - name: Run conformance tests (2025-11-25, stateful) run: | ./everything-server -http=":3001" -stateless=false & SERVER_PID=$! @@ -40,10 +40,9 @@ jobs: timeout 30 bash -c 'until curl -s http://localhost:3001/mcp > /dev/null; do sleep 0.5; done' npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \ --url http://localhost:3001/mcp \ - --suite active \ - --spec-version 2025-11-25 \ + --requirements 2025-11-25 \ --expected-failures ./conformance/baseline.yml - - name: Run conformance tests (stateless, 2026-07-28, full suite) + - name: Run conformance tests (2026-07-28, stateless) run: | ./everything-server -http=":3001" -stateless & SERVER_PID=$! @@ -51,8 +50,7 @@ jobs: timeout 30 bash -c 'until curl -s http://localhost:3001/mcp > /dev/null; do sleep 0.5; done' npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \ --url http://localhost:3001/mcp \ - --suite all \ - --spec-version 2026-07-28 \ + --requirements 2026-07-28 \ --expected-failures ./conformance/baseline.yml client-conformance: diff --git a/conformance/everything-client/main.go b/conformance/everything-client/main.go index 3776522..b7d8ba0 100644 --- a/conformance/everything-client/main.go +++ b/conformance/everything-client/main.go @@ -49,6 +49,7 @@ func init() { registerScenario("elicitation-sep1034-client-defaults", runElicitationDefaultsClient) registerScenario("sse-retry", runSSERetryClient) registerScenario("json-schema-ref-no-deref", runJSONSchemaRefNoDerefClient) + registerScenario("json-schema-2020-12-preservation", runJSONSchemaPreservationClient) registerScenario("sep-2322-client-request-state", runMrtrClient) registerScenario("http-standard-headers", runHTTPStandardHeadersClient) registerScenario("http-custom-headers", runHTTPCustomHeadersClient) @@ -343,6 +344,46 @@ func runJSONSchemaRefNoDerefClient(ctx context.Context, serverURL string, _ map[ return nil } +// ============================================================================ +// json-schema-2020-12-preservation scenario (SEP-1613, SEP-2106) +// ============================================================================ + +// runJSONSchemaPreservationClient echoes the focal tool's inputSchema back to +// the referee verbatim, through the permissive json_schema_echo tool, so that +// it can observe which 2020-12 keywords survived the client's parse of +// tools/list. The Go SDK surfaces a server's schema as the raw JSON object it +// received, so nothing is stripped. +func runJSONSchemaPreservationClient(ctx context.Context, serverURL string, _ map[string]any) error { + session, err := connectToServer(ctx, serverURL) + if err != nil { + return err + } + defer session.Close() + + tools, err := session.ListTools(ctx, nil) + if err != nil { + return fmt.Errorf("session.ListTools(): %v", err) + } + + const focalTool = "json_schema_2020_12_tool" + idx := slices.IndexFunc(tools.Tools, func(t *mcp.Tool) bool { + return t.Name == focalTool + }) + if idx == -1 { + return fmt.Errorf("tool %q not found", focalTool) + } + + const echoTool = "json_schema_echo" + if _, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: echoTool, + Arguments: map[string]any{"schema": tools.Tools[idx].InputSchema}, + }); err != nil { + return fmt.Errorf("session.CallTool(%q): %v", echoTool, err) + } + + return nil +} + // ============================================================================ // SEP-2322 multi-round-trip client scenario // ============================================================================ From 3f3b699b2b67e1ed033a63d6651671dab53c2d32 Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Fri, 4 Sep 2026 10:08:52 +0200 Subject: [PATCH 403/418] refactor: remove legacy MCPGODEBUG compatibility for new protocol release (#1237) --- docs/mcpgodebug.md | 17 +++++++++++++++++ internal/docs/mcpgodebug.src.md | 17 +++++++++++++++++ mcp/protocol.go | 12 +----------- mcp/sse.go | 4 ++-- mcp/streamable.go | 33 ++++----------------------------- 5 files changed, 41 insertions(+), 42 deletions(-) diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index d25b9b4..57766ce 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -44,6 +44,23 @@ Options listed below were added and will be removed in the 1.9.0 version of the soon as its context is cancelled and cannot be delayed by a slow or unresponsive peer. See issue #1150. +Options below were removed, according to plan: + +- `seterroroverwrite`. `SetError` now always preserves existing `Content`. + +- `enableoriginverification`. A nil + `StreamableHTTPOptions.CrossOriginProtection` now always means no + cross-origin protection. Wrap the handler with cross-origin protection + middleware instead. + +- `disablecontenttypecheck`. Content-Type validation on HTTP POST requests can + no longer be disabled. + +- `disablelocalhostprotection`, whose removal had been postponed to this + release. Set the `DisableLocalhostProtection` field in + `StreamableHTTPOptions` or `SSEOptions` to opt out of DNS rebinding + protection. + ### 1.7.0 Options listed below were added and will be removed in the 1.9.0 version of the SDK. diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index 32f8af2..fd0f255 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -43,6 +43,23 @@ Options listed below were added and will be removed in the 1.9.0 version of the soon as its context is cancelled and cannot be delayed by a slow or unresponsive peer. See issue #1150. +Options below were removed, according to plan: + +- `seterroroverwrite`. `SetError` now always preserves existing `Content`. + +- `enableoriginverification`. A nil + `StreamableHTTPOptions.CrossOriginProtection` now always means no + cross-origin protection. Wrap the handler with cross-origin protection + middleware instead. + +- `disablecontenttypecheck`. Content-Type validation on HTTP POST requests can + no longer be disabled. + +- `disablelocalhostprotection`, whose removal had been postponed to this + release. Set the `DisableLocalhostProtection` field in + `StreamableHTTPOptions` or `SSEOptions` to opt out of DNS rebinding + protection. + ### 1.7.0 Options listed below were added and will be removed in the 1.9.0 version of the SDK. diff --git a/mcp/protocol.go b/mcp/protocol.go index 4867e54..63d7015 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -329,23 +329,13 @@ type CallToolResult struct { err error } -// seterroroverwrite is a compatibility parameter that restores the pre-1.6.0 -// behavior of [CallToolResult.SetError], where Content was always overwritten -// with the error text. See the documentation for the mcpgodebug package for -// instructions on how to enable it. -// The option will be removed in the 1.8.0 version of the SDK. -var seterroroverwrite = mcpgodebug.Value("seterroroverwrite") - // SetError sets the error for the tool result and sets IsError to true. // If Content has not already been populated, it is set to the error text. // If Content has already been populated, it is left unchanged, allowing callers // to provide a user-friendly message while still recording the underlying error // for inspection via [GetError] in server middleware. -// -// To restore the previous behavior where Content was always overwritten, -// set MCPGODEBUG=seterroroverwrite=1. func (r *CallToolResult) SetError(err error) { - if len(r.Content) == 0 || seterroroverwrite == "1" { + if len(r.Content) == 0 { r.Content = []Content{&TextContent{Text: err.Error()}} } r.IsError = true diff --git a/mcp/sse.go b/mcp/sse.go index c7926b9..0a5bf7f 100644 --- a/mcp/sse.go +++ b/mcp/sse.go @@ -225,7 +225,7 @@ func (t *SSEServerTransport) SupportsProtocolVersion(version string) bool { func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { // DNS rebinding protection: auto-enabled for localhost servers. // See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise - if !h.opts.DisableLocalhostProtection && disablelocalhostprotection != "1" { + if !h.opts.DisableLocalhostProtection { if localAddr, ok := req.Context().Value(http.LocalAddrContextKey).(net.Addr); ok && localAddr != nil { if util.IsLoopback(localAddr.String()) && !util.IsLoopback(req.Host) { http.Error(w, fmt.Sprintf("Forbidden: invalid Host header %q", req.Host), http.StatusForbidden) @@ -235,7 +235,7 @@ func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { } // Validate 'Content-Type' header. - if disablecontenttypecheck != "1" && req.Method == http.MethodPost { + if req.Method == http.MethodPost { mediaType, _, err := mime.ParseMediaType(req.Header.Get("Content-Type")) if err != nil || mediaType != "application/json" { http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) diff --git a/mcp/streamable.go b/mcp/streamable.go index d1462d9..f24badb 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -184,8 +184,7 @@ type StreamableHTTPOptions struct { // CrossOriginProtection allows to customize cross-origin protection. // The deny handler set in the CrossOriginProtection through SetDenyHandler // is ignored. - // If nil, no cross-origin protection is applied. Use the `enableoriginverification` - // MCPGODEBUG compatibility parameter to enable the default protection until v1.8.0. + // If nil, no cross-origin protection is applied. // // Deprecated: wrap the handler with cross-origin protection middleware // instead. For example: @@ -240,10 +239,6 @@ func NewStreamableHTTPHandler(getServer func(*http.Request) *Server, opts *Strea h.opts.Logger = ensureLogger(h.opts.Logger) - if h.opts.CrossOriginProtection == nil && enableoriginverification == "1" { - h.opts.CrossOriginProtection = &http.CrossOriginProtection{} - } - if h.opts.MaxRequestBodyBytes == 0 { h.opts.MaxRequestBodyBytes = DefaultMaxRequestBodyBytes } @@ -274,20 +269,6 @@ func (h *StreamableHTTPHandler) closeAll() { } } -// disablelocalhostprotection is a compatibility parameter that allows to disable -// DNS rebinding protection, which was added in the 1.4.0 version of the SDK. -// See the documentation for the mcpgodebug package for instructions how to enable it. -// The option will be removed in the 1.8.0 version of the SDK. -var disablelocalhostprotection = mcpgodebug.Value("disablelocalhostprotection") - -// enableoriginverification is a compatibility parameter that restores the -// default cross-origin protection behavior from v1.4.1-v1.5.0. When set to -// "1", a zero-value CrossOriginProtection will be applied if none is -// explicitly provided in StreamableHTTPOptions. -// See the documentation for the mcpgodebug package for instructions how to enable it. -// The option will be removed in the 1.8.0 version of the SDK. -var enableoriginverification = mcpgodebug.Value("enableoriginverification") - // allowsessionsinstateless is a compatibility parameter that restores the old // behavior of reading and using Mcp-Session-Id headers in stateless mode. When // set to "1", stateless servers will read the session ID from the request @@ -308,12 +289,6 @@ var allowsessionsinstateless = mcpgodebug.Value("allowsessionsinstateless") // permanently fails the connection. var noprotocolerrorbody = mcpgodebug.Value("noprotocolerrorbody") -// disablecontenttypecheck is a compatibility parameter that allows to disable -// Content-Type validation on POST requests. -// See the documentation for the mcpgodebug package for instructions how to enable it. -// The option will be removed in the 1.8.0 version of the SDK. -var disablecontenttypecheck = mcpgodebug.Value("disablecontenttypecheck") - // plaintextstatefulrejection is a compatibility parameter that restores the // previous behavior of a stateful [StreamableHTTPHandler] when it receives a // request carrying SEP-2575 per-request metadata (i.e. an @@ -341,7 +316,7 @@ func writeJSONRPCError(w http.ResponseWriter, status int, id jsonrpc.ID, jerr *j func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { // DNS rebinding protection: auto-enabled for localhost servers. // See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise - if !h.opts.DisableLocalhostProtection && disablelocalhostprotection != "1" { + if !h.opts.DisableLocalhostProtection { if localAddr, ok := req.Context().Value(http.LocalAddrContextKey).(net.Addr); ok && localAddr != nil { if util.IsLoopback(localAddr.String()) && !util.IsLoopback(req.Host) { http.Error(w, fmt.Sprintf("Forbidden: invalid Host header %q", req.Host), http.StatusForbidden) @@ -410,7 +385,7 @@ func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http. return } - if disablecontenttypecheck != "1" && baseMediaType(req.Header.Get("Content-Type")) != "application/json" { + if baseMediaType(req.Header.Get("Content-Type")) != "application/json" { http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) return } @@ -644,7 +619,7 @@ func (h *StreamableHTTPHandler) serveStatefulDELETE(w http.ResponseWriter, req * // ID, a new session is created (this is the normal path for the first // initialize request). func (h *StreamableHTTPHandler) serveStatefulPOST(w http.ResponseWriter, req *http.Request) { - if disablecontenttypecheck != "1" && baseMediaType(req.Header.Get("Content-Type")) != "application/json" { + if baseMediaType(req.Header.Get("Content-Type")) != "application/json" { http.Error(w, "Content-Type must be 'application/json'", http.StatusUnsupportedMediaType) return } From a5026ea7b727a59b166a06cf7ec4931a50cef427 Mon Sep 17 00:00:00 2001 From: ARPAN MONDAL Date: Sun, 6 Sep 2026 15:36:58 +0530 Subject: [PATCH 404/418] mcp: decode a Base64-wrapped Mcp-Name before comparing it to the body (#1242) The 2026-07-28 transport spec lets a client Base64-wrap a name or resource URI that is not header-safe (`=?base64?...?=`) and requires the server to decode it before comparing. `validateMcpHeaders` decoded `Mcp-Param-*` values that way but compared `Mcp-Name` raw, so a spec-legal encoded name was refused as a header mismatch. This decodes `Mcp-Name` before comparing, mirroring the existing `Mcp-Param-*` path, and rejects an invalid Base64 wrapper with a clear error. Added table cases covering a valid encoded name and an invalid one (red before, green after). Fixes #1234 Co-authored-by: Arpan Mondal Co-authored-by: Guglielmo Colombo --- mcp/streamable_headers.go | 11 ++++++++--- mcp/streamable_headers_test.go | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/mcp/streamable_headers.go b/mcp/streamable_headers.go index 7d06399..77f2415 100644 --- a/mcp/streamable_headers.go +++ b/mcp/streamable_headers.go @@ -378,13 +378,18 @@ func validateMcpHeaders(header http.Header, msg jsonrpc.Message, toolLookup func if nameInHeader == "" { return fmt.Errorf("missing required Mcp-Name header for method %q", msg.Method) } - var ok bool + // A name that is not header-safe may be Base64-wrapped by the client + // (=?base64?...?=), so decode before comparing, as with Mcp-Param-*. + decodedName, ok := decodeHeaderValue(nameInHeader) + if !ok { + return errors.New("header mismatch: Mcp-Name header contains invalid Base64 encoding") + } nameInBody, ok = extractName(msg.Method, msg.Params) if !ok { return fmt.Errorf("failed to extract name from parameters for method %q", msg.Method) } - if nameInHeader != nameInBody { - return fmt.Errorf("header mismatch: Mcp-Name header value '%s' does not match body value '%s'", nameInHeader, nameInBody) + if decodedName != nameInBody { + return fmt.Errorf("header mismatch: Mcp-Name header value '%s' does not match body value '%s'", decodedName, nameInBody) } } diff --git a/mcp/streamable_headers_test.go b/mcp/streamable_headers_test.go index def7ad1..f0862f3 100644 --- a/mcp/streamable_headers_test.go +++ b/mcp/streamable_headers_test.go @@ -6,6 +6,7 @@ package mcp import ( "context" + "encoding/base64" "encoding/json" "fmt" "net/http" @@ -335,6 +336,23 @@ func TestValidateMcpHeaders(t *testing.T) { msg: &jsonrpc.Request{Method: "prompts/get", Params: mustMarshal(&GetPromptParams{Name: "code_review"})}, wantErr: false, }, + { + name: "Base64-encoded Mcp-Name is decoded before comparison", + version: minVersionForStandardHeaders, + methodHeader: "tools/call", + nameHeader: base64Prefix + base64.StdEncoding.EncodeToString([]byte("café-tool")) + base64Suffix, + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "café-tool"})}, + wantErr: false, + }, + { + name: "Mcp-Name with invalid Base64 encoding", + version: minVersionForStandardHeaders, + methodHeader: "tools/call", + nameHeader: base64Prefix + "not valid base64!" + base64Suffix, + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})}, + wantErr: true, + wantErrContain: "invalid Base64 encoding", + }, { name: "valid initialize (no name needed)", version: minVersionForStandardHeaders, From f99cdb649b0c61b07341bc31ecbf94b8f960bbef Mon Sep 17 00:00:00 2001 From: akashjainn <80935005+akashjainn@users.noreply.github.com> Date: Mon, 7 Sep 2026 04:16:16 -0400 Subject: [PATCH 405/418] mcp: base64-encode a non-header-safe Mcp-Name (#1246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: https://github.com/modelcontextprotocol/go-sdk/issues/1243 The 2026-07-28 transport spec only SHOULD-constrains names to header-safe characters, and lets a client Base64-wrap one that is not, using the `=?base64?...?=` sentinel. `setStandardHeaders` set `Mcp-Name` raw, while the sibling `Mcp-Param-*` path already ran its values through `encodeHeaderValue`, so a name outside the safe set went out unencoded. This routes `Mcp-Name` through `encodeHeaderValue` as well. #1242 landed the other direction — the server decoding the sentinel before comparing — so the two together close the round trip. Worth noting they interact: after #1242, a name that is literally shaped like the sentinel (`=?base64?abc?=`) is now rejected as invalid Base64, because the server tries to decode a value the client never encoded. The new test covers that case, and it fails on `main` today. `extractName` also covers `resources/read`, so this applies to resource URIs, which is where non-ASCII is most likely to show up in practice. ## Testing `TestMcpNameHeaderEncodingRoundTrip` drives `setStandardHeaders` and then feeds its output to `validateMcpHeaders`, so it pins the client and server halves against each other rather than asserting the encoding in isolation. Cases: an ASCII-safe name (must stay unencoded, no churn for the common path), non-ASCII, a leading space, a control character, and the sentinel-shaped literal above. Verified failing before the change and passing after. `go test ./...` passes. A note on process: [@remote-controlled-man](https://github.com/remote-controlled-man) asked about this issue earlier today and proposed much the same scope. I had the change written before I saw that comment and thought the #1242 interaction was worth surfacing either way, but I'm happy to close this if they'd rather carry it — please just say so. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_011Wf643Lx36V5wdwuuf2EdT --- mcp/streamable_headers.go | 7 ++++- mcp/streamable_headers_test.go | 54 ++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/mcp/streamable_headers.go b/mcp/streamable_headers.go index 77f2415..c2b8dfd 100644 --- a/mcp/streamable_headers.go +++ b/mcp/streamable_headers.go @@ -207,7 +207,12 @@ func setStandardHeaders(ctx context.Context, header http.Header, msg jsonrpc.Mes case *jsonrpc.Request: header.Set(methodHeader, msg.Method) if name, ok := extractName(msg.Method, msg.Params); ok { - header.Set(nameHeader, name) + // Names are only SHOULD-constrained to header-safe characters, so a + // name outside the safe set is Base64-wrapped with the =?base64?...?= + // sentinel, the same rule applied to Mcp-Param-* values. + if encoded, ok := encodeHeaderValue(name); ok { + header.Set(nameHeader, encoded) + } } if msg.Method == "tools/call" { if tool, ok := ctx.Value(toolContextKey).(*Tool); ok && tool != nil { diff --git a/mcp/streamable_headers_test.go b/mcp/streamable_headers_test.go index f0862f3..97af67c 100644 --- a/mcp/streamable_headers_test.go +++ b/mcp/streamable_headers_test.go @@ -1593,3 +1593,57 @@ func TestValidateParamHeaders_NestedArguments(t *testing.T) { t.Error("validateParamHeaders() = nil, want error for mismatched nested value") } } + +// TestMcpNameHeaderEncodingRoundTrip verifies that a name which is not +// header-safe is Base64-wrapped by the client per the 2026-07-28 transport +// spec, and that the server's validation accepts what the client produced. +func TestMcpNameHeaderEncodingRoundTrip(t *testing.T) { + tests := []struct { + name string + msg *jsonrpc.Request + wantHeader string + }{ + { + name: "ascii-safe tool name is sent as-is", + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "my-tool"})}, + wantHeader: "my-tool", + }, + { + name: "non-ascii tool name is base64 encoded", + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "café-tool"})}, + wantHeader: encodeBase64("café-tool"), + }, + { + name: "prompt name with a leading space is base64 encoded", + msg: &jsonrpc.Request{Method: "prompts/get", Params: mustMarshal(&GetPromptParams{Name: " leading-space"})}, + wantHeader: encodeBase64(" leading-space"), + }, + { + name: "resource uri with a control character is base64 encoded", + msg: &jsonrpc.Request{Method: "resources/read", Params: mustMarshal(&ReadResourceParams{URI: "file:///a\tb.txt"})}, + wantHeader: encodeBase64("file:///a\tb.txt"), + }, + { + name: "name that looks like the sentinel is base64 encoded", + msg: &jsonrpc.Request{Method: "tools/call", Params: mustMarshal(&CallToolParams{Name: "=?base64?abc?="})}, + wantHeader: encodeBase64("=?base64?abc?="), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + header := http.Header{} + header.Set(protocolVersionHeader, minVersionForStandardHeaders) + setStandardHeaders(context.Background(), header, tt.msg) + + if got := header.Get(nameHeader); got != tt.wantHeader { + t.Errorf("Mcp-Name = %q, want %q", got, tt.wantHeader) + } + + // The server must accept the headers the client just generated. + if err := validateMcpHeaders(header, tt.msg, nil); err != nil { + t.Errorf("validateMcpHeaders() on client-generated headers = %v, want nil", err) + } + }) + } +} From 5bc078a7959a745d1311fefa280b36c5cde19929 Mon Sep 17 00:00:00 2001 From: Max Freedom Pollard <272618364+MaxFreedomPollard@users.noreply.github.com> Date: Mon, 7 Sep 2026 05:31:45 -0400 Subject: [PATCH 406/418] auth: strip a terminating slash from the issuer before building metadata URLs (#1245) An issuer identifier written with a terminating slash, like `https://auth.example.com/`, makes authorization server metadata discovery miss every endpoint. `GetAuthServerMetadata` probes three URLs that no conformant server serves, finds nothing, and the caller falls back to the guessed 2025-03-26 endpoints, which are themselves malformed because they are built by appending to the same slash-terminated issuer. `authorizationServerMetadataURLs` in `auth/shared.go` decides whether the issuer has a path component with `baseURL.Path == ""` (line 47), and a trailing-slash issuer has `Path == "/"`, so it takes the path-insertion branch. That branch inserts `strings.TrimLeft(originalPath, "/")` (lines 59 and 62), which strips the leading slash but keeps the trailing one. For `https://auth.example.com/` the probes come out as `/.well-known/oauth-authorization-server/`, `/.well-known/openid-configuration/` and `//.well-known/openid-configuration`. For `https://auth.example.com/tenant1/` the two path-insertion probes come out with a trailing slash. Only the path-appending form was already right, because it uses `strings.Trim`. RFC 8414 section 3.1 settles it: "If the issuer identifier value contains a path component, any terminating "/" MUST be removed before inserting "/.well-known/" and the well-known URI suffix between the host component and the path component." So `https://auth.example.com/` has no path component and gets the two root forms, which is the list the MCP authorization spec gives for issuers without a path component. The change trims slashes off both ends of the issuer path once, uses that to pick between the root and path-insertion branches, and reuses it for all three URLs. An issuer without a terminating slash produces exactly the same list as before. `TestAuthorizationServerMetadataURLs` pins the URL list for all four issuer shapes. `TestGetAuthServerMetadataTrailingSlashIssuer` drives the whole lookup against the existing fake authorization server, serving metadata at the root well-known path. On unmodified main the first fails on both trailing-slash cases and the second fails with `GetAuthServerMetadata() error = Get "/.well-known/openid-configuration": redirect into loopback address "127.0.0.1"`, because `//.well-known/openid-configuration` makes `net/http.ServeMux` issue a canonicalizing redirect that the discovery client's `CheckRedirect` refuses. Both pass with the change. Verification on go1.26.4: `gofmt -l .` clean, `go vet ./...` clean, `staticcheck ./...` clean at v0.6.1 (the version the Test workflow pins), `go test ./auth/...` ok, `go test ./...` ok. I did not file an issue first. The fix is self-contained, confined to one unexported helper, and every issuer shape other than the slash-terminated ones is byte-identical to before. Co-authored-by: Guglielmo Colombo --- auth/shared.go | 14 +++++---- auth/shared_test.go | 70 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/auth/shared.go b/auth/shared.go index 8c5e10c..e58d438 100644 --- a/auth/shared.go +++ b/auth/shared.go @@ -44,7 +44,12 @@ func authorizationServerMetadataURLs(issuerURL string) []string { return nil } - if baseURL.Path == "" { + // RFC 8414, section 3.1 requires any terminating "/" to be removed from the + // issuer identifier before the well-known suffix is inserted, so an issuer + // such as "https://auth.example.com/" has no path component at all. + issuerPath := strings.Trim(baseURL.Path, "/") + + if issuerPath == "" { // "OAuth 2.0 Authorization Server Metadata". baseURL.Path = "/.well-known/oauth-authorization-server" urls = append(urls, baseURL.String()) @@ -54,15 +59,14 @@ func authorizationServerMetadataURLs(issuerURL string) []string { return urls } - originalPath := baseURL.Path // "OAuth 2.0 Authorization Server Metadata with path insertion". - baseURL.Path = "/.well-known/oauth-authorization-server/" + strings.TrimLeft(originalPath, "/") + baseURL.Path = "/.well-known/oauth-authorization-server/" + issuerPath urls = append(urls, baseURL.String()) // "OpenID Connect Discovery 1.0 with path insertion". - baseURL.Path = "/.well-known/openid-configuration/" + strings.TrimLeft(originalPath, "/") + baseURL.Path = "/.well-known/openid-configuration/" + issuerPath urls = append(urls, baseURL.String()) // "OpenID Connect Discovery 1.0 with path appending". - baseURL.Path = "/" + strings.Trim(originalPath, "/") + "/.well-known/openid-configuration" + baseURL.Path = "/" + issuerPath + "/.well-known/openid-configuration" urls = append(urls, baseURL.String()) return urls diff --git a/auth/shared_test.go b/auth/shared_test.go index 16b5054..34e0a35 100644 --- a/auth/shared_test.go +++ b/auth/shared_test.go @@ -6,11 +6,81 @@ package auth import ( "net/http" + "slices" "testing" "github.com/modelcontextprotocol/go-sdk/internal/oauthtest" ) +func TestAuthorizationServerMetadataURLs(t *testing.T) { + tests := []struct { + issuerURL string + want []string + }{ + { + issuerURL: "https://auth.example.com", + want: []string{ + "https://auth.example.com/.well-known/oauth-authorization-server", + "https://auth.example.com/.well-known/openid-configuration", + }, + }, + { + issuerURL: "https://auth.example.com/", + want: []string{ + "https://auth.example.com/.well-known/oauth-authorization-server", + "https://auth.example.com/.well-known/openid-configuration", + }, + }, + { + issuerURL: "https://auth.example.com/tenant1", + want: []string{ + "https://auth.example.com/.well-known/oauth-authorization-server/tenant1", + "https://auth.example.com/.well-known/openid-configuration/tenant1", + "https://auth.example.com/tenant1/.well-known/openid-configuration", + }, + }, + { + issuerURL: "https://auth.example.com/tenant1/", + want: []string{ + "https://auth.example.com/.well-known/oauth-authorization-server/tenant1", + "https://auth.example.com/.well-known/openid-configuration/tenant1", + "https://auth.example.com/tenant1/.well-known/openid-configuration", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.issuerURL, func(t *testing.T) { + if got := authorizationServerMetadataURLs(tt.issuerURL); !slices.Equal(got, tt.want) { + t.Errorf("authorizationServerMetadataURLs(%q) =\n%v\nwant\n%v", tt.issuerURL, got, tt.want) + } + }) + } +} + +// TestGetAuthServerMetadataTrailingSlashIssuer checks that an issuer identifier +// written with a terminating slash still finds metadata served at the root +// well-known path. +func TestGetAuthServerMetadataTrailingSlashIssuer(t *testing.T) { + s := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + MetadataEndpointConfig: &oauthtest.MetadataEndpointConfig{ + ServeOAuthInsertedEndpoint: true, + }, + }) + s.Start(t) + + got, err := GetAuthServerMetadata(t.Context(), s.URL()+"/", http.DefaultClient) + if err != nil { + t.Fatalf("GetAuthServerMetadata() error = %v, want nil", err) + } + if got == nil { + t.Fatal("GetAuthServerMetadata() got nil, want metadata") + } + if got.Issuer != s.URL() { + t.Errorf("GetAuthServerMetadata() issuer = %q, want %q", got.Issuer, s.URL()) + } +} + func TestGetAuthServerMetadata(t *testing.T) { tests := []struct { name string From e40f35d137b72bf0e9fd4a186c3d58958c0e6fd7 Mon Sep 17 00:00:00 2001 From: Ronald Date: Mon, 14 Sep 2026 03:52:10 -0400 Subject: [PATCH 407/418] mcp: label resultType on results that bypass the dispatcher (#1226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1225. See the [issue comment](https://github.com/modelcontextprotocol/go-sdk/issues/1225#issuecomment-5500818546). ## The bug `2026-07-28` requires `resultType` on every result. `CallToolResult`, `GetPromptResult`, and `ReadResourceResult` keep `resultType` in an unexported field. Each of these results can be `complete` or `input_required`, so it cannot embed `completeResultWithType` the way the seven types in #1060 do. Only `handleMultiRoundTripResult` sets that field. It runs inside `Server.callTool`, `getPrompt`, and `readResource`. Some middleware returns a result directly instead of calling `next` — for example, an auth, rate-limit, or entitlement gate. That middleware never reaches `handleMultiRoundTripResult`. The response then ships without the field, and strict clients reject it. ## The fix The choice between `complete` and `input_required` now happens inside `setMultiRoundTripResultType`. Both call sites that need this choice call that function. `setCompleteResultType` now labels results it did not label before, so it is renamed `annotateResultType`. The new function picks a branch with a type switch. Reading matters here. `setResultType` is unexported, so setting `InputRequests` is the only way for middleware to ask for input. If the code always labels results `complete`, the response ships as `{"resultType":"complete","inputRequests":{...}}`. A client that trusts the discriminator then drops the elicitation request. Recomputing the label every time is safe. A result that did reach the dispatcher already carries the same label from that test. This also covers one edge case: a handler that returns `InputRequests` again after the shim's single reinvocation. Before this fix, that case shipped with no `resultType`. Embedding `completeResultWithType` instead is not an option: it labels everything `complete` and overwrites `input_required`, breaking multi-round-trip flows. **Skip nil results.** Middleware can return a typed nil value, which satisfies the `Result` interface. `handle` now checks for that case before it calls the labeling function. Before this change, that same case also panicked, one frame later in `annotateServerInfo` and then `GetMeta`. ## Checks - `TestServerSessionHandle_SetsResultTypeWhenMiddlewareShortCircuits` fails on all three methods before the first change. Its fourth case fails if labeling overwrites a populated `InputRequests`. - `TestServerSessionHandle_ResultTypeGate` records which requests carry `resultType`, including one known gap. See "Separate from this PR" below. - `TestServerSessionHandle_NilResultFromMiddleware` panics without the nil check. - The complete-path cases added to `TestServerSessionHandle_SetsResultTypeOnNewProtocol` also pass before this change. They close a gap in #1060, which added only three cases, and only for `input_required`. - The exported API is unchanged. `go doc -all ./mcp`, with comments stripped, is identical before and after this change. - With the reproduction from the issue, over the streamable transport: the dispatched `tools/call` and the short-circuited `tools/call` both now return `resultType: "complete"`. I could not run `./scripts/conformance.sh`. CONTRIBUTING.md names that script, but the repo only has `client-conformance.sh` and `server-conformance.sh`. Both scripts need GNU `timeout`, and macOS does not include it. ## Also here Ten comments named an exported `ResultType` field and `ResultTypeComplete` / `ResultTypeInputRequired` constants. None of those exist. Two of the comments were `[CallToolResult.ResultType]` doc links, and those links cannot resolve. They now point at `[CallToolResult.NeedsInput]` instead, because an unexported field is also not a valid link target. --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Guglielmo Colombo --- mcp/mrtr.go | 19 +--- mcp/protocol.go | 92 +++++++++------ mcp/server.go | 16 +-- mcp/server_test.go | 271 ++++++++++++++++++++++++++++++++++++++++----- mcp/shared.go | 7 +- 5 files changed, 322 insertions(+), 83 deletions(-) diff --git a/mcp/mrtr.go b/mcp/mrtr.go index 008c0f8..fdf98ec 100644 --- a/mcp/mrtr.go +++ b/mcp/mrtr.go @@ -36,29 +36,20 @@ type multiRoundTripResponse interface { hasContent() bool } -func handleMultiRoundTripResult(ss *ServerSession, logger *slog.Logger, res multiRoundTripResponse) error { +// validateMultiRoundTripResult rejects a result that carries both content and +// input requests. [annotateResultType] labels the result later, in +// [ServerSession.handle], so this function does not set resultType. +func validateMultiRoundTripResult(logger *slog.Logger, res multiRoundTripResponse) error { if res == nil { return nil } - hasInputRequests := res.inputRequests() != nil - - if hasInputRequests && res.hasContent() { + if res.inputRequests() != nil && res.hasContent() { logger.Warn("handler returned both content and inputRequests") return &jsonrpc.Error{ Code: jsonrpc.CodeInternalError, Message: "server bug: result has both content and inputRequests", } } - - if clientSupportsMultiRoundTrip(ss) { - // For older clients the resultType is left unset. Input requests will be handled - // by serverMultiRoundTripMiddleware client calls and handler reinvocation. - if hasInputRequests { - res.setResultType(resultTypeInputRequired) - } else { - res.setResultType(resultTypeComplete) - } - } return nil } diff --git a/mcp/protocol.go b/mcp/protocol.go index 63d7015..be37a18 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -19,7 +19,7 @@ type resultType string const ( // resultTypeComplete indicates the result is final. - // This is the default when ResultType is empty. + // This is the default when resultType is empty. resultTypeComplete resultType = "complete" // resultTypeInputRequired indicates the server needs additional client @@ -40,9 +40,20 @@ type completeResultResponse interface { isCompleteResult() } -func setCompleteResultType(res Result) { - if r, ok := res.(completeResultResponse); ok { +// annotateResultType sets the resultType that protocol 2026-07-28 requires on +// every result. +func annotateResultType(res Result) { + switch r := res.(type) { + case completeResultResponse: r.setResultType(resultTypeComplete) + case multiRoundTripResponse: + // These results are complete or input_required, so label them by + // whether the handler asked for more client input. + if r.inputRequests() != nil { + r.setResultType(resultTypeInputRequired) + } else { + r.setResultType(resultTypeComplete) + } } } @@ -226,9 +237,8 @@ type CallToolParams struct { // marshaled to JSON. Arguments any `json:"arguments,omitempty"` - // InputResponses maps input request IDs to responses, provided when - // retrying a call after receiving a result with ResultType - // ResultTypeInputRequired. + // InputResponses maps input request IDs to responses. Set it when the + // client retries a call that asked for input. See [CallToolResult.NeedsInput]. InputResponses InputResponseMap `json:"inputResponses,omitempty"` // RequestState is the opaque state from the previous input-required result. // The client must echo this back when retrying. @@ -249,9 +259,8 @@ type CallToolParamsRaw struct { // Arguments (see [AddTool]). Arguments json.RawMessage `json:"arguments,omitempty"` - // InputResponses maps input request IDs to responses, provided when - // retrying a call after receiving a result with ResultType - // ResultTypeInputRequired. + // InputResponses maps input request IDs to responses. Set it when the + // client retries a call that asked for input. See [CallToolResult.NeedsInput]. InputResponses InputResponseMap `json:"inputResponses,omitempty"` // RequestState is the opaque state from the previous input-required result. // The client must echo this back when retrying. @@ -305,9 +314,9 @@ type CallToolResult struct { IsError bool `json:"isError,omitempty"` // InputRequests is a map of server-assigned IDs to input requests. - // Populated only when ResultType is ResultTypeInputRequired. + // It is set only when the result needs more client input. // The client must fulfill these and echo the IDs back in InputResponses - // when retrying the call. + // when it retries the call. InputRequests InputRequestMap `json:"inputRequests,omitempty"` // RequestState is an opaque string the client must echo back when @@ -317,10 +326,10 @@ type CallToolResult struct { // Unauthenticated servers must encrypt, sign and verify this value. RequestState string `json:"requestState,omitempty"` - // ResultType indicates whether this result is complete or requires further - // client input. Empty or ResultTypeComplete means the call succeeded - // normally. ResultTypeInputRequired means the client should fulfill the - // InputRequests and retry the call. + // resultType records whether the call finished or needs more client input. + // Empty or resultTypeComplete means it finished. resultTypeInputRequired + // means the client must fulfill InputRequests and retry. + // See [CallToolResult.NeedsInput]. resultType resultType // The error passed to setError, if any. // It is not marshaled, and therefore it is only visible on the server. @@ -348,7 +357,8 @@ func (r *CallToolResult) GetError() error { return r.err } -func (*CallToolResult) isResult() {} +func (*CallToolResult) isResult() {} +func (x *CallToolResult) isNil() bool { return x == nil } func (r *CallToolResult) setResultType(rt resultType) { r.resultType = rt } func (r *CallToolResult) requestState() string { return r.RequestState } @@ -363,7 +373,7 @@ func (r *CallToolResult) hasContent() bool { } // NeedsInput reports whether this result requires further client input. -// This is true when the server returned ResultType "input_required". +// This is true when the server returned a resultType of "input_required". // When NeedsInput returns true, check InputRequests for the set of // requests the server needs fulfilled before retrying the call. // An empty InputRequests with NeedsInput true indicates load-shedding. @@ -655,7 +665,8 @@ type CompleteResult struct { Completion CompletionResultDetails `json:"completion"` } -func (*CompleteResult) isResult() {} +func (*CompleteResult) isResult() {} +func (x *CompleteResult) isNil() bool { return x == nil } // CreateMessageParams holds parameters for a sampling/createMessage request. // @@ -839,6 +850,7 @@ type CreateMessageResult struct { } func (*CreateMessageResult) isResult() {} +func (x *CreateMessageResult) isNil() bool { return x == nil } func (*CreateMessageResult) isInputResponse() {} func (r *CreateMessageResult) UnmarshalJSON(data []byte) error { type result CreateMessageResult // avoid recursion @@ -890,6 +902,7 @@ var createMessageWithToolsResultAllow = map[string]bool{ } func (*CreateMessageWithToolsResult) isResult() {} +func (x *CreateMessageWithToolsResult) isNil() bool { return x == nil } func (*CreateMessageWithToolsResult) isInputResponse() {} // MarshalJSON marshals the result. When Content has a single element, it is @@ -950,9 +963,8 @@ type GetPromptParams struct { // The name of the prompt or prompt template. Name string `json:"name"` - // InputResponses maps input request IDs to responses, provided when - // retrying a call after receiving a result with ResultType - // ResultTypeInputRequired. + // InputResponses maps input request IDs to responses. Set it when the + // client retries a call that asked for input. See [CallToolResult.NeedsInput]. InputResponses InputResponseMap `json:"inputResponses,omitempty"` // RequestState is the opaque state from the previous input-required result. RequestState string `json:"requestState,omitempty"` @@ -972,19 +984,20 @@ type GetPromptResult struct { Description string `json:"description,omitempty"` Messages []*PromptMessage `json:"messages"` - // InputRequests is populated when ResultType is ResultTypeInputRequired. + // InputRequests is set when the result needs more client input. // See [CallToolResult.InputRequests]. InputRequests InputRequestMap `json:"inputRequests,omitempty"` // RequestState is the opaque state for multi-round-trip retries. // See [CallToolResult.RequestState]. RequestState string `json:"requestState,omitempty"` - // ResultType indicates whether this result is complete or requires further - // client input. See [CallToolResult.ResultType] for details. + // resultType records whether the call finished or needs more client input. + // See [CallToolResult.NeedsInput]. resultType resultType } -func (*GetPromptResult) isResult() {} +func (*GetPromptResult) isResult() {} +func (x *GetPromptResult) isNil() bool { return x == nil } func (r *GetPromptResult) setResultType(rt resultType) { r.resultType = rt } func (r *GetPromptResult) requestState() string { return r.RequestState } @@ -1094,7 +1107,8 @@ type InitializeResult struct { ServerInfo *Implementation `json:"serverInfo"` } -func (*InitializeResult) isResult() {} +func (*InitializeResult) isResult() {} +func (x *InitializeResult) isNil() bool { return x == nil } type InitializedParams struct { // Meta is reserved by the protocol to allow clients and servers to attach @@ -1137,7 +1151,8 @@ type DiscoverResult struct { Instructions string `json:"instructions,omitempty"` } -func (*DiscoverResult) isResult() {} +func (*DiscoverResult) isResult() {} +func (x *DiscoverResult) isNil() bool { return x == nil } func (x *ListPromptsParams) isParams() {} func (x *ListPromptsParams) isNil() bool { return x == nil } @@ -1207,6 +1222,7 @@ type ListPromptsResult struct { } func (x *ListPromptsResult) isResult() {} +func (x *ListPromptsResult) isNil() bool { return x == nil } func (x *ListPromptsResult) nextCursorPtr() *string { return &x.NextCursor } type ListResourceTemplatesParams struct { @@ -1238,6 +1254,7 @@ type ListResourceTemplatesResult struct { } func (x *ListResourceTemplatesResult) isResult() {} +func (x *ListResourceTemplatesResult) isNil() bool { return x == nil } func (x *ListResourceTemplatesResult) nextCursorPtr() *string { return &x.NextCursor } type ListResourcesParams struct { @@ -1269,6 +1286,7 @@ type ListResourcesResult struct { } func (x *ListResourcesResult) isResult() {} +func (x *ListResourcesResult) isNil() bool { return x == nil } func (x *ListResourcesResult) nextCursorPtr() *string { return &x.NextCursor } // ListRootsParams holds parameters for a roots/list request. @@ -1305,6 +1323,7 @@ type ListRootsResult struct { } func (*ListRootsResult) isResult() {} +func (x *ListRootsResult) isNil() bool { return x == nil } func (*ListRootsResult) isInputResponse() {} type ListToolsParams struct { @@ -1336,6 +1355,7 @@ type ListToolsResult struct { } func (x *ListToolsResult) isResult() {} +func (x *ListToolsResult) isNil() bool { return x == nil } func (x *ListToolsResult) nextCursorPtr() *string { return &x.NextCursor } // The severity of a log message. @@ -1575,9 +1595,8 @@ type ReadResourceParams struct { // the server how to interpret it. URI string `json:"uri"` - // InputResponses maps input request IDs to responses, provided when - // retrying a call after receiving a result with ResultType - // ResultTypeInputRequired. + // InputResponses maps input request IDs to responses. Set it when the + // client retries a call that asked for input. See [CallToolResult.NeedsInput]. InputResponses InputResponseMap `json:"inputResponses,omitempty"` // RequestState is the opaque state from the previous input-required result. RequestState string `json:"requestState,omitempty"` @@ -1596,19 +1615,20 @@ type ReadResourceResult struct { Cacheable Contents []*ResourceContents `json:"contents"` - // InputRequests is populated when ResultType is ResultTypeInputRequired. + // InputRequests is set when the result needs more client input. // See [CallToolResult.InputRequests]. InputRequests InputRequestMap `json:"inputRequests,omitempty"` // RequestState is the opaque state for multi-round-trip retries. // See [CallToolResult.RequestState]. RequestState string `json:"requestState,omitempty"` - // ResultType indicates whether this result is complete or requires further - // client input. See [CallToolResult.ResultType] for details. + // resultType records whether the call finished or needs more client input. + // See [CallToolResult.NeedsInput]. resultType resultType } -func (*ReadResourceResult) isResult() {} +func (*ReadResourceResult) isResult() {} +func (x *ReadResourceResult) isNil() bool { return x == nil } func (r *ReadResourceResult) setResultType(rt resultType) { r.resultType = rt } func (r *ReadResourceResult) requestState() string { return r.RequestState } @@ -2114,7 +2134,8 @@ type SubscriptionsListenResult struct { Meta `json:"_meta"` } -func (*SubscriptionsListenResult) isResult() {} +func (*SubscriptionsListenResult) isResult() {} +func (x *SubscriptionsListenResult) isNil() bool { return x == nil } // TODO(jba): add CompleteRequest and related types. @@ -2191,6 +2212,7 @@ type ElicitResult struct { } func (*ElicitResult) isResult() {} +func (x *ElicitResult) isNil() bool { return x == nil } func (*ElicitResult) isInputResponse() {} // ElicitationCompleteParams is sent from the server to the client, informing it that an out-of-band elicitation interaction has completed. diff --git a/mcp/server.go b/mcp/server.go index 5014a4f..183a519 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -910,7 +910,7 @@ func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetProm } res, err := prompt.handler(ctx, req) if err == nil && res != nil { - if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil { + if err := validateMultiRoundTripResult(s.opts.Logger, res); err != nil { return nil, err } } @@ -1012,10 +1012,10 @@ func (s *Server) callTool(ctx context.Context, req *CallToolRequest) (*CallToolR } res, err := st.handler(ctx, req) if err == nil && res != nil { - if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil { + if err := validateMultiRoundTripResult(s.opts.Logger, res); err != nil { return nil, err } - if res.Content == nil && res.resultType != resultTypeInputRequired { + if res.Content == nil && res.InputRequests == nil { res2 := *res res2.Content = []Content{} // avoid "null" res = &res2 @@ -1080,11 +1080,11 @@ func (s *Server) readResource(ctx context.Context, req *ReadResourceRequest) (*R if res == nil { return nil, fmt.Errorf("reading resource %s: read handler returned nil information", uri) } - if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil { + if err := validateMultiRoundTripResult(s.opts.Logger, res); err != nil { return nil, err } s.resolveCacheable(ctx, req, &res.Cacheable) - if res.resultType == resultTypeInputRequired { + if res.InputRequests != nil { return res, nil } if res.Contents == nil { @@ -2039,8 +2039,10 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, if err != nil { return nil, err } - if validatedMeta.usesNewProtocol { - setCompleteResultType(res) + // A middleware can return a typed nil value that satisfies the Result + // interface. Annotating that value panics. + if validatedMeta.usesNewProtocol && res != nil && !res.isNil() { + annotateResultType(res) annotateServerInfo(res, ss.server.impl) } return res, nil diff --git a/mcp/server_test.go b/mcp/server_test.go index 11e75e2..77dcb10 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -1399,6 +1399,22 @@ func TestServerSessionHandle_RejectsInitializeOnNewProtocol(t *testing.T) { }) } +// newProtocolParams merges fields into the _meta that opts a request into the +// sessionless protocol (SEP-2575). +func newProtocolParams(fields map[string]any) map[string]any { + params := map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20260728, + MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, + MetaKeyClientCapabilities: map[string]any{}, + }, + } + for k, v := range fields { + params[k] = v + } + return params +} + func TestServerSessionHandle_SetsResultTypeOnNewProtocol(t *testing.T) { server := NewServer(testImpl, &ServerOptions{ CompletionHandler: func(context.Context, *CompleteRequest) (*CompleteResult, error) { @@ -1424,19 +1440,19 @@ func TestServerSessionHandle_SetsResultTypeOnNewProtocol(t *testing.T) { InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Continue?"}}, }, nil }) - newProtocolParams := func(fields map[string]any) map[string]any { - params := map[string]any{ - "_meta": map[string]any{ - MetaKeyProtocolVersion: protocolVersion20260728, - MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, - MetaKeyClientCapabilities: map[string]any{}, - }, - } - for k, v := range fields { - params[k] = v - } - return params - } + AddTool(server, &Tool{Name: "toolComplete"}, func(context.Context, *CallToolRequest, struct{}) (*CallToolResult, any, error) { + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil + }) + server.AddPrompt(&Prompt{Name: "promptComplete"}, func(context.Context, *GetPromptRequest) (*GetPromptResult, error) { + return &GetPromptResult{ + Messages: []*PromptMessage{{Role: "assistant", Content: &TextContent{Text: "ok"}}}, + }, nil + }) + server.AddResource(&Resource{URI: "test://resource-complete", Name: "resourceComplete"}, func(context.Context, *ReadResourceRequest) (*ReadResourceResult, error) { + return &ReadResourceResult{ + Contents: []*ResourceContents{{URI: "test://resource-complete", Text: "ok"}}, + }, nil + }) tests := []struct { name string @@ -1507,40 +1523,243 @@ func TestServerSessionHandle_SetsResultTypeOnNewProtocol(t *testing.T) { params: newProtocolParams(map[string]any{"uri": "test://resource"}), want: resultTypeInputRequired, }, + { + name: "tool complete", + method: methodCallTool, + params: newProtocolParams(map[string]any{"name": "toolComplete", "arguments": map[string]any{}}), + want: resultTypeComplete, + }, + { + name: "prompt complete", + method: methodGetPrompt, + params: newProtocolParams(map[string]any{"name": "promptComplete"}), + want: resultTypeComplete, + }, + { + name: "resource complete", + method: methodReadResource, + params: newProtocolParams(map[string]any{"uri": "test://resource-complete"}), + want: resultTypeComplete, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { + if got := resultTypeOf(t, server, nil, tc.method, tc.params); got != string(tc.want) { + t.Errorf("resultType = %q, want %q", got, tc.want) + } + }) + } +} + +// TestServerSessionHandle_SetsResultTypeWhenMiddlewareShortCircuits is a +// regression test for #1225. User middleware wraps outside +// serverMultiRoundTripMiddleware, so a middleware that returns a result +// without calling next bypasses both that shim and the dispatcher. +func TestServerSessionHandle_SetsResultTypeWhenMiddlewareShortCircuits(t *testing.T) { + tests := []struct { + name string + method string + params map[string]any + result Result + want resultType + }{ + { + name: "tools/call", + method: methodCallTool, + params: newProtocolParams(map[string]any{"name": "tool", "arguments": map[string]any{}}), + result: &CallToolResult{Content: []Content{&TextContent{Text: "denied"}}}, + want: resultTypeComplete, + }, + { + name: "prompts/get", + method: methodGetPrompt, + params: newProtocolParams(map[string]any{"name": "prompt"}), + result: &GetPromptResult{ + Messages: []*PromptMessage{{Role: "assistant", Content: &TextContent{Text: "denied"}}}, + }, + want: resultTypeComplete, + }, + { + name: "resources/read", + method: methodReadResource, + params: newProtocolParams(map[string]any{"uri": "test://resource"}), + result: &ReadResourceResult{ + Contents: []*ResourceContents{{URI: "test://resource", Text: "denied"}}, + }, + want: resultTypeComplete, + }, + { + // setResultType is unexported, so populating InputRequests is + // the only way a middleware can ask for input. + name: "tools/call input required", + method: methodCallTool, + params: newProtocolParams(map[string]any{"name": "tool", "arguments": map[string]any{}}), + result: &CallToolResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Continue?"}}, + }, + want: resultTypeInputRequired, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Nothing is registered: if the middleware fell through to next, + // dispatch would fail rather than return a result. + server := NewServer(testImpl, nil) + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == tc.method { + return tc.result, nil + } + return next(ctx, method, req) + } + }) + if got := resultTypeOf(t, server, nil, tc.method, tc.params); got != string(tc.want) { + t.Errorf("resultType = %q, want %q", got, tc.want) + } + }) + } +} + +// TestServerSessionHandle_ResultTypeGate pins when a result carries resultType. +// annotateResultType is the only setter, and handle gates it on the request's +// _meta rather than on the version the session negotiated. So the last case +// below gets no resultType even though the session speaks 2026-07-28. Only a +// client that mixes protocol versions reaches that, so it is recorded rather +// than fixed. +func TestServerSessionHandle_ResultTypeGate(t *testing.T) { + version := func(v string) func(*ServerSessionState) { + return func(s *ServerSessionState) { + s.InitializeParams = &InitializeParams{ProtocolVersion: v} + } + } + callParams := map[string]any{"name": "tool", "arguments": map[string]any{}} + + tests := []struct { + name string + state func(*ServerSessionState) + params map[string]any + want resultType // "" means the field must be absent + }{ + { + name: "legacy session, no _meta", + state: version(protocolVersion20251125), + params: callParams, + }, + { + name: "legacy session, new-protocol _meta", + state: version(protocolVersion20251125), + params: newProtocolParams(callParams), + want: resultTypeComplete, + }, + { + name: "new-protocol session, no _meta", + state: version(protocolVersion20260728), + params: callParams, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + server := NewServer(testImpl, nil) + // Short-circuit so this measures handle's gate, not the + // dispatcher's. + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(context.Context, string, Request) (Result, error) { + return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil + } + }) + if got := resultTypeOf(t, server, tc.state, methodCallTool, tc.params); got != string(tc.want) { + t.Errorf("resultType = %q, want %q", got, tc.want) + } + }) + } +} + +// TestServerSessionHandle_NilResultFromMiddleware checks that a middleware +// returning no result does not panic. A typed nil satisfies Result, so the +// annotation helpers would otherwise dereference it. +func TestServerSessionHandle_NilResultFromMiddleware(t *testing.T) { + tests := []struct { + name string + result Result + }{ + {"nil interface", nil}, + {"typed nil", (*CallToolResult)(nil)}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + server := NewServer(testImpl, nil) + server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(context.Context, string, Request) (Result, error) { + return tc.result, nil + } + }) ss := &ServerSession{server: server} id, err := jsonrpc.MakeID("test") if err != nil { t.Fatal(err) } - result, err := ss.handle(context.Background(), &jsonrpc.Request{ + res, err := ss.handle(context.Background(), &jsonrpc.Request{ ID: id, - Method: tc.method, - Params: mustMarshal(tc.params), + Method: methodCallTool, + Params: mustMarshal(newProtocolParams(map[string]any{"name": "tool", "arguments": map[string]any{}})), }) if err != nil { - t.Fatal(err) + t.Fatalf("handle: %v", err) } - data, err := json.Marshal(result) + data, err := json.Marshal(res) if err != nil { t.Fatal(err) } - var got struct { - ResultType string `json:"resultType"` - } - if err := json.Unmarshal(data, &got); err != nil { - t.Fatal(err) - } - if got.ResultType != string(tc.want) { - t.Fatalf("resultType = %q, want %q; response = %s", got.ResultType, tc.want, data) + if got := string(data); got != "null" { + t.Errorf("result = %s, want null", got) } }) } } +// resultTypeOf handles one request on a transportless session and reports the +// resultType of the marshaled result, or "" if the field is absent. +func resultTypeOf(t *testing.T, server *Server, state func(*ServerSessionState), method string, params map[string]any) string { + t.Helper() + ss := &ServerSession{server: server} + if state != nil { + ss.updateState(state) + } + id, err := jsonrpc.MakeID("test") + if err != nil { + t.Fatal(err) + } + result, err := ss.handle(context.Background(), &jsonrpc.Request{ + ID: id, + Method: method, + Params: mustMarshal(params), + }) + if err != nil { + t.Fatal(err) + } + data, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + t.Fatal(err) + } + raw, ok := fields["resultType"] + if !ok { + return "" + } + var got string + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshaling resultType from %s: %v", data, err) + } + return got +} + // 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 diff --git a/mcp/shared.go b/mcp/shared.go index 2258752..6637a25 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -809,6 +809,9 @@ type Result interface { // isResult discourages implementation of Result outside of this package. isResult() + // isNil returns true if the underlying value is nil. + isNil() bool + // GetMeta returns metadata from a value. GetMeta() map[string]any // SetMeta sets the metadata on a value. @@ -827,13 +830,15 @@ type ResultBase struct { Meta `json:"_meta,omitempty"` } -func (*ResultBase) isResult() {} +func (*ResultBase) isResult() {} +func (x *ResultBase) isNil() bool { return x == nil } // emptyResult is returned by methods that have no result, like ping. // Those methods cannot return nil, because jsonrpc2 cannot handle nils. type emptyResult struct{} func (*emptyResult) isResult() {} +func (x *emptyResult) isNil() bool { return x == nil } func (*emptyResult) GetMeta() map[string]any { panic("should never be called") } func (*emptyResult) SetMeta(map[string]any) { panic("should never be called") } From 3210c2b34cacd6e80305b8216779bfe6c964d720 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Mon, 14 Sep 2026 13:33:30 +0200 Subject: [PATCH 408/418] mcp: carry a cancelled notification's reason as the request's cancel cause (#1255) The canceller read `CancelledParams.Reason` off the wire and dropped it: `Connection.Cancel` cancelled the request's context with a nil cause, so a handler that looked at `context.Cause` saw a bare `context.Canceled` and nothing of what the peer said. The specification asks implementations to log cancellation reasons, and the handler is the one place that can act on one; #1100 had already given the request context a cause, so the reason has a natural place to travel. `Connection.CancelCause` is `Cancel` with a cause. The canceller now cancels with an error that carries the peer's reason and unwraps to `context.Canceled`, so `errors.Is` keeps classifying the cancellation as one while `context.Cause` reads `request cancelled by the peer: `. It also logs the id and the reason at debug level through the logger the connection already carries, which is the SDK's own answer to the SHOULD. No exported API changes: the cause type stays unexported and is read through `context.Cause` and `Error()`. `TestCancellation` now asserts the cause the SDK's own client produces (its context's error, which the client sends as the reason), and `TestCancellationReason` writes the call and the cancelled notification to the connection directly with a reason the client would never send, on a session pinned to 2025-11-25 so the hand-written request needs no `_meta`. Both fail without the change. Fixes #1254 Co-authored-by: Guglielmo Colombo --- internal/jsonrpc2/conn.go | 9 ++++- mcp/mcp_test.go | 71 +++++++++++++++++++++++++++++++++++++-- mcp/transport.go | 29 ++++++++++++++-- 3 files changed, 102 insertions(+), 7 deletions(-) diff --git a/internal/jsonrpc2/conn.go b/internal/jsonrpc2/conn.go index 5849a87..94da6ae 100644 --- a/internal/jsonrpc2/conn.go +++ b/internal/jsonrpc2/conn.go @@ -453,12 +453,19 @@ func (ac *AsyncCall) Await(ctx context.Context, result any) error { // will not cause any messages that have not arrived yet with that ID to be // cancelled. func (c *Connection) Cancel(id ID) { + c.CancelCause(id, nil) +} + +// CancelCause is like [Connection.Cancel], but records cause as the reason +// the Context was cancelled, so that the Handle call can read it back through +// [context.Cause]. A nil cause reads as [context.Canceled]. +func (c *Connection) CancelCause(id ID, cause error) { var req *incomingRequest c.updateInFlight(func(s *inFlightState) { req = s.incomingByID[id] }) if req != nil { - req.cancel(nil) + req.cancel(cause) } } diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index e1f8c0f..bb76c1c 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -710,13 +710,13 @@ func TestCancellation(t *testing.T) { synctest.Test(t, func(t *testing.T) { var ( start = make(chan struct{}) - cancelled = make(chan struct{}, 1) // don't block the request + cancelled = make(chan error, 1) // don't block the request ) slowTool := func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { start <- struct{}{} select { case <-ctx.Done(): - cancelled <- struct{}{} + cancelled <- context.Cause(ctx) case <-time.After(5 * time.Second): return nil, nil, nil } @@ -732,7 +732,72 @@ func TestCancellation(t *testing.T) { <-start cancel() - <-cancelled + // The client sends its context's error as the reason of its cancelled + // notification, and the handler reads it back as the cause. + cause := <-cancelled + if !errors.Is(cause, context.Canceled) { + t.Errorf("context.Cause = %v, want it to wrap context.Canceled", cause) + } + if want := "request cancelled by the peer: " + context.Canceled.Error(); cause == nil || cause.Error() != want { + t.Errorf("context.Cause = %v, want %q", cause, want) + } + }) +} + +// TestCancellationReason verifies that the reason a peer gives in its +// cancelled notification reaches the handler as the cause of its context, +// which is what lets a server log it as the specification asks. +// +// The call and the notification are written to the connection directly, so +// the reason is one the SDK's own client would never send; the session is +// pinned to 2025-11-25 so that a request written that way needs no _meta. +func TestCancellationReason(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + var ( + start = make(chan struct{}) + cancelled = make(chan error, 1) // don't block the request + ) + slowTool := func(ctx context.Context, req *CallToolRequest, args any) (*CallToolResult, any, error) { + start <- struct{}{} + select { + case <-ctx.Done(): + cancelled <- context.Cause(ctx) + case <-time.After(5 * time.Second): + cancelled <- nil + } + return nil, nil, nil + } + ctx := context.Background() + ct, st := NewInMemoryTransports() + s := NewServer(testImpl, nil) + AddTool(s, &Tool{Name: "slow", InputSchema: &jsonschema.Schema{Type: "object"}}, slowTool) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ss.Close() }) + cs, err := NewClient(testImpl, nil).Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cs.Close() }) + + call := cs.conn.Call(ctx, methodCallTool, &CallToolParams{Name: "slow"}) + <-start + if err := cs.conn.Notify(ctx, notificationCancelled, &CancelledParams{RequestID: call.ID().Raw(), Reason: "user asked"}); err != nil { + t.Fatal(err) + } + + cause := <-cancelled + if cause == nil { + t.Fatal("the tool ran to completion, want it cancelled") + } + if !errors.Is(cause, context.Canceled) { + t.Errorf("context.Cause = %v, want it to wrap context.Canceled", cause) + } + if got, want := cause.Error(), "request cancelled by the peer: user asked"; got != want { + t.Errorf("context.Cause = %q, want %q", got, want) + } }) } diff --git a/mcp/transport.go b/mcp/transport.go index 8b6cd14..ce841f2 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -207,7 +207,7 @@ func connect[H handler, State any](ctx context.Context, t Transport, b binder[H, reader, writer := jsonrpc2.Reader(mcpConn), jsonrpc2.Writer(mcpConn) var ( h H - preempter canceller + preempter = canceller{logger: logger} ) bind := func(conn *jsonrpc2.Connection) jsonrpc2.Handler { h = b.bind(mcpConn, conn, s, onClose) @@ -252,7 +252,8 @@ type cancellationPropagator interface { // A canceller is a jsonrpc2.Preempter that cancels in-flight requests on MCP // cancelled notifications. type canceller struct { - conn *jsonrpc2.Connection + conn *jsonrpc2.Connection + logger *slog.Logger } // Preempt implements [jsonrpc2.Preempter]. @@ -266,11 +267,33 @@ func (c *canceller) Preempt(ctx context.Context, req *jsonrpc.Request) (result a if err != nil { return nil, err } - go c.conn.Cancel(id) + // The spec says implementations should log cancellation reasons, and + // the handler is the one place that can act on one, so the reason + // travels as the cause of the request's context rather than being + // dropped here. + c.logger.Debug("request cancelled by the peer", "id", id.Raw(), "reason", params.Reason) + go c.conn.CancelCause(id, &peerCancelledError{reason: params.Reason}) } return nil, jsonrpc2.ErrNotHandled } +// A peerCancelledError is the cause a request's context carries once the peer +// has sent a cancelled notification for it: [context.Cause] returns it to the +// handler with the reason the peer gave, and it unwraps to [context.Canceled] +// so that [errors.Is] keeps classifying the cancellation as one. +type peerCancelledError struct { + reason string +} + +func (e *peerCancelledError) Error() string { + if e.reason == "" { + return "request cancelled by the peer" + } + return "request cancelled by the peer: " + e.reason +} + +func (e *peerCancelledError) Unwrap() error { return context.Canceled } + // callSubscriptionsListen issues a "subscriptions/listen" call (SEP-2575) // without awaiting its JSON-RPC response. The call's logical lifetime is the // stream of notifications that follow on the same channel — the empty From a82c86a6cd9d357e56e57cd93abf90f950f59abd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Mon, 14 Sep 2026 17:49:53 +0200 Subject: [PATCH 409/418] mcp: classify the per-request protocol version by membership (#1268) `validateRequestMeta` decided whether a request follows the 2026-07-28 protocol by comparing `_meta.protocolVersion` against `2026-07-28` with `<`. This replaces that comparison with a membership test against `supportedProtocolVersions`, which is the rule `mcp/streamable.go:354` already applies to the `Mcp-Protocol-Version` header. Nothing changes for a version the SDK supports: one predating 2026-07-28 still selects the legacy handshake, and one at 2026-07-28 or later is still validated as the full `_meta` triple. What changes is a version the SDK does not support at all. It now reports `usesNewProtocol`, so it reaches the `CodeUnsupportedProtocolVersion` check in `(*ServerSession).handle` and is answered with the error the versioning page requires. Driving a stdio server with `_meta.protocolVersion` set to `1900-01-01`, the worked example on that page, before: ```json {"jsonrpc":"2.0","id":1,"error":{"code":0,"message":"method \"tools/list\" is invalid during session initialization"}} ``` and after: ```json {"jsonrpc":"2.0","id":1,"error":{"code":-32022,"message":"unsupported protocol version","data":{"supported":["2026-07-28","2025-11-25","2025-06-18","2025-03-26","2024-11-05"],"requested":"1900-01-01"}}} ``` There are two judgement calls in the diff, and I would rather state them than leave them to be found. The first is that an unsupported version returns early with `initializeParams` carrying only that version, and the rest of the `_meta` triple is left unvalidated. The shape of `_meta` is defined by the revision the request names, and that is a revision this SDK does not know, so there is nothing to validate it against; answering "missing `io.modelcontextprotocol/clientCapabilities`" would send the client off to fix the wrong thing while its real problem is the version. The partly populated value cannot escape: `handle` runs the unsupported check before anything reads the capabilities, and `Server.protocolVersions` is always a subset of `supportedProtocolVersions`, so a version that fails the membership test in `validateRequestMeta` always fails it in `handle` too. The second is that an empty `protocolVersion` still means "no version declared" rather than "unsupported version". `mcp/streamable.go` already reads the field that way when it decides whether a request carries a version at all (`metaVersion != ""`), and rejecting the empty string here would put the two layers back into disagreement, in the other direction. The `!ok` and `== ""` cases are therefore handled together. **Behaviour an existing user would see change.** A request whose `_meta.protocolVersion` is a string not in `SupportedProtocolVersions()` is now refused with `-32022`, where before a value sorting below `2026-07-28` was treated as a legacy request: served if the session was already initialized, refused with the session-initialization error if it was not. No published protocol revision is in that set, so a client speaking any released version of MCP sees no difference. A deployment that put a sentinel or placeholder version in `_meta` and relied on it being ignored would see those requests refused, which I believe is the right answer but is a visible change. This is not limited to stdio, and I want to be precise about where the header gate does and does not stand in front of it. The gate at `mcp/streamable.go:354` refuses an unsupported `Mcp-Protocol-Version` only when that version **also** sorts below `2026-07-28`, so an unknown version sorting above it (`2027-01-01`, say) passes the gate and reaches this classification; the deprecated HTTP+SSE transport in `mcp/sse.go` has no header check at all; and stdio, the in-memory transport and any custom transport have none either. The window the old comparison left open is therefore reachable on every transport, and a value sorting above `2026-07-28` with an incomplete `_meta` triple also changes answer, from `-32602` to `-32022`. **The test that pins it** is `TestServerUnknownProtocolVersion_NewProtocol` in `mcp/server_test.go`. It drives `(*ServerSession).handle` with a `tools/list` request whose `_meta.protocolVersion` names a version the SDK does not know, and asserts that the returned error is a `*jsonrpc.Error` with code `CodeUnsupportedProtocolVersion`, `Data.Requested` equal to the version asked for, and `Data.Supported` equal to `SupportedProtocolVersions()`. Four cases: `1900-01-01`, `2025-11-24`, `1.0`, and `1900-01-01` again with the rest of the triple omitted, which is the case that pins the first judgement call above. Before the change all four fail with `handle returned method "tools/list" is invalid during session initialization, want a *jsonrpc.Error`. `TestValidateRequestMeta` in `mcp/shared_test.go` gains four cases at the unit level: two that fail before the change, and two that guard the classifications which must not move, a supported pre-2026-07-28 version and an empty one. `go test ./...` and `gofmt -l .` are both clean. Fixes #1260. --- mcp/server_test.go | 58 ++++++++++++++++++++++++++++++++++++++++++++++ mcp/shared.go | 39 ++++++++++++++++++++++++------- mcp/shared_test.go | 51 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 8 deletions(-) diff --git a/mcp/server_test.go b/mcp/server_test.go index 77dcb10..1544e29 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -2151,3 +2151,61 @@ func TestServerSupportedProtocolVersions_NewProtocol(t *testing.T) { t.Errorf("UnsupportedProtocolVersionData.Supported mismatch (-want +got):\n%s", diff) } } + +// TestServerUnknownProtocolVersion_NewProtocol verifies that a request whose +// `_meta.protocolVersion` names a version the SDK does not know is rejected +// with [CodeUnsupportedProtocolVersion], and not served as a legacy handshake +// because its version string happens to sort below 2026-07-28. +func TestServerUnknownProtocolVersion_NewProtocol(t *testing.T) { + ctx := context.Background() + // Each case names a version that is not in [supportedProtocolVersions] and + // sorts below 2026-07-28. + tests := []struct { + name string + version string + extraMeta string // further `_meta` entries, each with a leading comma + }{ + // The versioning spec uses 1900-01-01 to illustrate this error. + {"documented example", "1900-01-01", fmt.Sprintf(`,%q:{}`, MetaKeyClientCapabilities)}, + {"unreleased revision", "2025-11-24", fmt.Sprintf(`,%q:{}`, MetaKeyClientCapabilities)}, + {"not a date", "1.0", fmt.Sprintf(`,%q:{}`, MetaKeyClientCapabilities)}, + // The rest of the `_meta` triple is defined by the revision the request + // names, so its absence must not mask the version itself. + {"no clientCapabilities", "1900-01-01", ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + server := NewServer(testImpl, nil) + _, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + params := fmt.Sprintf(`{"_meta":{%q:%q%s}}`, MetaKeyProtocolVersion, tc.version, tc.extraMeta) + _, err = ss.handle(ctx, &jsonrpc.Request{ + ID: jsonrpc2.Int64ID(1), + Method: methodListTools, + Params: json.RawMessage(params), + }) + var jerr *jsonrpc.Error + if !errors.As(err, &jerr) { + t.Fatalf("handle returned %v, want a *jsonrpc.Error", err) + } + if jerr.Code != CodeUnsupportedProtocolVersion { + t.Fatalf("error code = %d, want %d", jerr.Code, CodeUnsupportedProtocolVersion) + } + var data UnsupportedProtocolVersionData + if err := json.Unmarshal(jerr.Data, &data); err != nil { + t.Fatalf("unmarshal error data: %v", err) + } + if data.Requested != tc.version { + t.Errorf("UnsupportedProtocolVersionData.Requested = %q, want %q", data.Requested, tc.version) + } + if diff := cmp.Diff(SupportedProtocolVersions(), data.Supported); diff != "" { + t.Errorf("UnsupportedProtocolVersionData.Supported mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/mcp/shared.go b/mcp/shared.go index 6637a25..75dfeee 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -553,13 +553,19 @@ type validatedMeta struct { // validateRequestMeta inspects a JSON-RPC request to detect whether it follows // the >= 2026-07-28 protocol via the `_meta` field. -// If the request has no _meta, or no protocolVersion in _meta, it returns a non-nil -// validatedMeta with usesNewProtocol set to false, and a nil error. -// If the request has a protocolVersion in _meta it validates the presence of -// clientCapabilities in _meta. If it is missing or invalid, it returns nil and -// 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 -// initializeParams. +// If the request has no _meta, no protocolVersion in _meta, or a protocolVersion +// naming a supported version that predates 2026-07-28, it returns a non-nil +// validatedMeta with usesNewProtocol set to false, and a nil error: such a request +// belongs to the initialize handshake. +// If the protocolVersion names a version that is not supported at all, it returns +// usesNewProtocol set to true with initializeParams carrying only that version, so +// that the caller answers with [CodeUnsupportedProtocolVersion]. The rest of the +// `_meta` triple is not validated in that case, since the revision defining it is +// unknown. +// Otherwise, it validates the presence of clientCapabilities in _meta. If it is +// missing or invalid, it returns nil and 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 initializeParams. // Notifications always report usesNewProtocol false and never error. func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) { // SEP-2575 defines the `_meta` triple for calls only: NotificationParams @@ -574,9 +580,26 @@ func validateRequestMeta(req *jsonrpc.Request) (*validatedMeta, error) { return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil } protocolVersion, ok := meta[MetaKeyProtocolVersion].(string) - if !ok || protocolVersion < protocolVersion20260728 { + // An absent or empty value declares no version at all, which is also how the + // streamable handler reads `_meta.protocolVersion`. + if !ok || protocolVersion == "" { return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil } + // Classify by membership rather than by ordering: a version the SDK does not + // know sorts arbitrarily against 2026-07-28, and one that sorts below it would + // be served as a legacy handshake instead of reaching the + // CodeUnsupportedProtocolVersion check in ServerSession.handle. The + // streamable handler already applies the same membership rule to the + // Mcp-Protocol-Version header. + supported := slices.Contains(supportedProtocolVersions, protocolVersion) + if supported && protocolVersion < protocolVersion20260728 { + return &validatedMeta{usesNewProtocol: false, initializeParams: nil}, nil + } + if !supported { + return &validatedMeta{usesNewProtocol: true, initializeParams: &InitializeParams{ + ProtocolVersion: protocolVersion, + }}, nil + } var clientInfo *Implementation if _, present := meta[MetaKeyClientInfo]; present { var ok bool diff --git a/mcp/shared_test.go b/mcp/shared_test.go index 71a071d..d308c87 100644 --- a/mcp/shared_test.go +++ b/mcp/shared_test.go @@ -102,6 +102,57 @@ func TestValidateRequestMeta(t *testing.T) { params: json.RawMessage(`{"_meta": "not an object", "name": "x"}`), wantUsesNew: false, }, + { + name: "supported pre-2026-07-28 version: old protocol", + method: methodCallTool, + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: protocolVersion20251125, + }, + "name": "x", + }, + wantUsesNew: false, + }, + { + name: "empty protocolVersion declares nothing: old protocol", + method: methodCallTool, + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: "", + }, + "name": "x", + }, + wantUsesNew: false, + }, + { + // An unsupported version sorting below 2026-07-28 is still a + // new-protocol request, so that the caller can answer it with + // CodeUnsupportedProtocolVersion. + name: "unsupported version: new protocol", + method: methodCallTool, + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: "1900-01-01", + MetaKeyClientInfo: map[string]any{"name": "c", "version": "1"}, + MetaKeyClientCapabilities: map[string]any{}, + }, + "name": "x", + }, + wantUsesNew: true, + }, + { + // The triple is defined by a revision the SDK does not know, so an + // unsupported version is reported without validating the rest of it. + name: "unsupported version missing clientCapabilities: new protocol", + method: methodCallTool, + params: map[string]any{ + "_meta": map[string]any{ + MetaKeyProtocolVersion: "1900-01-01", + }, + "name": "x", + }, + wantUsesNew: true, + }, { name: "new protocol with logLevel", method: methodCallTool, From 3f43bcf012e45e701bfb91243d439edab44c4238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Mon, 14 Sep 2026 18:45:48 +0200 Subject: [PATCH 410/418] mcp: add HasParams so middleware can tell whether params are present (#1269) This adds `mcp.HasParams(req Request) bool`, so a receiving middleware can ask whether the message it was handed carried a `params` member. The member is optional for several methods: `serverMethodInfos` sets `missingParamsOK` for `server/discover`, `ping`, the four list methods and the `cancelled`, `initialized` and `roots/list_changed` notifications, so `{"jsonrpc":"2.0","id":1,"method":"tools/list"}` is a complete request. For one of those, `unmarshalParams` returns a nil `Params`, `newRequest` leaves `ServerRequest.Params` at the zero value of `P`, and `GetParams` hands the middleware a non-nil interface holding a typed nil pointer. Comparing that against nil says params are present, and the first method called on it dereferences: `GetMeta` is promoted from the embedded `Meta`. `Params` already declares `isNil` for precisely this case, and it is unexported, so from outside the package the accessor named after the question is the one thing that cannot be reached. `HasParams` is a function over `Request` rather than an exported method on `Params` for two reasons. A middleware holds a `Request`, and this way the caller never has to hold the value that panics. More importantly, `isNil` cannot answer the question for every `Params` value, so exporting it would ship a predicate that is itself unsafe: a params type declared the documented way for a custom method, `struct{ mcp.ParamsBase }`, promotes `isNil` through a field selector, and that dereferences the nil outer pointer before the method body runs. The predicate therefore has to be computed from outside the value, and `reflect.Value.IsNil` is what works for all of them. Three judgement calls, stated so nobody has to find them. First, `getRequestMeta` now goes through `HasParams`. That is a second fix rather than a refactor: `AddReceivingCustomMethod` registers with `missingParamsOK` too, so a client omitting `params` for a custom method made `ServerRequest.ProtocolVersion`, `ClientInfo` and `ClientCapabilities` panic in the promoted `isNil`. They now return `""`, `nil` and `nil`. I could have left it out to keep the diff to one function, but then the SDK would export a predicate that says to ask before touching params while three of its own accessors still panicked on the same value. If you would rather see it as a separate commit, say so and I will split it. Second, I did not add the predicate to the `Request` interface. `Request` is sealed by `isRequest`, so a method would have been a compatible change and would read slightly better at the call site, but a free function leaves the interface alone and serves every `Request` just as well. Third, this reflects once per call of `getRequestMeta`. It is one `reflect.ValueOf` on a value that is already an interface, on a path where `newResult` already reflects per request. Tests, both in `mcp/shared_test.go`. `TestServerRequest_PerRequestAccessors_MissingParams` is the one that fails before the change and passes after: on `main` it panics inside `getRequestMeta` with `invalid memory address or nil pointer dereference` from `(*customParams).isNil`, and here it asserts the three accessors return zero values. `TestHasParams` covers the exported behaviour: it builds exactly what `handleReceive` hands the middleware chain for `tools/list`, `prompts/list`, `resources/list` and `notifications/initialized` with no `params` member, asserts that `GetParams` still returns a non-nil interface so the case under test is still the typed-nil one, asserts `HasParams` reports false, and asserts it reports true when params are present. Behaviour an existing user would see change: only the three accessors above, and only for a custom-method params type built on `ParamsBase` whose `params` member was omitted, where a panic becomes a zero value. Nothing about the wire format changes, no exported signature moves, and no interface gains a method. A middleware that still writes `req.GetParams() != nil` and then dereferences still panics; this gives a caller the way to ask, it does not rescue a caller that does not. Also here: one sentence on the doc comments of both `AddReceivingMiddleware` methods saying a received message need not carry params, since that is where a middleware author is reading, and `examples/server/middleware/main.go` now logs `has_params` with `mcp.HasParams` instead of the comparison that cannot work. The example's printed output is unchanged, because the SDK client always sends a `params` member for the methods it drives. `go test ./...` and `gofmt -l .` are clean. One thing to say plainly, since it is yours to decide and not mine: this adds an exported symbol. `HasParams` is a new name in the public API of `mcp`, and everything else here is doc comments, an example and the three accessors. If you would rather not grow the surface for this, the smaller answer is to keep the `getRequestMeta` fix and the doc-comment sentence and drop the exported function, and a middleware author then has the guarantee written down where they are reading even though they still cannot ask the question in code. I prefer the function because the guarantee alone leaves every middleware writing the same three lines of `reflect`, and the one that does not write them panics on an ordinary `tools/list`, but I will cut it to whichever shape you want. Fixes #1261. Co-authored-by: Guglielmo Colombo --- examples/server/middleware/main.go | 2 +- mcp/client.go | 3 ++ mcp/server.go | 3 ++ mcp/shared.go | 27 ++++++++++++-- mcp/shared_test.go | 56 ++++++++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 3 deletions(-) diff --git a/examples/server/middleware/main.go b/examples/server/middleware/main.go index 4e37471..262d726 100644 --- a/examples/server/middleware/main.go +++ b/examples/server/middleware/main.go @@ -38,7 +38,7 @@ func main() { logger.Info("MCP method started", "method", method, "session_id", req.GetSession().ID(), - "has_params", req.GetParams() != nil, + "has_params", mcp.HasParams(req), ) // Log more for tool calls. if ctr, ok := req.(*mcp.CallToolRequest); ok { diff --git a/mcp/client.go b/mcp/client.go index c11f0c8..1ee13e8 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -1147,6 +1147,9 @@ func (c *Client) AddSendingMiddleware(middleware ...Middleware) { // // Receiving middleware is called when a request is received. It is useful for tasks // such as authentication, request logging and metrics. +// +// A received message need not carry params: use [HasParams] before inspecting +// [Request.GetParams]. func (c *Client) AddReceivingMiddleware(middleware ...Middleware) { c.mu.Lock() defer c.mu.Unlock() diff --git a/mcp/server.go b/mcp/server.go index 183a519..442d7bc 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1857,6 +1857,9 @@ func (s *Server) AddSendingMiddleware(middleware ...Middleware) { // // Receiving middleware is called when a request is received. It is useful for tasks // such as authentication, request logging and metrics. +// +// A received message need not carry params: use [HasParams] before inspecting +// [Request.GetParams]. func (s *Server) AddReceivingMiddleware(middleware ...Middleware) { s.mu.Lock() defer s.mu.Unlock() diff --git a/mcp/shared.go b/mcp/shared.go index 75dfeee..cacfa75 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -688,6 +688,30 @@ func (r *ServerRequest[P]) GetParams() Params { return r.Params } func (r *ClientRequest[P]) GetExtra() *RequestExtra { return nil } func (r *ServerRequest[P]) GetExtra() *RequestExtra { return r.Extra } +// HasParams reports whether req carries params. +// +// The "params" member of a message may be omitted for several methods, among +// them the list methods, ping and notifications/initialized. For such a request +// [Request.GetParams] returns a non-nil [Params] holding a nil pointer, so +// comparing its result against nil reports that params are present, while +// calling a method on that result panics. Middleware that inspects params must +// ask this first: +// +// if mcp.HasParams(req) { +// log.Println(method, req.GetParams().GetMeta()) +// } +func HasParams(req Request) bool { + params := req.GetParams() + if params == nil { + return false + } + // isNil cannot answer this for every value: a params type embedding + // ParamsBase promotes isNil through a field selector, which dereferences + // the nil outer pointer before the method body runs. + v := reflect.ValueOf(params) + return v.Kind() != reflect.Pointer || !v.IsNil() +} + // ProtocolVersion returns the protocol version negotiated for this request. // // For requests following the >= 2026-07-28 protocol, the value is read from @@ -749,8 +773,7 @@ func (r *ServerRequest[P]) ClientCapabilities() *ClientCapabilities { // getRequestMeta returns the raw `_meta` map from the request's params, or // nil if the params are absent. func getRequestMeta[P Params](r *ServerRequest[P]) map[string]any { - // In practice P is a pointer type implementing Params. - if any(r.Params) == nil || r.Params.isNil() { + if !HasParams(r) { return nil } return r.Params.GetMeta() diff --git a/mcp/shared_test.go b/mcp/shared_test.go index d308c87..4566768 100644 --- a/mcp/shared_test.go +++ b/mcp/shared_test.go @@ -324,6 +324,62 @@ func TestServerRequest_PerRequestAccessors_Empty(t *testing.T) { } } +func TestHasParams(t *testing.T) { + // {"jsonrpc":"2.0","id":1,"method":"tools/list"} is a complete request: + // serverMethodInfos marks these methods missingParamsOK. Build what + // handleReceive hands the middleware chain for one. + for _, method := range []string{ + methodListTools, + methodListPrompts, + methodListResources, + notificationInitialized, + } { + t.Run(method, func(t *testing.T) { + info := serverMethodInfos[method] + params, err := info.unmarshalParams(nil) + if err != nil { + t.Fatal(err) + } + req := info.newRequest(&ServerSession{}, params, nil) + if req.GetParams() == nil { + t.Fatal("GetParams returned a nil interface, so this test no longer covers the typed-nil case") + } + if HasParams(req) { + t.Error("HasParams = true for a request whose params member was omitted") + } + }) + } + + t.Run("params present", func(t *testing.T) { + info := serverMethodInfos[methodListTools] + params, err := info.unmarshalParams(json.RawMessage(`{"cursor":"c"}`)) + if err != nil { + t.Fatal(err) + } + req := info.newRequest(&ServerSession{}, params, nil) + if !HasParams(req) { + t.Error("HasParams = false for a request carrying params") + } + }) +} + +func TestServerRequest_PerRequestAccessors_MissingParams(t *testing.T) { + // Custom methods also allow the params member to be omitted, and their + // params types embed ParamsBase, whose promoted isNil dereferences the + // typed-nil outer pointer. + type customParams struct{ ParamsBase } + req := &ServerRequest[*customParams]{} + 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) + } +} + func TestImplementationDescriptionJSON(t *testing.T) { impl := &Implementation{ Name: "greeter", From 0c0eb236257200d913d04e658fd2f2e5cefa7832 Mon Sep 17 00:00:00 2001 From: rishabh Date: Mon, 14 Sep 2026 13:20:25 -0400 Subject: [PATCH 411/418] docs/client: fix the SEP-1034 and SEP-1330 links (#1252) The "Schema defaults and enums" section of docs/client.md links SEP-1034 to https://modelcontextprotocol.io/seps/1034 and SEP-1330 to https://modelcontextprotocol.io/seps/1330, and both of those bare-number URLs are dead. modelcontextprotocol.io serves SEPs at number-plus-slug URLs, which is what every other SEP link in this repo already uses (seps/2575-stateless-mcp, seps/2577-deprecate-roots-sampling-and-logging, seps/2596-feature-lifecycle-and-deprecation-policy, seps/2207-oidc-refresh-token-guidance); these two were the only bare-number ones left. I took the replacement slugs from the site's own sitemap.xml, so the `1034--` double hyphen is what is published rather than a typo here. curl -sIL on the two old URLs returns 404 and 404, and on the two new ones 200 and 200. The edit is in internal/docs/client.src.md with docs/client.md regenerated by `go generate ./internal/docs`, and `go build ./... && go test ./...` pass. Co-authored-by: Guglielmo Colombo --- docs/client.md | 4 ++-- internal/docs/client.src.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/client.md b/docs/client.md index ad71c11..23d57be 100644 --- a/docs/client.md +++ b/docs/client.md @@ -304,7 +304,7 @@ func Example_elicitation() { `ElicitParams.RequestedSchema` is a flat schema of primitive fields, which the client renders as a form. Two field keywords shape that form. -A `Default` ([SEP-1034](https://modelcontextprotocol.io/seps/1034)) prefills a +A `Default` ([SEP-1034](https://modelcontextprotocol.io/seps/1034--support-default-values-for-all-primitive-types-in)) prefills a field. When the user accepts without supplying it, the SDK fills the field in from the schema before the result reaches either side's caller — the client does so after its elicitation handler returns, and `ServerSession.Elicit` does @@ -313,7 +313,7 @@ defaulted field `Required` defeats it: accepted content is validated against the schema before defaults are applied, so an answer that omits the field is rejected rather than defaulted. -An `Enum` ([SEP-1330](https://modelcontextprotocol.io/seps/1330)) restricts a +An `Enum` ([SEP-1330](https://modelcontextprotocol.io/seps/1330-elicitation-enum-schema-improvements-and-standards)) restricts a field to a fixed set of values, which the client renders as a choice. Enums are supported only on `"string"` fields; declaring one on another type is rejected. To label the choices, set the legacy `enumNames` keyword through diff --git a/internal/docs/client.src.md b/internal/docs/client.src.md index fdaef2a..c0f64b4 100644 --- a/internal/docs/client.src.md +++ b/internal/docs/client.src.md @@ -99,7 +99,7 @@ pattern. `ElicitParams.RequestedSchema` is a flat schema of primitive fields, which the client renders as a form. Two field keywords shape that form. -A `Default` ([SEP-1034](https://modelcontextprotocol.io/seps/1034)) prefills a +A `Default` ([SEP-1034](https://modelcontextprotocol.io/seps/1034--support-default-values-for-all-primitive-types-in)) prefills a field. When the user accepts without supplying it, the SDK fills the field in from the schema before the result reaches either side's caller — the client does so after its elicitation handler returns, and `ServerSession.Elicit` does @@ -108,7 +108,7 @@ defaulted field `Required` defeats it: accepted content is validated against the schema before defaults are applied, so an answer that omits the field is rejected rather than defaulted. -An `Enum` ([SEP-1330](https://modelcontextprotocol.io/seps/1330)) restricts a +An `Enum` ([SEP-1330](https://modelcontextprotocol.io/seps/1330-elicitation-enum-schema-improvements-and-standards)) restricts a field to a fixed set of values, which the client renders as a choice. Enums are supported only on `"string"` fields; declaring one on another type is rejected. To label the choices, set the legacy `enumNames` keyword through From c1918e19ad93014def1ef1dcf4a136783b6de9d0 Mon Sep 17 00:00:00 2001 From: jackyyy <142072937+yyydddkkk@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:17:47 +0800 Subject: [PATCH 412/418] mcp: preserve SSE events buffered after the endpoint (#1270) SSEClientTransport.Connect creates one event scanner to read the endpoint event and a second scanner to read subsequent messages. Each scanner owns a buffered reader, so bytes read ahead by the first scanner are discarded when it is abandoned. A message coalesced with the endpoint can therefore disappear while the connection remains open. Reuse the event scanner across the endpoint handshake and the message-reading goroutine. This preserves unread bytes without changing the public API. The regression test exercises Connect and Read with a controlled HTTP response body, covering both a message coalesced with the endpoint and a message arriving after Connect returns. On unmodified main, the separate-message control passes and the coalesced case times out; both pass with the fix. Validation on linux/amd64 with Go 1.26.8: - go test ./... - go test -race ./... - go vet ./... - staticcheck v0.6.1 ./... - gofmt and git diff --check The HTTP response boundary is simulated; no third-party server or proxy was used. This change concerns the legacy SSE transport. The Streamable HTTP conformance suite was not run. --- mcp/sse.go | 6 ++++-- mcp/sse_test.go | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/mcp/sse.go b/mcp/sse.go index 0a5bf7f..f5e90b8 100644 --- a/mcp/sse.go +++ b/mcp/sse.go @@ -437,9 +437,11 @@ func (c *SSEClientTransport) Connect(ctx context.Context) (Connection, error) { maxEventSize = DefaultMaxEventSize } + // Reuse the scanner so events buffered after the endpoint are not lost. + events := scanEventsLimited(resp.Body, maxEventSize) msgEndpoint, err := func() (*url.URL, error) { var evt Event - for evt, err = range scanEventsLimited(resp.Body, maxEventSize) { + for evt, err = range events { break } if err != nil { @@ -468,7 +470,7 @@ func (c *SSEClientTransport) Connect(ctx context.Context) (Connection, error) { go func() { defer s.Close() // close the transport when the GET exits - for evt, err := range scanEventsLimited(resp.Body, maxEventSize) { + for evt, err := range events { if err != nil { return } diff --git a/mcp/sse_test.go b/mcp/sse_test.go index de10eb3..d9b7a2b 100644 --- a/mcp/sse_test.go +++ b/mcp/sse_test.go @@ -13,10 +13,13 @@ import ( "net/http" "net/http/httptest" "slices" + "strings" "sync/atomic" "testing" + "time" "github.com/google/go-cmp/cmp" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" ) // TestSSEServerTransport_SupportedVersions verifies that the deprecated @@ -432,3 +435,49 @@ func TestSSEServerTransportMaxRequestBodyBytes(t *testing.T) { }) } } + +func TestSSEClientTransportBufferedEvents(t *testing.T) { + const endpoint = "event: endpoint\ndata: /messages\n\n" + const message = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/tools/list_changed\"}\n\n" + for _, coalesced := range []bool{false, true} { + t.Run(fmt.Sprintf("coalesced=%t", coalesced), func(t *testing.T) { + reader, writer := io.Pipe() + defer reader.Close() + defer writer.Close() + prefix := endpoint + if coalesced { + prefix += message + } + transport := &SSEClientTransport{ + Endpoint: "http://example.com/sse", + HTTPClient: &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": {"text/event-stream"}}, + Body: struct { + io.Reader + io.Closer + }{io.MultiReader(strings.NewReader(prefix), reader), reader}, + }, nil + })}, + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + conn, err := transport.Connect(ctx) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + if !coalesced { + go func() { _, _ = io.WriteString(writer, message) }() + } + msg, err := conn.Read(ctx) + if err != nil { + t.Fatalf("Read: %v", err) + } + if req, ok := msg.(*jsonrpc.Request); !ok || req.Method != "notifications/tools/list_changed" { + t.Fatalf("Read = %v, want tools/list_changed notification", msg) + } + }) + } +} From fbd36cb7870176bfc3e8e423df697cf110d0f927 Mon Sep 17 00:00:00 2001 From: rishabh Date: Tue, 15 Sep 2026 07:44:03 -0400 Subject: [PATCH 413/418] docs/server: fix logging defaults and the SetLoggingLevel method name (#1251) The Logging section of docs/server.md says "For stateful sessions" twice in a row with opposite meanings, so one of the two sentences has to be wrong. The default level of "info" is set in only one place in the SDK, `StreamableHTTPHandler.ephemeralConnectOpts` in mcp/streamable.go, which handles stateless requests; a session created through `Server.Connect` starts with an empty `ServerSessionState.LogLevel`, and `ServerSession.Log` returns early while the level is empty, which is exactly what the first sentence already says. The second sentence now reads "For stateless sessions". The same section also tells readers to call `SetLevel`, but there is no such exported method: the client-side call is `ClientSession.SetLoggingLevel`, and `setLevel` is only the unexported server handler, so the pkg.go.dev link on the page points at an anchor that does not exist. For context, the stateless default arrived in #411 and the paragraph describing it was written later in #497, which is where the duplicated "stateful" came from. Checked with `go doc ./mcp ClientSession` (only `SetLoggingLevel` is exported) and `grep -rn 'LogLevel = "info"' mcp/` (a single hit, mcp/streamable.go:516); docs/server.md was regenerated with `go generate ./internal/docs`, and `go build ./... && go test ./...` pass. --------- Signed-off-by: Rishabh Co-authored-by: Guglielmo Colombo --- docs/server.md | 10 +++++++--- internal/docs/server.src.md | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/server.md b/docs/server.md index d7bc314..8973e3a 100644 --- a/docs/server.md +++ b/docs/server.md @@ -1066,8 +1066,12 @@ server produces logs that remain server-side, for use by server maintainers.) **Server-side**: The minimum log level is part of the server state. For stateful sessions, there is no default log level: no log messages will be sent -until the client calls `SetLevel` (see below). -For stateful sessions, the level defaults to "info". +until the client calls `SetLoggingLevel` (see below). +For legacy stateless sessions the level defaults to "info". +For sessionless 2026-07-28 requests there is no server-side default: each request +carries its level in `_meta` (`io.modelcontextprotocol/logLevel`), and nothing is +logged until the client sends one. +(Note: logging is deprecated as of 2026-07-28 / SEP-2577.) [`ServerSession.Log`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.Log) is the low-level way for servers to log to clients. It sends a logging notification to the client if the level of the message @@ -1083,7 +1087,7 @@ Servers always report the logging capability. **Client-side**: Set [`ClientOptions.LoggingMessageHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.LoggingMessageHandler) to receive log messages. -Call [`ClientSession.SetLevel`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.SetLevel) to change the log level for a session. +Call [`ClientSession.SetLoggingLevel`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.SetLoggingLevel) to change the log level for a session. ```go func Example_logging() { diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index 82b956e..ff66383 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -437,8 +437,12 @@ server produces logs that remain server-side, for use by server maintainers.) **Server-side**: The minimum log level is part of the server state. For stateful sessions, there is no default log level: no log messages will be sent -until the client calls `SetLevel` (see below). -For stateful sessions, the level defaults to "info". +until the client calls `SetLoggingLevel` (see below). +For legacy stateless sessions the level defaults to "info". +For sessionless 2026-07-28 requests there is no server-side default: each request +carries its level in `_meta` (`io.modelcontextprotocol/logLevel`), and nothing is +logged until the client sends one. +(Note: logging is deprecated as of 2026-07-28 / SEP-2577.) [`ServerSession.Log`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.Log) is the low-level way for servers to log to clients. It sends a logging notification to the client if the level of the message @@ -454,7 +458,7 @@ Servers always report the logging capability. **Client-side**: Set [`ClientOptions.LoggingMessageHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.LoggingMessageHandler) to receive log messages. -Call [`ClientSession.SetLevel`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.SetLevel) to change the log level for a session. +Call [`ClientSession.SetLoggingLevel`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.SetLoggingLevel) to change the log level for a session. %include ../../mcp/server_example_test.go logging - From 3785c500b581ad76bc5a74e9f84d8feeb89fef27 Mon Sep 17 00:00:00 2001 From: Luca Candela <73209+CaliLuke@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:22:16 -0700 Subject: [PATCH 414/418] mcp: preserve structured content number precision (#1239) CallToolResult currently decodes structuredContent through an any field, which converts JSON numbers to float64 and silently rounds integers outside the IEEE-754 safe range. This change decodes structuredContent separately with UseNumber, preserving wire numbers as json.Number while retaining the existing decoded object, array, and primitive shapes. The field documentation now describes that representation. Users who depend on the previous float64 behavior can temporarily restore it with MCPGODEBUG=structuredcontentfloat64=1. An end-to-end in-memory client/server regression covers 9007199254740993 from a low-level Server.AddTool result. Additional coverage verifies the compatibility option restores the legacy rounded float64 value. Verification: - gofmt -l . - go test ./... - go vet ./... Fixes #1236 --------- Co-authored-by: Guglielmo Colombo --- docs/mcpgodebug.md | 10 +++++++ internal/docs/mcpgodebug.src.md | 10 +++++++ internal/json/json.go | 9 ++++++ mcp/protocol.go | 22 +++++++++++++-- mcp/protocol_test.go | 20 ++++++++++++- mcp/server_test.go | 50 +++++++++++++++++++++++++++++++-- mcp/streamable_test.go | 10 +++++-- 7 files changed, 124 insertions(+), 7 deletions(-) diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index 57766ce..43a62ce 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -18,6 +18,16 @@ ## `MCPGODEBUG` history +### 1.9.0 + +Options listed below were added and will be removed in the 1.11.0 version of the SDK. + +- `structuredcontentfloat64` added. If set to `1`, numbers in + `CallToolResult.StructuredContent` are decoded as `float64`, restoring the + previous behavior. The default behavior decodes numbers as `json.Number` so + that large integers and other exact JSON values do not silently lose + precision. + ### 1.8.0 Options listed below were added and will be removed in the 1.9.0 version of the SDK. diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index fd0f255..8d1385f 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -17,6 +17,16 @@ ## `MCPGODEBUG` history +### 1.9.0 + +Options listed below were added and will be removed in the 1.11.0 version of the SDK. + +- `structuredcontentfloat64` added. If set to `1`, numbers in + `CallToolResult.StructuredContent` are decoded as `float64`, restoring the + previous behavior. The default behavior decodes numbers as `json.Number` so + that large integers and other exact JSON values do not silently lose + precision. + ### 1.8.0 Options listed below were added and will be removed in the 1.9.0 version of the SDK. diff --git a/internal/json/json.go b/internal/json/json.go index b3fa039..3a0e52b 100644 --- a/internal/json/json.go +++ b/internal/json/json.go @@ -44,6 +44,15 @@ func Unmarshal(data []byte, v any) error { return NewDecoder(bytes.NewReader(data)).Decode(v) } +func UnmarshalUseNumber(data []byte, v any) error { + if err := checkMaxDepth(data, defaultMaxDepth); err != nil { + return err + } + dec := NewDecoder(bytes.NewReader(data)) + dec.dec.UseNumber() + return dec.Decode(v) +} + // checkMaxDepth scans data once and reports [errMaxDepthExceeded] if the // nesting of JSON objects and arrays exceeds maxDepth. It is a lightweight pass which // tracks '{' and '[' against '}' and ']' while skipping over the contents of strings. diff --git a/mcp/protocol.go b/mcp/protocol.go index be37a18..c340e60 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -289,6 +289,8 @@ type CallToolResult struct { // result of the tool call. Per SEP-2106, it may marshal to any valid JSON // value (object, array, or primitive) conforming to the tool's // [Tool.OutputSchema]. + // Numbers received from the wire are represented as [json.Number] to preserve + // their exact values. // // When using a [ToolHandlerFor] with structured output, you should not // populate this field. It will be automatically populated with the typed Out @@ -379,6 +381,12 @@ func (r *CallToolResult) hasContent() bool { // An empty InputRequests with NeedsInput true indicates load-shedding. func (r *CallToolResult) NeedsInput() bool { return r.resultType == resultTypeInputRequired } +// structuredcontentfloat64 is a compatibility parameter that restores the +// previous behavior of decoding numbers in [CallToolResult.StructuredContent] +// as float64 values. By default, numbers are decoded as [json.Number] to avoid +// losing precision. The option will be removed in the 1.11.0 version of the SDK. +var structuredcontentfloat64 = mcpgodebug.Value("structuredcontentfloat64") + func (x *CallToolResult) MarshalJSON() ([]byte, error) { type res CallToolResult // avoid recursion type wire struct { @@ -401,12 +409,22 @@ func (x *CallToolResult) UnmarshalJSON(data []byte) error { type res CallToolResult // avoid recursion var wire struct { res - Content []*wireContent `json:"content"` - ResultType resultType `json:"resultType"` + Content []*wireContent `json:"content"` + StructuredContent json.RawMessage `json:"structuredContent"` + ResultType resultType `json:"resultType"` } if err := internaljson.Unmarshal(data, &wire); err != nil { return err } + if len(wire.StructuredContent) > 0 { + unmarshal := internaljson.UnmarshalUseNumber + if structuredcontentfloat64 == "1" { + unmarshal = internaljson.Unmarshal + } + if err := unmarshal(wire.StructuredContent, &wire.res.StructuredContent); err != nil { + return err + } + } var err error if wire.res.Content, err = contentsFromWire(wire.Content, nil); err != nil { return err diff --git a/mcp/protocol_test.go b/mcp/protocol_test.go index fe496de..4f0c345 100644 --- a/mcp/protocol_test.go +++ b/mcp/protocol_test.go @@ -1285,7 +1285,7 @@ func TestContentUnmarshal(t *testing.T) { Meta: Meta{"m": true}, Content: content, IsError: true, - StructuredContent: 3.0, + StructuredContent: json.Number("3"), } var gotf CallToolResult roundtrip(ctrf, &gotf) @@ -1298,6 +1298,24 @@ func TestContentUnmarshal(t *testing.T) { roundtrip(pm, &gotpm) } +func TestCallToolResultStructuredContentFloat64Compatibility(t *testing.T) { + prev := structuredcontentfloat64 + structuredcontentfloat64 = "1" + t.Cleanup(func() { structuredcontentfloat64 = prev }) + + var got CallToolResult + if err := json.Unmarshal([]byte(`{"content":[],"structuredContent":{"id":9007199254740993}}`), &got); err != nil { + t.Fatal(err) + } + structured, ok := got.StructuredContent.(map[string]any) + if !ok { + t.Fatalf("StructuredContent type = %T, want map[string]any", got.StructuredContent) + } + if got := structured["id"]; got != float64(9007199254740992) { + t.Errorf("id = %v (%T), want rounded float64 value", got, got) + } +} + func TestToolAnnotations_MarshalJSON(t *testing.T) { boolPtr := func(b bool) *bool { return &b } diff --git a/mcp/server_test.go b/mcp/server_test.go index 1544e29..2c7bbe0 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -677,7 +677,7 @@ func TestAddToolNonObjectOutputSchema(t *testing.T) { name: "primitive number (map-based schema)", outputSchema: map[string]any{"type": "number"}, content: 42.0, - want: 42.0, + want: json.Number("42"), }, { name: "primitive string (RawMessage schema)", @@ -724,6 +724,52 @@ func TestAddToolNonObjectOutputSchema(t *testing.T) { } } +func TestCallToolStructuredContentPreservesLargeInteger(t *testing.T) { + const want int64 = 9007199254740993 + + server := NewServer(testImpl, nil) + server.AddTool(&Tool{ + Name: "large-integer", + InputSchema: &jsonschema.Schema{Type: "object"}, + }, func(context.Context, *CallToolRequest) (*CallToolResult, error) { + return &CallToolResult{StructuredContent: map[string]any{"id": want}}, nil + }) + + clientTransport, serverTransport := NewInMemoryTransports() + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + if err != nil { + t.Fatal(err) + } + defer serverSession.Close() + + client := NewClient(testImpl, nil) + clientSession, err := client.Connect(context.Background(), clientTransport, nil) + if err != nil { + t.Fatal(err) + } + defer clientSession.Close() + + result, err := clientSession.CallTool(context.Background(), &CallToolParams{Name: "large-integer"}) + if err != nil { + t.Fatal(err) + } + structured, ok := result.StructuredContent.(map[string]any) + if !ok { + t.Fatalf("StructuredContent type = %T, want map[string]any", result.StructuredContent) + } + number, ok := structured["id"].(json.Number) + if !ok { + t.Fatalf("id type = %T, want json.Number", structured["id"]) + } + got, err := number.Int64() + if err != nil { + t.Fatal(err) + } + if got != want { + t.Errorf("id = %d, want %d", got, want) + } +} + // TestAddToolGenericNonObjectOutput verifies SEP-2106 for the generic // AddTool[In, Out] helper: Out may be a slice or primitive whose inferred // JSON Schema has a non-object root. @@ -816,7 +862,7 @@ func TestAddToolGenericNonObjectOutput(t *testing.T) { if res.IsError { t.Fatalf("unexpected tool error: %v", res.Content) } - if diff := cmp.Diff(float64(42), res.StructuredContent); diff != "" { + if diff := cmp.Diff(json.Number("42"), res.StructuredContent); diff != "" { t.Errorf("structured content mismatch (-want +got):\n%s", diff) } }) diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index ca4d0a7..7af9dff 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -288,8 +288,14 @@ func TestStreamableConcurrentHandling(t *testing.T) { t.Errorf("CallTool failed: %v", err) return } - if got := int(res.StructuredContent.(map[string]any)["Count"].(float64)); got != i { - t.Errorf("got count %d, want %d", got, i) + number, ok := res.StructuredContent.(map[string]any)["Count"].(json.Number) + if !ok { + t.Errorf("count type = %T, want json.Number", res.StructuredContent.(map[string]any)["Count"]) + continue + } + got, err := number.Int64() + if err != nil || got != int64(i) { + t.Errorf("got count %d, %v; want %d", got, err, i) } } }) From 826e653c270433bbaee6d5d46a771a66f9e15d98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Thu, 17 Sep 2026 10:36:23 +0200 Subject: [PATCH 415/418] mcp: refuse a call on a legacy session before initialize (#1273) Closes #1271. `Server.handle` refuses a call made before `initialize` on a legacy session, and that refusal lives in the `default` branch of its method switch, so any method with a `case` of its own skips it. Three are served on a session that never handshook: `logging/setLevel`, `resources/subscribe` and `resources/unsubscribe`. `resources/subscribe` is the one that reaches state rather than merely answering: it starts a watcher and registers a subscriber, so a client that subscribes before `initialize` is delivered `notifications/resources/updated` for the lifetime of a session the server never agreed to. The check now runs after the switch, for every call carrying no new-protocol `_meta`, with `initialize` and `ping` exempt. That is the same condition the `default` branch already applied and the same error it produced; the change is that the three methods above stop skipping it. `ping` stays allowed because the lifecycle page exempts it. `server/discover` is deliberately left alone: a legacy request there already receives method-not-found, which tells the client more than an initialization error would. The two notification cases stay outside, since the gate covers calls and a notification carries no refusal. The SEP-2575 path is untouched. A session without an `initialize` handshake is exactly what that proposal is for, so the exemption is the request's own `_meta.protocolVersion` rather than anything about the session, and a new-protocol client is served as it is today. Tests: `TestServerHandle_LegacyCallBeforeInitialize` drives each affected method on a fresh session and asserts the refusal, that `resources/subscribe` runs no handler and leaves no subscriber, and that `initialize` and `ping` are served. `TestServerHandle_NewProtocolCallWithoutInitialize` asserts a new-protocol session with no handshake still gets `tools/list`, and that the three methods keep their existing method-not-found rather than the initialization refusal. Without the change, the setLevel, subscribe and unsubscribe rows fail. `go test ./mcp/ -count=1` is green, `gofmt -l` is empty and `go vet ./mcp/` is clean. This does not depend on #1266 and rebases onto it cleanly, verified with the suite green on the combination. One decision worth your view: the refusal is a plain error, which on the wire carries code 0, exactly as the `default` branch produced before this change. The specification prescribes no code for it. If you would rather it were `-32600` or `-32603`, say so and I will change it here. --------- Co-authored-by: Guglielmo Colombo --- mcp/server.go | 14 ++-- mcp/server_test.go | 156 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 4 deletions(-) diff --git a/mcp/server.go b/mcp/server.go index 442d7bc..b2f89f4 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1989,10 +1989,6 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, } } default: - if !initialized && !validatedMeta.usesNewProtocol && req.IsCall() { - 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) - } if !initialized && validatedMeta.usesNewProtocol && validatedMeta.initializeParams != nil { ss.updateState(func(state *ServerSessionState) { state.InitializeParams = validatedMeta.initializeParams @@ -2000,6 +1996,16 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any, } } + // In legacy protocol versions, a client cannot send requests other than + // pings before the server has responded to the initialize request. A + // new-protocol request is exempt: a SEP-2575 session has no 'initialize' + // to wait for, and the request itself says which protocol it speaks. + if !initialized && !validatedMeta.usesNewProtocol && req.IsCall() && + req.Method != methodInitialize && req.Method != methodPing { + 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) + } + // modelcontextprotocol/go-sdk#26: handle calls asynchronously, and // notifications synchronously, except for 'initialize' which shouldn't be // asynchronous to other diff --git a/mcp/server_test.go b/mcp/server_test.go index 2c7bbe0..4d09cbc 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -2198,6 +2198,162 @@ func TestServerSupportedProtocolVersions_NewProtocol(t *testing.T) { } } +// TestServerHandle_LegacyCallBeforeInitialize asserts that a call carrying no +// new-protocol `_meta` is refused on a session that has not completed +// 'initialize', whichever branch of the method switch it lands in. The +// handshake itself is what ends that state, and the lifecycle spec exempts +// pings, so those two are served. +// +// The gate used to sit in the default branch, so 'logging/setLevel', +// 'resources/subscribe' and 'resources/unsubscribe', which share a case that +// only refuses new-protocol requests, were served before the handshake. For +// subscribe that registered a session that never handshook as a subscriber, +// so the subtest also asserts that the refusal leaves no subscriber and runs +// no handler. +func TestServerHandle_LegacyCallBeforeInitialize(t *testing.T) { + ctx := context.Background() + const uri = "test://resource" + + tests := []struct { + name string + method string + params string + served bool + }{ + { + name: "initialize", + method: methodInitialize, + params: fmt.Sprintf(`{"protocolVersion":%q,"capabilities":{},"clientInfo":{"name":"c","version":"1"}}`, protocolVersion20251125), + served: true, + }, + {name: "ping", method: methodPing, params: `{}`, served: true}, + {name: "tools/list", method: methodListTools, params: `{}`}, + {name: "logging/setLevel", method: methodSetLevel, params: `{"level":"info"}`}, + {name: "resources/subscribe", method: methodSubscribe, params: fmt.Sprintf(`{"uri":%q}`, uri)}, + {name: "resources/unsubscribe", method: methodUnsubscribe, params: fmt.Sprintf(`{"uri":%q}`, uri)}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var handlerRan bool + server := NewServer(testImpl, &ServerOptions{ + SubscribeHandler: func(context.Context, *SubscribeRequest) error { + handlerRan = true + return nil + }, + UnsubscribeHandler: func(context.Context, *UnsubscribeRequest) error { + handlerRan = true + return nil + }, + }) + _, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + res, err := ss.handle(ctx, &jsonrpc.Request{ + ID: jsonrpc2.Int64ID(1), + Method: tc.method, + Params: json.RawMessage(tc.params), + }) + if tc.served { + if err != nil { + t.Fatalf("handle(%q) before initialize returned %v, want it served", tc.method, err) + } + if res == nil { + t.Fatalf("handle(%q) before initialize returned no result", tc.method) + } + if tc.method == methodInitialize && ss.InitializeParams() == nil { + t.Errorf("initialize was answered without recording InitializeParams") + } + return + } + if err == nil { + t.Fatalf("handle(%q) served the call before initialize, want a refusal", tc.method) + } + if want := fmt.Sprintf("method %q is invalid during session initialization", tc.method); err.Error() != want { + t.Errorf("handle(%q) error = %q, want %q", tc.method, err.Error(), want) + } + if handlerRan { + t.Errorf("handle(%q) ran the server's handler for a call it refused", tc.method) + } + server.mu.Lock() + _, subscribed := server.resourceSubscriptions[uri][ss] + server.mu.Unlock() + if subscribed { + t.Errorf("handle(%q) left the session subscribed to %q", tc.method, uri) + } + }) + } +} + +// TestServerHandle_NewProtocolCallWithoutInitialize asserts that the gate +// stays off the SEP-2575 path. A fresh session that never sends 'initialize' +// is served a new-protocol 'tools/list', and the methods the gate now covers +// are answered, when a new-protocol request names them, with the error the +// new protocol already gave them (the method does not exist there), and not +// with the initialization refusal. +func TestServerHandle_NewProtocolCallWithoutInitialize(t *testing.T) { + ctx := context.Background() + const uri = "test://resource" + + tests := []struct { + name string + method string + params map[string]any + served bool + }{ + {name: "tools/list", method: methodListTools, params: newProtocolParams(nil), served: true}, + {name: "logging/setLevel", method: methodSetLevel, params: newProtocolParams(map[string]any{"level": "info"})}, + {name: "resources/subscribe", method: methodSubscribe, params: newProtocolParams(map[string]any{"uri": uri})}, + {name: "resources/unsubscribe", method: methodUnsubscribe, params: newProtocolParams(map[string]any{"uri": uri})}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + server := NewServer(testImpl, &ServerOptions{ + SubscribeHandler: func(context.Context, *SubscribeRequest) error { return nil }, + UnsubscribeHandler: func(context.Context, *UnsubscribeRequest) error { return nil }, + }) + _, st := NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + defer ss.Close() + + params, err := json.Marshal(tc.params) + if err != nil { + t.Fatalf("marshalling params: %v", err) + } + res, err := ss.handle(ctx, &jsonrpc.Request{ + ID: jsonrpc2.Int64ID(1), + Method: tc.method, + Params: params, + }) + if tc.served { + if err != nil { + t.Fatalf("handle(%q) on a new-protocol session returned %v, want it served", tc.method, err) + } + if res == nil { + t.Fatalf("handle(%q) on a new-protocol session returned no result", tc.method) + } + return + } + var jerr *jsonrpc.Error + if !errors.As(err, &jerr) { + t.Fatalf("handle(%q) returned %v, want a *jsonrpc.Error", tc.method, err) + } + if jerr.Code != jsonrpc.CodeMethodNotFound { + t.Errorf("handle(%q) error code = %d, want %d (removed in the new protocol)", tc.method, jerr.Code, jsonrpc.CodeMethodNotFound) + } + if strings.Contains(err.Error(), "invalid during session initialization") { + t.Errorf("handle(%q) applied the initialization gate to a new-protocol request: %v", tc.method, err) + } + }) + } +} + // TestServerUnknownProtocolVersion_NewProtocol verifies that a request whose // `_meta.protocolVersion` names a version the SDK does not know is rejected // with [CodeUnsupportedProtocolVersion], and not served as a legacy handshake From b58aa8d8d8530fdc3c02573a345aa161a466659e Mon Sep 17 00:00:00 2001 From: Guglielmo Colombo Date: Fri, 18 Sep 2026 09:26:01 +0200 Subject: [PATCH 416/418] mcp: ignore notification in stdio batches (#1277) Fixes #1256 --- mcp/transport.go | 2 +- mcp/transport_test.go | 104 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/mcp/transport.go b/mcp/transport.go index ce841f2..08d3b15 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -734,7 +734,7 @@ func (t *ioConn) Read(ctx context.Context) (jsonrpc.Message, error) { if batch { var respBatch *msgBatch // track incoming requests in the batch for _, msg := range msgs { - if req, ok := msg.(*jsonrpc.Request); ok { + if req, ok := msg.(*jsonrpc.Request); ok && req.IsCall() { if respBatch == nil { respBatch = &msgBatch{ unresolved: make(map[jsonrpc2.ID]int), diff --git a/mcp/transport_test.go b/mcp/transport_test.go index d4d7782..a20ee5f 100644 --- a/mcp/transport_test.go +++ b/mcp/transport_test.go @@ -5,6 +5,7 @@ package mcp import ( + "bytes" "context" "errors" "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. func TestIOConnFrameCap(t *testing.T) { tests := []struct { From 3b917b466cc540079b82bcd0fecdc46a2ba13644 Mon Sep 17 00:00:00 2001 From: sravan kilaru Date: Fri, 18 Sep 2026 02:41:05 -0500 Subject: [PATCH 417/418] oauthex: build defaultDiscoveryTransport lazily instead of at import (#1278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1276 ## What's happening `oauthex` builds `defaultDiscoveryTransport` as a package-level var, so it runs at import time. That path calls `proxyConfigured`, which invokes `http.DefaultTransport`'s proxy func (`http.ProxyFromEnvironment`). net/http reads the proxy env vars only once and caches the result, so this import-time call freezes the proxy config before `main()` can set `HTTPS_PROXY`. New in v1.8.0, which is why it's a regression — just importing `.../mcp` (which pulls in `oauthex`) triggers it, even with no use of the SDK. ## Fix Wrap the setup in `sync.OnceValue` so it runs on first use instead of at import. Still built once and reused. ## Testing (removed) - Ran the issue's repro against this branch via a local `replace`: prints `proxy=http://127.0.0.1:9` now, `proxy=` before. - Added a regression test in `oauthex`. - `go test ./...` passes. Co-authored-by: Guglielmo Colombo --- oauthex/oauth2.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/oauthex/oauth2.go b/oauthex/oauth2.go index caed92f..0e77149 100644 --- a/oauthex/oauth2.go +++ b/oauthex/oauth2.go @@ -16,6 +16,7 @@ import ( "net/netip" "net/url" "strings" + "sync" "syscall" "time" @@ -25,7 +26,15 @@ import ( const maxDiscoveryRedirects = 10 -var defaultDiscoveryTransport = newDiscoveryTransport(http.DefaultTransport) +// Build this on first use, not when the package loads. Building it checks the +// proxy via http.ProxyFromEnvironment, and net/http only reads the proxy +// environment once and then remembers it for the whole run. If that happened +// at import time, it would lock in the proxy setting before the app had a +// chance to set HTTPS_PROXY. sync.OnceValue waits until first use and still +// builds the transport just once. See #1276. +var defaultDiscoveryTransport = sync.OnceValue(func() http.RoundTripper { + return newDiscoveryTransport(http.DefaultTransport) +}) type httpStatusError struct { StatusCode int @@ -103,7 +112,7 @@ func checkHTTPSOrLoopback(addr string) error { } func newDiscoveryClient(c *http.Client) *http.Client { - transport := defaultDiscoveryTransport + transport := defaultDiscoveryTransport() if c != nil && c.Transport != nil { // a caller that has taken over dialing or TLS opts out of the checks if t, ok := c.Transport.(*http.Transport); ok && t.DialContext == nil && t.DialTLSContext == nil { From 4608cda997aad09d9354b2f1305ed04177a23bd2 Mon Sep 17 00:00:00 2001 From: Aircg <64237451+cgair@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:42:05 +0800 Subject: [PATCH 418/418] mcp: retire only the subscriptions a listen registered (#1275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit *Narrowed after review — see the discussion below. The earlier revision changed the registry keying; this one changes only the teardown.* ### The problem `subscriptionsListen` registers a session according to what the listen asked for: ```go if allowed.ToolsListChanged { s.toolChangeSubscriptions[req.Session] = requestID } if allowed.PromptsListChanged { s.promptChangeSubscriptions[req.Session] = requestID } if allowed.ResourcesListChanged { s.resourceChangeSubscriptions[req.Session] = requestID } ``` Its deferred cleanup does not: ```go delete(s.toolChangeSubscriptions, req.Session) delete(s.promptChangeSubscriptions, req.Session) delete(s.resourceChangeSubscriptions, req.Session) ``` It runs whether or not this listen ever wrote those registries. A session can hold several listens at once, so the first one to unwind takes the others' registrations with it, and those streams stay open while receiving nothing. The scope is the persistent-session transports, stdio and in-memory. Over streamable HTTP the registries cannot collide: a stateful handler refuses SEP-2575 requests outright with `CodeUnsupportedProtocolVersion`, and a stateless one builds a temporary session per POST and closes it when the request ends. Reachable through this SDK's own client, on stdio: 1. `Client.Connect` opens the auto-listen for list-changed notifications. 2. `ClientSession.Subscribe(uri)` opens a second listen, carrying only a resource subscription. 3. `ClientSession.Unsubscribe(uri)` retires that second listen. 4. Its cleanup deletes `toolChangeSubscriptions[session]`, which belongs to the auto-listen. 5. The auto-listen is still open and never receives `tools/list_changed` again. ### How I pinned it down A diagnostic run over the three states, reading the registries and `ServerSession.listenIDs` directly: ``` after connect toolSub=2 promptSub=2 resSub[r1]=false listenIDs=[{2}] after Subscribe toolSub=2 promptSub=2 resSub[r1]=true listenIDs=[{2} {3}] after Unsubscribe toolSub= promptSub= resSub[r1]=false listenIDs=[{2}] ``` Line 2 rules out the second listen having overwritten the entry: `toolSub` is still 2, listen 3 never wrote that registry. Line 3 rules out the client cancelling the wrong stream: `listenIDs` still holds listen 2, so the auto-listen is alive on the server, parked on `<-ctx.Done()`. What changed is only that its registration is gone, deleted by listen 3's teardown. ### The change Guard each delete with the condition that guarded the registration, so a listen retires what it registered and nothing else. ### Validation Two tests, next to the existing `TestSubscriptionsListen_*` ones and reusing their fixtures. Against `826e653` without the change: ``` --- FAIL: TestSubscriptionsListen_TeardownKeepsOtherListens (5.01s) mcp_test.go:3442: timed out waiting for "tool" --- PASS: TestSubscriptionsListen_TeardownRetiresOwnRegistration (0.01s) ``` The second passes before and after on purpose: it asserts that the only listen on a session does still get retired, so that the guards cannot be tightened into a registry leak. The eight pre-existing `TestSubscriptionsListen_*` tests pass in both runs, and all ten pass with the change. Run on the same commit, before and after: `go test ./...` and `go test -race ./...` ok with no races, `gofmt -l .`, `go vet ./...` and `staticcheck v0.6.1` clean. Also `go test ./...` on Go 1.25.0 and 1.26.8 to match the CI matrix, and `go generate ./internal/readme ./internal/docs` produces no diff. Environment: Intel Core i5-1038NG7, macOS 14. ### Known limitations Two cases are outside this change, both needing two listens on one session that overlap: - Two listens opting in to the same notification type. `allowed.ToolsListChanged` is true for both, so the registration overwrites and this teardown still retires the survivor's entry. - Two listens subscribed to the same URI. `resourceSubscriptions[uri][session]` overwrites the same way, and the deferred `unsubscribe` deletes it. Neither is reachable from this SDK's client — `Subscribe` dedups per URI and there is no exported way to open a second list-changed listen — so both require a non-Go client on stdio. I have tests for them and am happy to open a separate issue if you want them tracked. --- AI assistance was used for reading through the code, the diagnostic and regression tests, and running the verification above. The root cause analysis and the design decisions are mine, and I can explain every line without it. Co-authored-by: Guglielmo Colombo --- mcp/mcp_test.go | 153 ++++++++++++++++++++++++++++++++++++++++++++++++ mcp/server.go | 15 ++++- 2 files changed, 165 insertions(+), 3 deletions(-) diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index bb76c1c..7b10b30 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -3363,6 +3363,159 @@ func TestSubscriptionsListen_DisconnectScrubsMaps(t *testing.T) { } } +// newSubListenSubscribeServer is newSubListenServer with a resource and the +// handlers that make the server advertise resources.subscribe, so that a +// listen can carry a resource subscription. +func newSubListenSubscribeServer() *Server { + s := NewServer(testImpl, &ServerOptions{ + SubscribeHandler: func(context.Context, *SubscribeRequest) error { return nil }, + UnsubscribeHandler: func(context.Context, *UnsubscribeRequest) error { return nil }, + }) + AddTool(s, &Tool{Name: "t1"}, sayHi) + s.AddPrompt(&Prompt{Name: "p1"}, nil) + s.AddResource(&Resource{Name: "r1", URI: "file:///r1"}, nil) + return s +} + +func waitSubListenEvent(t *testing.T, events chan subListenEvent, kind string) subListenEvent { + t.Helper() + select { + case e := <-events: + if e.kind != kind { + t.Fatalf("got event %q, want %q", e.kind, kind) + } + return e + case <-time.After(5 * time.Second): + t.Fatalf("timed out waiting for %q", kind) + return subListenEvent{} + } +} + +// TestSubscriptionsListen_TeardownKeepsOtherListens verifies that unwinding +// one listen leaves the other listens on the same session registered. +// Unsubscribe tears down the listen that Subscribe opened for the resource; +// the auto-listen opened by Connect must keep delivering list-changed +// notifications. +func TestSubscriptionsListen_TeardownKeepsOtherListens(t *testing.T) { + events := make(chan subListenEvent, 16) + server := newSubListenSubscribeServer() + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + defer ss.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cs, err := newSubListenClient(events).Connect(ctx, ct, + &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer cs.Close() + + autoListen := waitSubListenEvent(t, events, "ack") + + if err := cs.Subscribe(ctx, &SubscribeParams{URI: "file:///r1"}); err != nil { + t.Fatalf("Subscribe: %v", err) + } + resourceListen := waitSubListenEvent(t, events, "ack") + if resourceListen.id == autoListen.id { + t.Fatalf("Subscribe reused subscription ID %s", autoListen.id) + } + + if err := cs.Unsubscribe(ctx, &UnsubscribeParams{URI: "file:///r1"}); err != nil { + t.Fatalf("Unsubscribe: %v", err) + } + // Wait for the server to unwind that listen, observed through a registry + // the auto-listen does not appear in. + waitUntil(t, 5*time.Second, "resource listen to unwind", func() bool { + server.mu.Lock() + defer server.mu.Unlock() + _, ok := server.resourceSubscriptions["file:///r1"] + return !ok + }) + + AddTool(server, &Tool{Name: "t2"}, sayHi) + if got := waitSubListenEvent(t, events, "tool"); got.id != autoListen.id { + t.Errorf("tool notification id = %s, want auto-listen %s", got.id, autoListen.id) + } +} + +// TestSubscriptionsListen_TeardownRetiresOwnRegistration verifies that a +// listen still retires what it registered, so that per-listen tracking does +// not turn into a leak. +func TestSubscriptionsListen_TeardownRetiresOwnRegistration(t *testing.T) { + events := make(chan subListenEvent, 8) + server := newSubListenServer() + + ct, st := NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + defer ss.Close() + + // A client with no list-changed handlers does not auto-listen, so the + // listen opened below is the session's only one. + c := NewClient(testImpl, nil) + c.AddReceivingMiddleware(func(next MethodHandler) MethodHandler { + return func(ctx context.Context, method string, req Request) (Result, error) { + if method == notificationSubscriptionsAck { + events <- subListenEvent{"ack", ""} + } + return next(ctx, method, req) + } + }) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer cs.Close() + + listenCtx, cancelListen := context.WithCancel(context.Background()) + defer cancelListen() + if err := cs.subscriptionsListen(listenCtx, &SubscriptionsListenParams{ + Notifications: &NotificationSubscriptions{ToolsListChanged: true, PromptsListChanged: true}, + }); err != nil { + t.Fatalf("listen: %v", err) + } + waitSubListenEvent(t, events, "ack") + + server.mu.Lock() + _, inTool := server.toolChangeSubscriptions[ss] + _, inPrompt := server.promptChangeSubscriptions[ss] + server.mu.Unlock() + if !inTool || !inPrompt { + t.Fatal("listen not registered") + } + + cancelListen() + waitUntil(t, 5*time.Second, "registrations to be retired", func() bool { + server.mu.Lock() + defer server.mu.Unlock() + _, inTool := server.toolChangeSubscriptions[ss] + _, inPrompt := server.promptChangeSubscriptions[ss] + return !inTool && !inPrompt + }) +} + +// waitUntil polls cond until it reports true, failing the test after timeout. +func waitUntil(t *testing.T, timeout time.Duration, what string, cond func() bool) { + t.Helper() + deadline := time.Now().Add(timeout) + for !cond() { + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for %s", what) + } + time.Sleep(10 * time.Millisecond) + } +} + // TestSubscriptionsListen_RespectsServerCapabilities verifies that during // Connect the client only opens a SEP-2575 subscriptions/listen stream for the // change notifications the server advertised during capability negotiation. diff --git a/mcp/server.go b/mcp/server.go index b2f89f4..569abab 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1276,10 +1276,19 @@ func (s *Server) subscriptionsListen(ctx context.Context, req *SubscriptionsList } s.mu.Unlock() defer func() { + // Retire only what this listen registered above: a session can hold + // several listens at once, and deleting by session alone retires a + // registration belonging to a listen that is still open. s.mu.Lock() - delete(s.toolChangeSubscriptions, req.Session) - delete(s.promptChangeSubscriptions, req.Session) - delete(s.resourceChangeSubscriptions, req.Session) + if allowed.ToolsListChanged { + delete(s.toolChangeSubscriptions, req.Session) + } + if allowed.PromptsListChanged { + delete(s.promptChangeSubscriptions, req.Session) + } + if allowed.ResourcesListChanged { + delete(s.resourceChangeSubscriptions, req.Session) + } s.mu.Unlock() }()