Skip to content

Commit 5e8908e

Browse files
authored
Add support for _meta field in tool calls (#68)
* Add support for _meta field in tool calls Implements support for the optional _meta field in CallToolRequest as specified in the MCP specification (2025-11-25). The echo tool now accepts metadata in requests and echoes it back in responses, enabling validation of metadata propagation through MCP clients, proxies, and routers. Common metadata use cases include request tracking (progressToken), client context, debugging, and testing metadata propagation through complex MCP architectures. Fixes #67 * changes from review
1 parent d5b38b5 commit 5e8908e

File tree

4 files changed

+233
-10
lines changed

4 files changed

+233
-10
lines changed

cmd/yardstick-server/README.md

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,10 @@ docker run -p 8080:8080 -e MCP_TRANSPORT=streamable-http -e PORT=8080 ghcr.io/st
7373
```
7474

7575
## Tools
76+
7677
### `echo` Tool
7778

78-
The server exposes a single tool called `echo` with the following specification:
79+
A deterministic echo tool for basic testing and validation. This tool also supports metadata echoing for testing metadata propagation through MCP implementations.
7980

8081
**Input Schema:**
8182
```json
@@ -92,13 +93,77 @@ The server exposes a single tool called `echo` with the following specification:
9293
}
9394
```
9495

95-
**Output:**
96+
**Output (StructuredContent):**
9697
```json
9798
{
9899
"output": "input_string"
99100
}
100101
```
101102

103+
**Metadata Support:**
104+
The `echo` tool accepts and echoes back the optional `_meta` field from tool call requests. Any metadata provided in the request's `_meta` field will be returned in the response's `_meta` field, enabling validation that:
105+
- MCP clients correctly pass metadata in tool calls
106+
- Servers properly return metadata in responses
107+
- Proxies and routers preserve metadata throughout the request/response lifecycle
108+
- Metadata can be used for request tracking, debugging, and observability
109+
110+
When metadata is present, it is also logged to the server's output for debugging purposes.
111+
112+
**Example Request with Metadata:**
113+
```json
114+
{
115+
"jsonrpc": "2.0",
116+
"id": 1,
117+
"method": "tools/call",
118+
"params": {
119+
"name": "echo",
120+
"arguments": {
121+
"input": "test123"
122+
},
123+
"_meta": {
124+
"progressToken": "task123",
125+
"requestId": "req-456",
126+
"clientInfo": "test-client-v1"
127+
}
128+
}
129+
}
130+
```
131+
132+
**Example Response with Metadata:**
133+
```json
134+
{
135+
"jsonrpc": "2.0",
136+
"id": 1,
137+
"result": {
138+
"content": [
139+
{
140+
"type": "text",
141+
"text": "{\"output\":\"test123\"}"
142+
}
143+
],
144+
"_meta": {
145+
"progressToken": "task123",
146+
"requestId": "req-456",
147+
"clientInfo": "test-client-v1"
148+
}
149+
}
150+
}
151+
```
152+
153+
## Metadata Field Support
154+
155+
The `echo` tool supports the optional `_meta` field as specified in the [MCP specification (2025-11-25)](https://modelcontextprotocol.io). The `_meta` field allows clients and servers to attach additional metadata to their interactions without exposing it to the LLM.
156+
157+
**Common use cases:**
158+
- **Request tracking**: Using `progressToken` for progress notifications
159+
- **Client context**: Passing client version, user information, or session data
160+
- **Debugging**: Including trace IDs, debug levels, or diagnostic information
161+
- **Testing**: Validating metadata propagation through complex MCP architectures
162+
163+
**Standard Fields:**
164+
While the `_meta` field accepts any key-value pairs, the MCP specification defines some standard fields:
165+
- `progressToken`: An opaque token for associating progress notifications with requests
166+
102167
## Development
103168

104169
### Running tests

cmd/yardstick-server/integration_test.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,8 @@ func TestEndToEndEchoFunctionality(t *testing.T) {
175175
assert.True(t, result.IsError)
176176
} else {
177177
assert.NoError(t, err)
178-
assert.Nil(t, result) // When successful, result is nil and response contains the data
178+
// Result is nil when no metadata is present (original behavior)
179+
assert.Nil(t, result)
179180
assert.NotNil(t, response)
180181
assert.Equal(t, tc.input, response.Output)
181182
}
@@ -206,6 +207,7 @@ func TestServerStartup(t *testing.T) {
206207

207208
result, response, err := echoHandler(context.Background(), req, params)
208209
assert.NoError(t, err)
210+
// Result is nil when no metadata (original behavior)
209211
assert.Nil(t, result)
210212
assert.NotNil(t, response)
211213
})
@@ -232,8 +234,9 @@ func TestConcurrentEchoRequests(t *testing.T) {
232234
return
233235
}
234236

235-
if result != nil {
236-
results <- fmt.Errorf("expected nil result for request %d", id)
237+
// Result is nil when no metadata is present (this is expected)
238+
if result != nil && result.IsError {
239+
results <- fmt.Errorf("unexpected error result for request %d", id)
237240
return
238241
}
239242

cmd/yardstick-server/main.go

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,18 +56,41 @@ func authWrapper(next http.Handler) http.Handler {
5656
})
5757
}
5858

59-
func echoHandler(_ context.Context, _ *mcp.CallToolRequest, params EchoRequest) (*mcp.CallToolResult, EchoResponse, error) {
59+
func echoHandler(_ context.Context, req *mcp.CallToolRequest, params EchoRequest) (*mcp.CallToolResult, EchoResponse, error) {
60+
// Extract metadata from request to echo back in response
61+
var metadata mcp.Meta
62+
if req.Params != nil && len(req.Params.Meta) > 0 {
63+
log.Printf("echo tool called with metadata: %+v", req.Params.Meta)
64+
metadata = req.Params.Meta
65+
}
66+
6067
if !validateAlphanumeric(params.Input) {
61-
return &mcp.CallToolResult{
68+
// Echo back metadata even in error cases
69+
result := &mcp.CallToolResult{
6270
Content: []mcp.Content{&mcp.TextContent{Text: "input must be alphanumeric only"}},
6371
IsError: true,
64-
}, EchoResponse{}, nil
72+
}
73+
if len(metadata) > 0 {
74+
result.Meta = metadata
75+
}
76+
return result, EchoResponse{}, nil
6577
}
6678

6779
response := EchoResponse{
6880
Output: params.Input,
6981
}
7082

83+
// Return result with metadata echoed back only if metadata is present
84+
// When result is nil, SDK auto-populates Content from response
85+
// When result is non-nil with empty Content, SDK should still auto-populate Content
86+
if len(metadata) > 0 {
87+
result := &mcp.CallToolResult{
88+
Meta: metadata,
89+
}
90+
return result, response, nil
91+
}
92+
93+
// No metadata, return nil result (original behavior)
7194
return nil, response, nil
7295
}
7396

@@ -96,8 +119,9 @@ func main() {
96119

97120
// Add echo tool to server using the new API
98121
mcp.AddTool(server, &mcp.Tool{
99-
Name: "echo",
100-
Description: "Echo back an alphanumeric string for deterministic testing",
122+
Name: "echo",
123+
Description: "Echo back an alphanumeric string for deterministic testing. " +
124+
"Also echoes back any _meta field from the request for testing metadata propagation.",
101125
InputSchema: inputSchema,
102126
}, echoHandler)
103127

cmd/yardstick-server/main_test.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ func TestEchoHandler(t *testing.T) {
9393
assert.True(t, result.IsError)
9494
} else {
9595
assert.NoError(t, err)
96+
// Result is nil when no metadata is present (original behavior)
9697
assert.Nil(t, result)
9798
assert.NotNil(t, response)
9899
assert.Equal(t, tt.expectedOutput, response.Output)
@@ -188,3 +189,133 @@ func TestCheckAuth_Disabled(t *testing.T) {
188189
err = checkAuth(req)
189190
assert.NoError(t, err)
190191
}
192+
193+
func TestEchoHandler_WithMetadata(t *testing.T) {
194+
// Create request with metadata
195+
requestMeta := mcp.Meta{
196+
"progressToken": "test123",
197+
"customKey": "customValue",
198+
}
199+
req := &mcp.CallToolRequest{
200+
Params: &mcp.CallToolParamsRaw{
201+
Meta: requestMeta,
202+
},
203+
}
204+
params := EchoRequest{Input: "hello"}
205+
206+
// Call handler
207+
result, response, err := echoHandler(context.Background(), req, params)
208+
209+
// Verify response
210+
assert.NoError(t, err)
211+
assert.NotNil(t, result)
212+
assert.NotNil(t, response)
213+
assert.Equal(t, "hello", response.Output)
214+
215+
// Verify metadata is echoed back
216+
assert.NotNil(t, result.Meta)
217+
assert.Equal(t, requestMeta["progressToken"], result.Meta["progressToken"])
218+
assert.Equal(t, requestMeta["customKey"], result.Meta["customKey"])
219+
}
220+
221+
func TestEchoHandler_WithMultipleMetadataFields(t *testing.T) {
222+
tests := []struct {
223+
name string
224+
metadata mcp.Meta
225+
}{
226+
{
227+
name: "with progressToken",
228+
metadata: mcp.Meta{"progressToken": "token123"},
229+
},
230+
{
231+
name: "with multiple fields",
232+
metadata: mcp.Meta{
233+
"progressToken": "token456",
234+
"requestId": "req789",
235+
"clientInfo": "test-client",
236+
},
237+
},
238+
{
239+
name: "with nested metadata",
240+
metadata: mcp.Meta{"debug": map[string]any{"level": "verbose"}},
241+
},
242+
}
243+
244+
for _, tt := range tests {
245+
t.Run(tt.name, func(t *testing.T) {
246+
// Create request with metadata
247+
req := &mcp.CallToolRequest{
248+
Params: &mcp.CallToolParamsRaw{
249+
Meta: tt.metadata,
250+
},
251+
}
252+
params := EchoRequest{Input: "test123"}
253+
254+
// Call handler
255+
result, response, err := echoHandler(context.Background(), req, params)
256+
257+
// Verify response
258+
assert.NoError(t, err)
259+
assert.NotNil(t, result)
260+
assert.NotNil(t, response)
261+
assert.Equal(t, "test123", response.Output)
262+
263+
// Verify metadata is echoed back
264+
assert.NotNil(t, result.Meta)
265+
for key, expectedValue := range tt.metadata {
266+
actualValue, exists := result.Meta[key]
267+
assert.True(t, exists, "Expected metadata key %s to exist", key)
268+
assert.Equal(t, expectedValue, actualValue, "Metadata value mismatch for key %s", key)
269+
}
270+
})
271+
}
272+
}
273+
274+
func TestEchoHandler_WithoutMetadata(t *testing.T) {
275+
// Create request without metadata
276+
req := &mcp.CallToolRequest{
277+
Params: &mcp.CallToolParamsRaw{},
278+
}
279+
params := EchoRequest{Input: "test123"}
280+
281+
// Call handler
282+
result, response, err := echoHandler(context.Background(), req, params)
283+
284+
// Verify response
285+
assert.NoError(t, err)
286+
// Result should be nil when no metadata is present (original behavior)
287+
assert.Nil(t, result)
288+
assert.NotNil(t, response)
289+
assert.Equal(t, "test123", response.Output)
290+
}
291+
292+
func TestEchoHandler_WithMetadata_ValidationError(t *testing.T) {
293+
// Create request with metadata but invalid input
294+
requestMeta := mcp.Meta{
295+
"progressToken": "error-token",
296+
"requestId": "error-req-123",
297+
}
298+
req := &mcp.CallToolRequest{
299+
Params: &mcp.CallToolParamsRaw{
300+
Meta: requestMeta,
301+
},
302+
}
303+
params := EchoRequest{Input: "invalid@input!"} // Contains special characters
304+
305+
// Call handler
306+
result, response, err := echoHandler(context.Background(), req, params)
307+
308+
// Verify response
309+
assert.NoError(t, err)
310+
assert.NotNil(t, result)
311+
assert.True(t, result.IsError, "Expected IsError to be true for invalid input")
312+
assert.NotEmpty(t, result.Content, "Expected error message in Content")
313+
314+
// Verify metadata is still echoed back even in error case
315+
assert.NotNil(t, result.Meta, "Metadata should be echoed back even on validation error")
316+
assert.Equal(t, requestMeta["progressToken"], result.Meta["progressToken"])
317+
assert.Equal(t, requestMeta["requestId"], result.Meta["requestId"])
318+
319+
// Verify response is empty for error case
320+
assert.Empty(t, response.Output)
321+
}

0 commit comments

Comments
 (0)