Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/rough_edges.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 6 additions & 0 deletions internal/docs/rough_edges.src.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 3 additions & 0 deletions mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
package mcp

import (
"bytes"
"context"
"encoding/json"
"log"
"log/slog"
"slices"
"strings"
"testing"
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions mcp/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions mcp/tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"context"
"encoding/json"
"fmt"
"strings"

"github.com/google/jsonschema-go/jsonschema"
)
Expand Down Expand Up @@ -101,3 +102,26 @@ func applySchema(data json.RawMessage, resolved *jsonschema.Resolved) (json.RawM
}
return data, nil
}

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))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

feel free to ignore: should we include the tool name here to make it easier for debugging? i can also see this being a problem since it is too long. i could see this error message being annoying if the user could have multiple tool names and they don't know which one is too long

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tool name is included at the call site.
An arguable style choice, but I mildly prefer having validators only describe the specific error, because this then leave it up to the caller for how to present that error. For example the caller could do "Adding tool %q: %v"

}
var invalidChars []string
seen := make(map[rune]bool)
for _, r := range name {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' || r == '.') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: a switch would be clearer

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, checked the standard library and we don't use switches for this sort of check anywhere: we either use a conditional or boolean array.

Factored out to an (inlined) helper function, which I think makes it clearer. WDYT?

if !seen[r] {
invalidChars = append(invalidChars, fmt.Sprintf("%q", string(r)))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use maps.Keys(seen) at the end.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't: need to report characters in the order they occur. Want to be consistent with other SDKs.

seen[r] = true
}
}
}
if len(invalidChars) > 0 {
return fmt.Errorf("tool name contains invalid characters: %s", strings.Join(invalidChars, ", "))
}
return nil
}
50 changes: 50 additions & 0 deletions mcp/tool_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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", "[email protected]", `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)
}
})
}

})

}
Loading