From 5eff53a2c77d988bcbe43992cb300d4f88936f2e Mon Sep 17 00:00:00 2001 From: Rodric Rabbah Date: Wed, 17 Sep 2025 17:09:57 -0400 Subject: [PATCH 1/2] feat: update type definition of _meta field to text/blob resources. - Parses optional _meta field on the text/blob resource - Add TestResourceContentsMetaField to verify _meta field handling - Ensure backward compatibility with resources without _meta --- mcp/types.go | 4 +- mcp/types_test.go | 125 ++++++++++++++++++++++++++++++++++++++++++++++ mcp/utils.go | 9 ++++ 3 files changed, 136 insertions(+), 2 deletions(-) diff --git a/mcp/types.go b/mcp/types.go index f871b7d9d..1279e2d97 100644 --- a/mcp/types.go +++ b/mcp/types.go @@ -717,7 +717,7 @@ type ResourceContents interface { type TextResourceContents struct { // Meta is a metadata object that is reserved by MCP for storing additional information. - Meta *Meta `json:"_meta,omitempty"` + Meta map[string]interface{} `json:"_meta,omitempty"` // The URI of this resource. URI string `json:"uri"` // The MIME type of this resource, if known. @@ -731,7 +731,7 @@ func (TextResourceContents) isResourceContents() {} type BlobResourceContents struct { // Meta is a metadata object that is reserved by MCP for storing additional information. - Meta *Meta `json:"_meta,omitempty"` + Meta map[string]interface{} `json:"_meta,omitempty"` // The URI of this resource. URI string `json:"uri"` // The MIME type of this resource, if known. diff --git a/mcp/types_test.go b/mcp/types_test.go index c1453de60..b6a76b665 100644 --- a/mcp/types_test.go +++ b/mcp/types_test.go @@ -138,3 +138,128 @@ func TestCallToolResultWithResourceLink(t *testing.T) { assert.Equal(t, "A test document", resourceLink.Description) assert.Equal(t, "application/pdf", resourceLink.MIMEType) } + +func TestResourceContentsMetaField(t *testing.T) { + tests := []struct { + name string + inputJSON string + expectedType string + expectedMeta map[string]interface{} + }{ + { + name: "TextResourceContents with _meta field", + inputJSON: `{ + "uri": "file://test.txt", + "mimeType": "text/plain", + "text": "Hello World", + "_meta": { + "mcpui.dev/ui-preferred-frame-size": ["800px", "600px"], + "mcpui.dev/ui-initial-render-data": { + "test": "value" + } + } + }`, + expectedType: "text", + expectedMeta: map[string]interface{}{ + "mcpui.dev/ui-preferred-frame-size": []interface{}{"800px", "600px"}, + "mcpui.dev/ui-initial-render-data": map[string]interface{}{ + "test": "value", + }, + }, + }, + { + name: "BlobResourceContents with _meta field", + inputJSON: `{ + "uri": "file://image.png", + "mimeType": "image/png", + "blob": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==", + "_meta": { + "width": 100, + "height": 100, + "format": "PNG" + } + }`, + expectedType: "blob", + expectedMeta: map[string]interface{}{ + "width": float64(100), // JSON numbers are always float64 + "height": float64(100), + "format": "PNG", + }, + }, + { + name: "TextResourceContents without _meta field", + inputJSON: `{ + "uri": "file://simple.txt", + "mimeType": "text/plain", + "text": "Simple content" + }`, + expectedType: "text", + expectedMeta: nil, + }, + { + name: "BlobResourceContents without _meta field", + inputJSON: `{ + "uri": "file://simple.png", + "mimeType": "image/png", + "blob": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" + }`, + expectedType: "blob", + expectedMeta: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Parse the JSON as a generic map first + var contentMap map[string]interface{} + err := json.Unmarshal([]byte(tc.inputJSON), &contentMap) + require.NoError(t, err) + + // Use ParseResourceContents to convert to ResourceContents + resourceContent, err := ParseResourceContents(contentMap) + require.NoError(t, err) + require.NotNil(t, resourceContent) + + // Test based on expected type + if tc.expectedType == "text" { + textContent, ok := resourceContent.(TextResourceContents) + require.True(t, ok, "Expected TextResourceContents") + + // Verify standard fields + assert.Equal(t, contentMap["uri"], textContent.URI) + assert.Equal(t, contentMap["mimeType"], textContent.MIMEType) + assert.Equal(t, contentMap["text"], textContent.Text) + + // Verify _meta field + assert.Equal(t, tc.expectedMeta, textContent.Meta) + + } else if tc.expectedType == "blob" { + blobContent, ok := resourceContent.(BlobResourceContents) + require.True(t, ok, "Expected BlobResourceContents") + + // Verify standard fields + assert.Equal(t, contentMap["uri"], blobContent.URI) + assert.Equal(t, contentMap["mimeType"], blobContent.MIMEType) + assert.Equal(t, contentMap["blob"], blobContent.Blob) + + // Verify _meta field + assert.Equal(t, tc.expectedMeta, blobContent.Meta) + } + + // Test round-trip marshaling to ensure _meta is preserved + marshaledJSON, err := json.Marshal(resourceContent) + require.NoError(t, err) + + var marshaledMap map[string]interface{} + err = json.Unmarshal(marshaledJSON, &marshaledMap) + require.NoError(t, err) + + // Verify _meta field is preserved in marshaled output + if tc.expectedMeta != nil { + assert.Equal(t, tc.expectedMeta, marshaledMap["_meta"]) + } else { + assert.Nil(t, marshaledMap["_meta"]) + } + }) + } +} diff --git a/mcp/utils.go b/mcp/utils.go index 2d1bd47ba..f355fcfee 100644 --- a/mcp/utils.go +++ b/mcp/utils.go @@ -705,11 +705,19 @@ func ParseResourceContents(contentMap map[string]any) (ResourceContents, error) mimeType := ExtractString(contentMap, "mimeType") + var meta map[string]interface{} + if metaValue, ok := contentMap["_meta"]; ok { + if metaMap, ok := metaValue.(map[string]interface{}); ok { + meta = metaMap + } + } + if text := ExtractString(contentMap, "text"); text != "" { return TextResourceContents{ URI: uri, MIMEType: mimeType, Text: text, + Meta: meta, }, nil } @@ -718,6 +726,7 @@ func ParseResourceContents(contentMap map[string]any) (ResourceContents, error) URI: uri, MIMEType: mimeType, Blob: blob, + Meta: meta, }, nil } From 264c75aa44714d8c308906be00ba4995054c25c8 Mon Sep 17 00:00:00 2001 From: Rodric Rabbah Date: Wed, 17 Sep 2025 22:12:04 -0400 Subject: [PATCH 2/2] chore: apply coderabbit nits. --- mcp/types.go | 10 ++-- mcp/types_test.go | 130 +++++++++++++++++++++++++++++++++++++++++++--- mcp/utils.go | 13 +++-- 3 files changed, 134 insertions(+), 19 deletions(-) diff --git a/mcp/types.go b/mcp/types.go index 1279e2d97..1bf26d097 100644 --- a/mcp/types.go +++ b/mcp/types.go @@ -716,8 +716,9 @@ type ResourceContents interface { } type TextResourceContents struct { - // Meta is a metadata object that is reserved by MCP for storing additional information. - Meta map[string]interface{} `json:"_meta,omitempty"` + // Raw per‑resource metadata; pass‑through as defined by MCP. Not the same as mcp.Meta. + // Allows _meta to be used for MCP-UI features for example. Does not assume any specific format. + Meta map[string]any `json:"_meta,omitempty"` // The URI of this resource. URI string `json:"uri"` // The MIME type of this resource, if known. @@ -730,8 +731,9 @@ type TextResourceContents struct { func (TextResourceContents) isResourceContents() {} type BlobResourceContents struct { - // Meta is a metadata object that is reserved by MCP for storing additional information. - Meta map[string]interface{} `json:"_meta,omitempty"` + // Raw per‑resource metadata; pass‑through as defined by MCP. Not the same as mcp.Meta. + // Allows _meta to be used for MCP-UI features for example. Does not assume any specific format. + Meta map[string]any `json:"_meta,omitempty"` // The URI of this resource. URI string `json:"uri"` // The MIME type of this resource, if known. diff --git a/mcp/types_test.go b/mcp/types_test.go index b6a76b665..fbe0ee756 100644 --- a/mcp/types_test.go +++ b/mcp/types_test.go @@ -144,8 +144,19 @@ func TestResourceContentsMetaField(t *testing.T) { name string inputJSON string expectedType string - expectedMeta map[string]interface{} + expectedMeta map[string]any }{ + { + name: "TextResourceContents with empty _meta", + inputJSON: `{ + "uri":"file://empty-meta.txt", + "mimeType":"text/plain", + "text":"x", + "_meta": {} + }`, + expectedType: "text", + expectedMeta: map[string]any{}, + }, { name: "TextResourceContents with _meta field", inputJSON: `{ @@ -160,9 +171,9 @@ func TestResourceContentsMetaField(t *testing.T) { } }`, expectedType: "text", - expectedMeta: map[string]interface{}{ + expectedMeta: map[string]any{ "mcpui.dev/ui-preferred-frame-size": []interface{}{"800px", "600px"}, - "mcpui.dev/ui-initial-render-data": map[string]interface{}{ + "mcpui.dev/ui-initial-render-data": map[string]any{ "test": "value", }, }, @@ -180,7 +191,7 @@ func TestResourceContentsMetaField(t *testing.T) { } }`, expectedType: "blob", - expectedMeta: map[string]interface{}{ + expectedMeta: map[string]any{ "width": float64(100), // JSON numbers are always float64 "height": float64(100), "format": "PNG", @@ -211,7 +222,7 @@ func TestResourceContentsMetaField(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Parse the JSON as a generic map first - var contentMap map[string]interface{} + var contentMap map[string]any err := json.Unmarshal([]byte(tc.inputJSON), &contentMap) require.NoError(t, err) @@ -250,16 +261,119 @@ func TestResourceContentsMetaField(t *testing.T) { marshaledJSON, err := json.Marshal(resourceContent) require.NoError(t, err) - var marshaledMap map[string]interface{} + var marshaledMap map[string]any err = json.Unmarshal(marshaledJSON, &marshaledMap) require.NoError(t, err) // Verify _meta field is preserved in marshaled output + v, ok := marshaledMap["_meta"] if tc.expectedMeta != nil { - assert.Equal(t, tc.expectedMeta, marshaledMap["_meta"]) + // Special case: empty maps are omitted due to omitempty tag + if len(tc.expectedMeta) == 0 { + assert.False(t, ok, "_meta should be omitted when empty due to omitempty") + } else { + require.True(t, ok, "_meta should be present") + assert.Equal(t, tc.expectedMeta, v) + } } else { - assert.Nil(t, marshaledMap["_meta"]) + assert.False(t, ok, "_meta should be omitted when nil") } }) } } + +func TestParseResourceContentsInvalidMeta(t *testing.T) { + tests := []struct { + name string + inputJSON string + expectedErr string + }{ + { + name: "TextResourceContents with invalid _meta (string)", + inputJSON: `{ + "uri": "file://test.txt", + "mimeType": "text/plain", + "text": "Hello World", + "_meta": "invalid_meta_string" + }`, + expectedErr: "_meta must be an object", + }, + { + name: "TextResourceContents with invalid _meta (number)", + inputJSON: `{ + "uri": "file://test.txt", + "mimeType": "text/plain", + "text": "Hello World", + "_meta": 123 + }`, + expectedErr: "_meta must be an object", + }, + { + name: "TextResourceContents with invalid _meta (array)", + inputJSON: `{ + "uri": "file://test.txt", + "mimeType": "text/plain", + "text": "Hello World", + "_meta": ["invalid", "array"] + }`, + expectedErr: "_meta must be an object", + }, + { + name: "TextResourceContents with invalid _meta (boolean)", + inputJSON: `{ + "uri": "file://test.txt", + "mimeType": "text/plain", + "text": "Hello World", + "_meta": true + }`, + expectedErr: "_meta must be an object", + }, + { + name: "TextResourceContents with invalid _meta (null)", + inputJSON: `{ + "uri": "file://test.txt", + "mimeType": "text/plain", + "text": "Hello World", + "_meta": null + }`, + expectedErr: "_meta must be an object", + }, + { + name: "BlobResourceContents with invalid _meta (string)", + inputJSON: `{ + "uri": "file://image.png", + "mimeType": "image/png", + "blob": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==", + "_meta": "invalid_meta_string" + }`, + expectedErr: "_meta must be an object", + }, + { + name: "BlobResourceContents with invalid _meta (number)", + inputJSON: `{ + "uri": "file://image.png", + "mimeType": "image/png", + "blob": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==", + "_meta": 456 + }`, + expectedErr: "_meta must be an object", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Parse the JSON as a generic map first + var contentMap map[string]any + err := json.Unmarshal([]byte(tc.inputJSON), &contentMap) + require.NoError(t, err) + + // Use ParseResourceContents to convert to ResourceContents + resourceContent, err := ParseResourceContents(contentMap) + + // Expect an error + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErr) + assert.Nil(t, resourceContent) + }) + } +} diff --git a/mcp/utils.go b/mcp/utils.go index f355fcfee..4eb386f74 100644 --- a/mcp/utils.go +++ b/mcp/utils.go @@ -705,28 +705,27 @@ func ParseResourceContents(contentMap map[string]any) (ResourceContents, error) mimeType := ExtractString(contentMap, "mimeType") - var meta map[string]interface{} - if metaValue, ok := contentMap["_meta"]; ok { - if metaMap, ok := metaValue.(map[string]interface{}); ok { - meta = metaMap - } + meta := ExtractMap(contentMap, "_meta") + + if _, present := contentMap["_meta"]; present && meta == nil { + return nil, fmt.Errorf("_meta must be an object") } if text := ExtractString(contentMap, "text"); text != "" { return TextResourceContents{ + Meta: meta, URI: uri, MIMEType: mimeType, Text: text, - Meta: meta, }, nil } if blob := ExtractString(contentMap, "blob"); blob != "" { return BlobResourceContents{ + Meta: meta, URI: uri, MIMEType: mimeType, Blob: blob, - Meta: meta, }, nil }