Repository: modelcontextprotocol/go-sdk

Issue:
SEP-1034: elicitation default values.
Our elicitation schemas are intentionally decoupled from their JSON-RPC implementation. However, when validating the schema, we should apply defaults.

Unlike tool handling, I'm not sure we should inject these defaults into the Content map.

This implements [SEP-1034](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1034).

Hints from issue comments:
@findleyr: If someone wanted to help move this along this week, here are TODOs:

- Investigate how other SDKs treat this.
- Use `jsonschema.Resolved.ApplyDefaults` when validating the schema
- Add tests.@markus-kusano: Open question: Do we need to handle elicitation defaults on the server side, or just on the client, or both? I think we need to just do it in 1 location (either on the client or the server). But! If we are expected to talk to clients/server that are not using the Go SDK then we might want to make sure to use the defaults in both, rather than assuming we are talking to a Go MCP SDK client and server

Why? Elicitation requests are a request for input from the server to the client. The schema on the elicitation request from the server to the client contains the expected format of the response, as well as the defaults. If the client chooses to not include anything in the response for fields with defaults then the default value should be used. The default can be applied eon the client side (before sending the response back), or on the server side (before the values are used).

The TL;DR flow for this is:

1. Server sends elicitation request with schema with optional defaults
2. Client decides how to respond to the request, potentially not including values for some parts of the schema
3. [Option A] The client sets the fields in the response to their default value, sends the response back to the server
4. [Option B] The server sets fields on the response to their default value after getting the response

We only need one of [Option A] or [Option B] since they are redundant. OTOH, we might want to harden the server to also use the defaults, e.g., this would make the system work if we are talking to a client that is not using the Go implementation@findleyr: @markus-kusano I'm hesitant to do it on the server side on the basis of typescript _not_ doing this, and preserving information: the handler should see exactly which information the client submitted.

A middle ground might be to simply apply defaults on the server side before validation. That way, if the default is "6", with min=4 and max=8, and the client doesn't submit the field, the content is still considered valid. WDYT?@findleyr: BTW, I actually noticed that we weren't doing any validation server side, and wondered if this is just the practice. It's not: the typescript SDK validates on both caller and callee.

I filed https://github.com/modelcontextprotocol/go-sdk/issues/648. Fixing this for eliciation will be one half of that.

Solution patch:
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/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/server.go b/mcp/server.go
index 4a7bc89..d1d6a1d 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
@@ -1015,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.
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 == '.'
+}



Test patch:
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_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/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)
+				}
+			})
+		}
+
+	})
+
+}