From 5a69eeb5bfeaa9166f503ee491b7e6dfbdcf55ce Mon Sep 17 00:00:00 2001 From: syf2211 Date: Sun, 28 Jun 2026 18:08:40 +0000 Subject: [PATCH 1/2] fix(tools): omit tool annotations from JSON when unset Add ToolAnnotation.HasAny() and only include the annotations field in Tool.MarshalJSON when at least one annotation value is present. Introduce WithoutDefaultAnnotations() so callers can opt out of the default hint pointers initialized by NewTool and omit annotations from tools/list output when they have no annotation metadata. Fixes #710 --- mcp/tools.go | 22 +++++++++++- mcp/tools_additional_test.go | 67 ++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/mcp/tools.go b/mcp/tools.go index 31f375f7e..3ab8f6ed4 100644 --- a/mcp/tools.go +++ b/mcp/tools.go @@ -643,7 +643,9 @@ func (t Tool) MarshalJSON() ([]byte, error) { m["outputSchema"] = t.OutputSchema } - m["annotations"] = t.Annotations + if t.Annotations.HasAny() { + m["annotations"] = t.Annotations + } if t.DeferLoading { m["defer_loading"] = t.DeferLoading @@ -777,6 +779,15 @@ type ToolAnnotation struct { OpenWorldHint *bool `json:"openWorldHint,omitempty"` } +// HasAny reports whether any annotation field was explicitly set. +func (a ToolAnnotation) HasAny() bool { + return a.Title != "" || + a.ReadOnlyHint != nil || + a.DestructiveHint != nil || + a.IdempotentHint != nil || + a.OpenWorldHint != nil +} + // ToolOption is a function that configures a Tool. // It provides a flexible way to set various properties of a Tool using the functional options pattern. type ToolOption func(*Tool) @@ -944,6 +955,15 @@ func WithRawOutputSchema(schema json.RawMessage) ToolOption { } } +// WithoutDefaultAnnotations clears the default annotation hints initialized by +// NewTool so the annotations field is omitted from JSON unless later options +// set annotation values explicitly. +func WithoutDefaultAnnotations() ToolOption { + return func(t *Tool) { + t.Annotations = ToolAnnotation{} + } +} + // WithToolAnnotation adds optional hints about the Tool. func WithToolAnnotation(annotation ToolAnnotation) ToolOption { return func(t *Tool) { diff --git a/mcp/tools_additional_test.go b/mcp/tools_additional_test.go index 4ce723d9e..ef56e55f9 100644 --- a/mcp/tools_additional_test.go +++ b/mcp/tools_additional_test.go @@ -294,6 +294,73 @@ func TestToolAnnotations(t *testing.T) { }) } +func TestToolAnnotationsMarshalJSON(t *testing.T) { + t.Run("NewTool includes default annotations", func(t *testing.T) { + tool := NewTool("test", WithDescription("desc")) + + data, err := json.Marshal(tool) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(data, &parsed)) + annotations, ok := parsed["annotations"].(map[string]any) + require.True(t, ok) + assert.Equal(t, false, annotations["readOnlyHint"]) + assert.Equal(t, true, annotations["destructiveHint"]) + }) + + t.Run("WithoutDefaultAnnotations omits annotations field", func(t *testing.T) { + tool := NewTool("test", + WithoutDefaultAnnotations(), + WithDescription("desc"), + ) + + data, err := json.Marshal(tool) + require.NoError(t, err) + assert.NotContains(t, string(data), `"annotations"`) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(data, &parsed)) + _, hasAnnotations := parsed["annotations"] + assert.False(t, hasAnnotations) + }) + + t.Run("WithoutDefaultAnnotations with explicit hint includes annotations", func(t *testing.T) { + tool := NewTool("test", + WithoutDefaultAnnotations(), + WithReadOnlyHintAnnotation(true), + ) + + data, err := json.Marshal(tool) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(data, &parsed)) + annotations, ok := parsed["annotations"].(map[string]any) + require.True(t, ok) + assert.Equal(t, true, annotations["readOnlyHint"]) + assert.NotContains(t, annotations, "destructiveHint") + }) + + t.Run("NewToolWithRawSchema omits annotations when unset", func(t *testing.T) { + rawSchema := json.RawMessage(`{"type":"object","properties":{}}`) + tool := NewToolWithRawSchema("raw-tool", "Raw tool", rawSchema) + + data, err := json.Marshal(tool) + require.NoError(t, err) + assert.NotContains(t, string(data), `"annotations"`) + }) + + t.Run("zero-value ToolAnnotation HasAny is false", func(t *testing.T) { + assert.False(t, ToolAnnotation{}.HasAny()) + }) + + t.Run("ToolAnnotation HasAny detects explicit fields", func(t *testing.T) { + assert.True(t, ToolAnnotation{Title: "x"}.HasAny()) + assert.True(t, ToolAnnotation{ReadOnlyHint: ToBoolPtr(false)}.HasAny()) + }) +} + // Test Tool with both InputSchema and OutputSchema func TestToolWithBothSchemas(t *testing.T) { From 8816266b224697c405a19af09c9d76627e3012b0 Mon Sep 17 00:00:00 2001 From: syf2211 Date: Mon, 29 Jun 2026 18:13:41 +0000 Subject: [PATCH 2/2] test(tools): convert annotation marshal tests to table-driven style Address review feedback on PR #911 by using a shared test matrix for annotation JSON marshaling cases. --- mcp/tools_additional_test.go | 131 ++++++++++++++++++++--------------- 1 file changed, 77 insertions(+), 54 deletions(-) diff --git a/mcp/tools_additional_test.go b/mcp/tools_additional_test.go index ef56e55f9..d84f314bc 100644 --- a/mcp/tools_additional_test.go +++ b/mcp/tools_additional_test.go @@ -295,61 +295,84 @@ func TestToolAnnotations(t *testing.T) { } func TestToolAnnotationsMarshalJSON(t *testing.T) { - t.Run("NewTool includes default annotations", func(t *testing.T) { - tool := NewTool("test", WithDescription("desc")) - - data, err := json.Marshal(tool) - require.NoError(t, err) - - var parsed map[string]any - require.NoError(t, json.Unmarshal(data, &parsed)) - annotations, ok := parsed["annotations"].(map[string]any) - require.True(t, ok) - assert.Equal(t, false, annotations["readOnlyHint"]) - assert.Equal(t, true, annotations["destructiveHint"]) - }) - - t.Run("WithoutDefaultAnnotations omits annotations field", func(t *testing.T) { - tool := NewTool("test", - WithoutDefaultAnnotations(), - WithDescription("desc"), - ) - - data, err := json.Marshal(tool) - require.NoError(t, err) - assert.NotContains(t, string(data), `"annotations"`) - - var parsed map[string]any - require.NoError(t, json.Unmarshal(data, &parsed)) - _, hasAnnotations := parsed["annotations"] - assert.False(t, hasAnnotations) - }) - - t.Run("WithoutDefaultAnnotations with explicit hint includes annotations", func(t *testing.T) { - tool := NewTool("test", - WithoutDefaultAnnotations(), - WithReadOnlyHintAnnotation(true), - ) - - data, err := json.Marshal(tool) - require.NoError(t, err) - - var parsed map[string]any - require.NoError(t, json.Unmarshal(data, &parsed)) - annotations, ok := parsed["annotations"].(map[string]any) - require.True(t, ok) - assert.Equal(t, true, annotations["readOnlyHint"]) - assert.NotContains(t, annotations, "destructiveHint") - }) - - t.Run("NewToolWithRawSchema omits annotations when unset", func(t *testing.T) { - rawSchema := json.RawMessage(`{"type":"object","properties":{}}`) - tool := NewToolWithRawSchema("raw-tool", "Raw tool", rawSchema) + rawSchema := json.RawMessage(`{"type":"object","properties":{}}`) + + tests := []struct { + name string + tool Tool + wantAnnotations bool + wantContains string + wantNotContains string + checkAnnotations func(t *testing.T, annotations map[string]any) + }{ + { + name: "NewTool includes default annotations", + tool: NewTool("test", WithDescription("desc")), + wantAnnotations: true, + checkAnnotations: func(t *testing.T, annotations map[string]any) { + t.Helper() + assert.Equal(t, false, annotations["readOnlyHint"]) + assert.Equal(t, true, annotations["destructiveHint"]) + }, + }, + { + name: "WithoutDefaultAnnotations omits annotations field", + tool: NewTool("test", + WithoutDefaultAnnotations(), + WithDescription("desc"), + ), + wantAnnotations: false, + wantNotContains: `"annotations"`, + }, + { + name: "WithoutDefaultAnnotations with explicit hint includes annotations", + tool: NewTool("test", + WithoutDefaultAnnotations(), + WithReadOnlyHintAnnotation(true), + ), + wantAnnotations: true, + checkAnnotations: func(t *testing.T, annotations map[string]any) { + t.Helper() + assert.Equal(t, true, annotations["readOnlyHint"]) + assert.NotContains(t, annotations, "destructiveHint") + }, + }, + { + name: "NewToolWithRawSchema omits annotations when unset", + tool: NewToolWithRawSchema("raw-tool", "Raw tool", rawSchema), + wantAnnotations: false, + wantNotContains: `"annotations"`, + }, + } - data, err := json.Marshal(tool) - require.NoError(t, err) - assert.NotContains(t, string(data), `"annotations"`) - }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(tt.tool) + require.NoError(t, err) + + if tt.wantContains != "" { + assert.Contains(t, string(data), tt.wantContains) + } + if tt.wantNotContains != "" { + assert.NotContains(t, string(data), tt.wantNotContains) + } + + var parsed map[string]any + require.NoError(t, json.Unmarshal(data, &parsed)) + + annotations, ok := parsed["annotations"].(map[string]any) + if tt.wantAnnotations { + require.True(t, ok) + if tt.checkAnnotations != nil { + tt.checkAnnotations(t, annotations) + } + return + } + + _, hasAnnotations := parsed["annotations"] + assert.False(t, hasAnnotations) + }) + } t.Run("zero-value ToolAnnotation HasAny is false", func(t *testing.T) { assert.False(t, ToolAnnotation{}.HasAny())