Skip to content

Commit 7577297

Browse files
authored
feat: add support for arbitrary metadata in Tool descriptor (#35)
1 parent 8ba14c7 commit 7577297

File tree

3 files changed

+134
-0
lines changed

3 files changed

+134
-0
lines changed

mcp/types.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@ type Tool struct {
120120
// OutputSchema optionally declares the structure of structuredContent
121121
// in CallToolResult for this tool.
122122
OutputSchema *ToolOutputSchema `json:"outputSchema,omitempty"`
123+
// Meta carries arbitrary metadata exposed via the `_meta` field in the MCP spec.
124+
// Keys must follow the naming constraints described in the spec.
125+
Meta map[string]any `json:"_meta,omitempty"`
123126
}
124127

125128
// ToolInputSchema is a JSON-schema-like description of tool input.

mcpservice/static_tools.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ func NewTool[A any](name string, fn func(ctx context.Context, session sessions.S
6363
Name: name,
6464
Description: cfg.description,
6565
InputSchema: input,
66+
Meta: cloneMeta(cfg.meta),
6667
}
6768

6869
handler := func(ctx context.Context, session sessions.Session, req *mcp.CallToolRequestReceived) (*mcp.CallToolResult, error) {
@@ -115,6 +116,7 @@ type ToolOption func(*toolConfig)
115116
type toolConfig struct {
116117
description string
117118
allowAdditionalProperties bool // default false (strict)
119+
meta map[string]any
118120
}
119121

120122
// WithToolDescription sets the tool description used in listings.
@@ -129,6 +131,25 @@ func WithToolAllowAdditionalProperties(allow bool) ToolOption {
129131
return func(c *toolConfig) { c.allowAdditionalProperties = allow }
130132
}
131133

134+
// WithToolMeta attaches arbitrary metadata to the tool descriptor that will be
135+
// surfaced under the `_meta` field when the tool is listed. Multiple
136+
// invocations merge keys (later values overwrite earlier ones). A nil or empty
137+
// map is ignored. The map is shallow-copied at option application time so the
138+
// caller can mutate their original without affecting the descriptor.
139+
func WithToolMeta(meta map[string]any) ToolOption {
140+
return func(c *toolConfig) {
141+
if len(meta) == 0 {
142+
return
143+
}
144+
if c.meta == nil {
145+
c.meta = make(map[string]any, len(meta))
146+
}
147+
for k, v := range meta {
148+
c.meta[k] = v
149+
}
150+
}
151+
}
152+
132153
// NewTool constructs a StaticTool from a typed args struct A. It:
133154
// - Reflects a JSON Schema from A using invopop/jsonschema
134155
// - Down-converts it to MCP's simplified ToolInputSchema
@@ -148,6 +169,7 @@ func NewToolWithOutput[A, O any](name string, fn func(ctx context.Context, sessi
148169
Description: cfg.description,
149170
InputSchema: input,
150171
OutputSchema: &outSchema,
172+
Meta: cloneMeta(cfg.meta),
151173
}
152174
handler := func(ctx context.Context, session sessions.Session, req *mcp.CallToolRequestReceived) (*mcp.CallToolResult, error) {
153175
var a A

mcpservice/tool_metadata_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package mcpservice
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"testing"
7+
8+
"github.com/ggoodman/mcp-server-go/mcp"
9+
"github.com/ggoodman/mcp-server-go/sessions"
10+
)
11+
12+
// simple no-op session implementation for tests (use zero value) - if a real one exists in tests, adapt.
13+
14+
type nopSession struct{ sessions.Session }
15+
16+
type emptyArgs struct{}
17+
type emptyArgs2 struct{}
18+
19+
func TestNewTool_WithMeta_IncludedInSnapshot(t *testing.T) {
20+
tool := NewTool[emptyArgs]("echo", func(ctx context.Context, s sessions.Session, w ToolResponseWriter, r *ToolRequest[emptyArgs]) error {
21+
w.AppendText("ok")
22+
return nil
23+
}, WithToolDescription("echo tool"), WithToolMeta(map[string]any{"category": "test", "version": 2}))
24+
25+
c := NewToolsContainer(tool)
26+
list := c.Snapshot()
27+
if len(list) != 1 {
28+
t.Fatalf("expected 1 tool, got %d", len(list))
29+
}
30+
if list[0].Meta == nil {
31+
b, _ := json.Marshal(list[0])
32+
t.Fatalf("expected meta, got nil. tool=%s", string(b))
33+
}
34+
if got := list[0].Meta["category"]; got != "test" {
35+
b, _ := json.Marshal(list[0].Meta)
36+
t.Fatalf("expected category 'test', got %v meta=%s", got, string(b))
37+
}
38+
if got := list[0].Meta["version"]; got != 2 {
39+
b, _ := json.Marshal(list[0].Meta)
40+
t.Fatalf("expected version 2, got %v meta=%s", got, string(b))
41+
}
42+
}
43+
44+
func TestNewTool_WithOutput_MetaIncluded(t *testing.T) {
45+
// tool with output schema
46+
type out struct {
47+
Value string `json:"value"`
48+
}
49+
tool := NewToolWithOutput[emptyArgs2, out]("produce", func(ctx context.Context, s sessions.Session, w ToolResponseWriterTyped[out], r *ToolRequest[emptyArgs2]) error {
50+
w.SetStructured(out{Value: "hi"})
51+
return nil
52+
}, WithToolMeta(map[string]any{"kind": "producer"}))
53+
54+
c := NewToolsContainer(tool)
55+
page, err := c.ListTools(context.Background(), nopSession{}, nil)
56+
if err != nil {
57+
t.Fatalf("ListTools error: %v", err)
58+
}
59+
if len(page.Items) != 1 {
60+
b, _ := json.Marshal(page.Items)
61+
t.Fatalf("expected 1 tool, got %d: %s", len(page.Items), string(b))
62+
}
63+
if page.Items[0].Meta == nil || page.Items[0].Meta["kind"] != "producer" {
64+
b, _ := json.Marshal(page.Items[0])
65+
t.Fatalf("expected meta kind=producer, got %s", string(b))
66+
}
67+
if page.Items[0].OutputSchema == nil || page.Items[0].OutputSchema.Type != "object" {
68+
b, _ := json.Marshal(page.Items[0].OutputSchema)
69+
t.Fatalf("expected output schema object, got %s", string(b))
70+
}
71+
}
72+
73+
func TestNewTool_NoMeta_OmitsField(t *testing.T) {
74+
tool := NewTool[emptyArgs]("plain", func(ctx context.Context, s sessions.Session, w ToolResponseWriter, r *ToolRequest[emptyArgs]) error {
75+
return nil
76+
})
77+
c := NewToolsContainer(tool)
78+
list := c.Snapshot()
79+
if len(list) != 1 {
80+
panic("expected 1 tool")
81+
}
82+
if list[0].Meta != nil {
83+
b, _ := json.Marshal(list[0])
84+
// Should be omitted when empty
85+
var raw map[string]any
86+
_ = json.Unmarshal(b, &raw)
87+
if _, ok := raw["_meta"]; ok {
88+
// meta should not be present at all
89+
panic("_meta should be omitted for empty meta")
90+
}
91+
}
92+
}
93+
94+
// Ensure tool call result meta unaffected by tool descriptor meta.
95+
func TestToolDescriptorMeta_DoesNotLeakToCallResult(t *testing.T) {
96+
tool := NewTool[emptyArgs]("echo", func(ctx context.Context, s sessions.Session, w ToolResponseWriter, r *ToolRequest[emptyArgs]) error {
97+
w.AppendText("hello")
98+
return nil
99+
}, WithToolMeta(map[string]any{"tag": "descriptor"}))
100+
c := NewToolsContainer(tool)
101+
res, err := c.Call(context.Background(), nopSession{}, &mcp.CallToolRequestReceived{Name: "echo"})
102+
if err != nil {
103+
t.Fatalf("call error: %v", err)
104+
}
105+
if res.Meta != nil { // CallToolResult inherits from BaseMetadata
106+
b, _ := json.Marshal(res)
107+
t.Fatalf("expected no meta on result (unless handler sets), got %s", string(b))
108+
}
109+
}

0 commit comments

Comments
 (0)