Skip to content

Commit 1943780

Browse files
authored
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
1 parent 9ef2b27 commit 1943780

File tree

7 files changed

+168
-0
lines changed

7 files changed

+168
-0
lines changed

docs/rough_edges.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,9 @@ v2.
1010
of the SDK where event persistence and delivery were combined.
1111

1212
**Workaround**: `Open` may be implemented as a no-op.
13+
14+
- Enforcing valid tool names: with
15+
[SEP-986](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/986)
16+
landing after the SDK was at v1, we missed an opportunity to panic on invalid
17+
tool names. Instead, we have to simply produce an error log. In v2, we should
18+
panic.

internal/docs/rough_edges.src.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,9 @@ v2.
99
of the SDK where event persistence and delivery were combined.
1010

1111
**Workaround**: `Open` may be implemented as a no-op.
12+
13+
- Enforcing valid tool names: with
14+
[SEP-986](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/986)
15+
landing after the SDK was at v1, we missed an opportunity to panic on invalid
16+
tool names. Instead, we have to simply produce an error log. In v2, we should
17+
panic.

mcp/server.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,9 @@ func (s *Server) RemovePrompts(names ...string) {
191191
// Most users should use the top-level function [AddTool], which handles all these
192192
// responsibilities.
193193
func (s *Server) AddTool(t *Tool, h ToolHandler) {
194+
if err := validateToolName(t.Name); err != nil {
195+
s.opts.Logger.Error(fmt.Sprintf("AddTool: invalid tool name %q: %v", t.Name, err))
196+
}
194197
if t.InputSchema == nil {
195198
// This prevents the tool author from forgetting to write a schema where
196199
// one should be provided. If we papered over this by supplying the empty

mcp/server_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
package mcp
66

77
import (
8+
"bytes"
89
"context"
910
"encoding/json"
1011
"log"
12+
"log/slog"
1113
"slices"
1214
"strings"
1315
"testing"
@@ -490,6 +492,69 @@ func TestAddTool(t *testing.T) {
490492
}
491493
}
492494

495+
func TestAddToolNameValidation(t *testing.T) {
496+
tests := []struct {
497+
label string
498+
name string
499+
wantLogContaining string
500+
}{
501+
{
502+
label: "empty name",
503+
name: "",
504+
wantLogContaining: `tool name cannot be empty`,
505+
},
506+
{
507+
label: "long name",
508+
name: strings.Repeat("a", 129),
509+
wantLogContaining: "exceeds maximum length of 128 characters",
510+
},
511+
{
512+
label: "name with spaces",
513+
name: "get user profile",
514+
wantLogContaining: `tool name contains invalid characters: \" \"`,
515+
},
516+
{
517+
label: "name with multiple invalid chars",
518+
name: "user name@domain,com",
519+
wantLogContaining: `tool name contains invalid characters: \" \", \"@\", \",\"`,
520+
},
521+
{
522+
label: "name with unicode",
523+
name: "tool-ñame",
524+
wantLogContaining: `tool name contains invalid characters: \"ñ\"`,
525+
},
526+
{
527+
label: "valid name",
528+
name: "valid-tool_name.123",
529+
wantLogContaining: "", // No log expected
530+
},
531+
}
532+
for _, test := range tests {
533+
t.Run(test.label, func(t *testing.T) {
534+
var buf bytes.Buffer
535+
s := NewServer(testImpl, &ServerOptions{
536+
Logger: slog.New(slog.NewTextHandler(&buf, nil)),
537+
})
538+
539+
// Use the generic AddTool as it also calls validateToolName.
540+
AddTool(s, &Tool{Name: test.name}, func(context.Context, *CallToolRequest, any) (*CallToolResult, any, error) {
541+
return nil, nil, nil
542+
})
543+
544+
logOutput := buf.String()
545+
if test.wantLogContaining != "" {
546+
if !strings.Contains(logOutput, test.wantLogContaining) {
547+
t.Errorf("log output =\n%s\nwant containing %q", logOutput, test.wantLogContaining)
548+
}
549+
} else {
550+
if logOutput != "" {
551+
t.Errorf("expected empty log output, got %q", logOutput)
552+
}
553+
}
554+
})
555+
}
556+
}
557+
493558
type schema = jsonschema.Schema
494559

495560
func testToolForSchema[In, Out any](t *testing.T, tool *Tool, in string, out Out, wantIn, wantOut any, wantErrContaining string) {

mcp/shared.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,14 @@ const (
3434
// It is the version that the client sends in the initialization request, and
3535
// the default version used by the server.
3636
latestProtocolVersion = protocolVersion20250618
37+
protocolVersion20251125 = "2025-11-25" // not yet released
3738
protocolVersion20250618 = "2025-06-18"
3839
protocolVersion20250326 = "2025-03-26"
3940
protocolVersion20241105 = "2024-11-05"
4041
)
4142

4243
var supportedProtocolVersions = []string{
44+
protocolVersion20251125,
4345
protocolVersion20250618,
4446
protocolVersion20250326,
4547
protocolVersion20241105,

mcp/tool.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"context"
99
"encoding/json"
1010
"fmt"
11+
"strings"
1112

1213
"github.com/google/jsonschema-go/jsonschema"
1314
)
@@ -101,3 +102,38 @@ func applySchema(data json.RawMessage, resolved *jsonschema.Resolved) (json.RawM
101102
}
102103
return data, nil
103104
}
105+
106+
// validateToolName checks whether name is a valid tool name, reporting a
107+
// non-nil error if not.
108+
func validateToolName(name string) error {
109+
if name == "" {
110+
return fmt.Errorf("tool name cannot be empty")
111+
}
112+
if len(name) > 128 {
113+
return fmt.Errorf("tool name exceeds maximum length of 128 characters (current: %d)", len(name))
114+
}
115+
// For consistency with other SDKs, report characters in the order the appear
116+
// in the name.
117+
var invalidChars []string
118+
seen := make(map[rune]bool)
119+
for _, r := range name {
120+
if !validToolNameRune(r) {
121+
if !seen[r] {
122+
invalidChars = append(invalidChars, fmt.Sprintf("%q", string(r)))
123+
seen[r] = true
124+
}
125+
}
126+
}
127+
if len(invalidChars) > 0 {
128+
return fmt.Errorf("tool name contains invalid characters: %s", strings.Join(invalidChars, ", "))
129+
}
130+
return nil
131+
}
132+
133+
// validToolNameRune reports whether r is valid within tool names.
134+
func validToolNameRune(r rune) bool {
135+
return (r >= 'a' && r <= 'z') ||
136+
(r >= 'A' && r <= 'Z') ||
137+
(r >= '0' && r <= '9') ||
138+
r == '_' || r == '-' || r == '.'
139+
}

mcp/tool_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,53 @@ func TestToolErrorHandling(t *testing.T) {
145145
}
146146
})
147147
}
148+
149+
func TestValidateToolName(t *testing.T) {
150+
t.Run("valid", func(t *testing.T) {
151+
validTests := []struct {
152+
label string
153+
toolName string
154+
}{
155+
{"simple alphanumeric names", "getUser"},
156+
{"names with underscores", "get_user_profile"},
157+
{"names with dashes", "user-profile-update"},
158+
{"names with dots", "admin.tools.list"},
159+
{"mixed character names", "DATA_EXPORT_v2.1"},
160+
{"single character names", "a"},
161+
{"128 character names", strings.Repeat("a", 128)},
162+
}
163+
for _, test := range validTests {
164+
t.Run(test.label, func(t *testing.T) {
165+
if err := validateToolName(test.toolName); err != nil {
166+
t.Errorf("validateToolName(%q) = %v, want nil", test.toolName, err)
167+
}
168+
})
169+
}
170+
})
171+
172+
t.Run("invalid", func(t *testing.T) {
173+
invalidTests := []struct {
174+
label string
175+
toolName string
176+
wantErrContaining string
177+
}{
178+
{"empty names", "", "tool name cannot be empty"},
179+
{"names longer than 128 characters", strings.Repeat("a", 129), "tool name exceeds maximum length of 128 characters (current: 129)"},
180+
{"names with spaces", "get user profile", `tool name contains invalid characters: " "`},
181+
{"names with commas", "get,user,profile", `tool name contains invalid characters: ","`},
182+
{"names with forward slashes", "user/profile/update", `tool name contains invalid characters: "/"`},
183+
{"names with other special chars", "[email protected]", `tool name contains invalid characters: "@"`},
184+
{"names with multiple invalid chars", "user name@domain,com", `tool name contains invalid characters: " ", "@", ","`},
185+
{"names with unicode characters", "user-ñame", `tool name contains invalid characters: "ñ"`},
186+
}
187+
for _, test := range invalidTests {
188+
t.Run(test.label, func(t *testing.T) {
189+
if err := validateToolName(test.toolName); err == nil || !strings.Contains(err.Error(), test.wantErrContaining) {
190+
t.Errorf("validateToolName(%q) = %v, want error containing %q", test.toolName, err, test.wantErrContaining)
191+
}
192+
})
193+
}
194+
195+
})
196+
197+
}

0 commit comments

Comments
 (0)