diff --git a/mcp/types.go b/mcp/types.go index f871b7d9d..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 *Meta `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 *Meta `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 c1453de60..fbe0ee756 100644 --- a/mcp/types_test.go +++ b/mcp/types_test.go @@ -138,3 +138,242 @@ 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]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: `{ + "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]any{ + "mcpui.dev/ui-preferred-frame-size": []interface{}{"800px", "600px"}, + "mcpui.dev/ui-initial-render-data": map[string]any{ + "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]any{ + "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]any + 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]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 { + // 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.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 2d1bd47ba..4eb386f74 100644 --- a/mcp/utils.go +++ b/mcp/utils.go @@ -705,8 +705,15 @@ func ParseResourceContents(contentMap map[string]any) (ResourceContents, error) mimeType := ExtractString(contentMap, "mimeType") + 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, @@ -715,6 +722,7 @@ func ParseResourceContents(contentMap map[string]any) (ResourceContents, error) if blob := ExtractString(contentMap, "blob"); blob != "" { return BlobResourceContents{ + Meta: meta, URI: uri, MIMEType: mimeType, Blob: blob,