Skip to content

Commit e495469

Browse files
authored
feat: pass _meta through resources/read path (#211)
1 parent c8fb0d1 commit e495469

9 files changed

Lines changed: 105 additions & 10 deletions

File tree

lib/action_mcp/content/resource.rb

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ class Resource < Base
99
# @return [String] The MIME type of the resource.
1010
# @return [String, nil] The text content of the resource (optional).
1111
# @return [String, nil] The base64-encoded blob of the resource (optional).
12-
attr_reader :uri, :mime_type, :text, :blob, :annotations
12+
# @return [Hash, nil] Optional extension metadata (serialized on the wire as `_meta`).
13+
attr_reader :uri, :mime_type, :text, :blob, :annotations, :meta
1314

1415
# Initializes a new Resource content.
1516
#
@@ -18,24 +19,38 @@ class Resource < Base
1819
# @param text [String, nil] The text content of the resource (optional).
1920
# @param blob [String, nil] The base64-encoded blob of the resource (optional).
2021
# @param annotations [Hash, nil] Optional annotations for the resource.
21-
def initialize(uri, mime_type = "text/plain", text: nil, blob: nil, annotations: nil)
22+
# @param meta [Hash, #to_hash, #to_h, nil] Optional extension metadata. Emitted on the wire as `_meta`.
23+
def initialize(uri, mime_type = "text/plain", text: nil, blob: nil, annotations: nil, meta: nil)
2224
super("resource", annotations: annotations)
2325
@uri = uri
2426
@mime_type = mime_type
2527
@text = text
2628
@blob = blob
2729
@annotations = annotations
30+
@meta =
31+
if meta.nil?
32+
nil
33+
elsif meta.respond_to?(:to_hash)
34+
meta.to_hash
35+
elsif meta.respond_to?(:to_h)
36+
meta.to_h
37+
else
38+
raise ArgumentError, "meta must respond to :to_hash or :to_h, got: #{meta.class}"
39+
end
2840
end
2941

3042
# Returns a hash representation of the resource content.
3143
# Per MCP spec, embedded resources have type "resource" with a nested resource object.
44+
# `meta` is emitted as `_meta` on the inner resource hash (TextResourceContents /
45+
# BlobResourceContents), not on the outer content envelope.
3246
#
3347
# @return [Hash] The hash representation of the resource content.
3448
def to_h
3549
inner = { uri: @uri, mimeType: @mime_type }
3650
inner[:text] = @text if @text
3751
inner[:blob] = @blob if @blob
3852
inner[:annotations] = @annotations if @annotations
53+
inner[:_meta] = @meta if @meta && !@meta.empty?
3954

4055
{ type: @type, resource: inner }
4156
end

lib/action_mcp/resource.rb

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ module ActionMCP
44
# Represents a resource with its metadata.
55
# Used by resources/list to describe concrete resources.
66
class Resource
7-
attr_reader :uri, :name, :title, :description, :mime_type, :size, :annotations
7+
attr_reader :uri, :name, :title, :description, :mime_type, :size, :annotations, :meta
88

99
# @param uri [String] The URI of the resource
1010
# @param name [String] Display name of the resource
@@ -13,14 +13,25 @@ class Resource
1313
# @param mime_type [String, nil] MIME type of the resource content
1414
# @param size [Integer, nil] Size of the resource in bytes
1515
# @param annotations [Hash, nil] Optional annotations
16-
def initialize(uri:, name:, title: nil, description: nil, mime_type: nil, size: nil, annotations: nil)
16+
# @param meta [Hash, #to_hash, #to_h, nil] Optional extension metadata. Emitted on the wire as `_meta`.
17+
def initialize(uri:, name:, title: nil, description: nil, mime_type: nil, size: nil, annotations: nil, meta: nil)
1718
@uri = uri
1819
@name = name
1920
@title = title
2021
@description = description
2122
@mime_type = mime_type
2223
@size = size
2324
@annotations = annotations
25+
@meta =
26+
if meta.nil?
27+
nil
28+
elsif meta.respond_to?(:to_hash)
29+
meta.to_hash
30+
elsif meta.respond_to?(:to_h)
31+
meta.to_h
32+
else
33+
raise ArgumentError, "meta must respond to :to_hash or :to_h, got: #{meta.class}"
34+
end
2435
freeze
2536
end
2637

@@ -35,6 +46,7 @@ def to_h
3546
hash[:mimeType] = mime_type if mime_type
3647
hash[:size] = size if size
3748
hash[:annotations] = annotations if annotations
49+
hash[:_meta] = meta if meta && !meta.empty?
3850
hash
3951
end
4052

@@ -46,12 +58,12 @@ def ==(other)
4658
other.is_a?(Resource) && uri == other.uri && name == other.name &&
4759
title == other.title && description == other.description &&
4860
mime_type == other.mime_type && size == other.size &&
49-
annotations == other.annotations
61+
annotations == other.annotations && meta == other.meta
5062
end
5163
alias eql? ==
5264

5365
def hash
54-
[ uri, name, title, description, mime_type, size, annotations ].hash
66+
[ uri, name, title, description, mime_type, size, annotations, meta ].hash
5567
end
5668
end
5769
end

lib/action_mcp/resource_template.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,16 +156,18 @@ def lists_resources?
156156
# @param mime_type [String, nil] Falls back to template mime_type
157157
# @param size [Integer, nil] Size in bytes
158158
# @param annotations [Hash, nil] Optional annotations
159+
# @param meta [Hash, #to_hash, #to_h, nil] Optional extension metadata passed through to the Resource (emitted as `_meta`)
159160
# @return [ActionMCP::Resource]
160-
def build_resource(uri:, name:, title: nil, description: nil, mime_type: nil, size: nil, annotations: nil)
161+
def build_resource(uri:, name:, title: nil, description: nil, mime_type: nil, size: nil, annotations: nil, meta: nil)
161162
ActionMCP::Resource.new(
162163
uri: uri,
163164
name: name,
164165
title: title,
165166
description: description || @description,
166167
mime_type: mime_type || @mime_type,
167168
size: size,
168-
annotations: annotations
169+
annotations: annotations,
170+
meta: meta
169171
)
170172
end
171173

lib/action_mcp/server/resources.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,13 +163,14 @@ def collect_resources(request_id)
163163

164164
# Normalize a content object to MCP ReadResourceResult content shape.
165165
#
166-
# @return [Hash] with keys: uri, mimeType, and text or blob
166+
# @return [Hash] with keys: uri, mimeType, text or blob, and optional _meta
167167
def normalize_read_content(content, _uri)
168168
case content
169169
when ActionMCP::Content::Resource
170170
inner = { uri: content.uri, mimeType: content.mime_type }
171171
inner[:text] = content.text if content.text
172172
inner[:blob] = content.blob if content.blob
173+
inner[:_meta] = content.meta if content.meta && !content.meta.empty?
173174
inner
174175
else
175176
content.respond_to?(:to_h) ? content.to_h : content

test/action_mcp/content_test.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ class ContentTest < ActiveSupport::TestCase
8080
resource_full = Resource.new(uri, mime_type, text: text_content, blob: blob_content)
8181
expected_full = { type: "resource", resource: { uri: uri, mimeType: mime_type, text: text_content, blob: blob_content } }
8282
assert_equal expected_full, resource_full.to_h
83+
84+
# meta is emitted on the inner resource hash as `_meta`, not the outer envelope
85+
meta = { ui: { prefersBorder: true } }
86+
resource_with_meta = Resource.new(uri, mime_type, text: text_content, meta: meta)
87+
assert_equal meta, resource_with_meta.to_h[:resource][:_meta]
88+
refute resource_with_meta.to_h.key?(:_meta)
8389
end
8490

8591
test "Resource content supports annotations" do

test/action_mcp/resource_template_test.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,17 @@ def self.name = "Test\#{SecureRandom.hex(6)}Template"
3131
end
3232
end
3333

34+
test "build_resource forwards meta to Resource" do
35+
template = create_temp_template(uri_template: "meta://item/{id}", name: "MetaTemplate")
36+
resource = template.build_resource(
37+
uri: "meta://item/1",
38+
name: "item 1",
39+
meta: { ui: { prefersBorder: false } }
40+
)
41+
42+
assert_equal({ ui: { prefersBorder: false } }, resource.meta)
43+
end
44+
3445
test "allows unique URI templates" do
3546
# Create temporary classes with unique templates
3647
create_temp_template(uri_template: "service://resource/{id}", name: "Template1")

test/action_mcp/resource_test.rb

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ class ResourceTest < ActiveSupport::TestCase
1212
description: "A test file",
1313
mime_type: "text/plain",
1414
size: 42,
15-
annotations: { audience: [ "user" ] }
15+
annotations: { audience: [ "user" ] },
16+
meta: { ui: { prefersBorder: true } }
1617
)
1718

1819
assert_equal "file:///test.txt", resource.uri
@@ -22,6 +23,8 @@ class ResourceTest < ActiveSupport::TestCase
2223
assert_equal "text/plain", resource.mime_type
2324
assert_equal 42, resource.size
2425
assert_equal({ audience: [ "user" ] }, resource.annotations)
26+
assert_equal({ ui: { prefersBorder: true } }, resource.meta)
27+
assert_equal({ ui: { prefersBorder: true } }, resource.to_h[:_meta])
2528
end
2629

2730
test "creates resource with only required fields" do
@@ -58,6 +61,7 @@ class ResourceTest < ActiveSupport::TestCase
5861
hash = resource.to_h
5962

6063
assert_equal({ uri: "file:///test.txt", name: "test.txt" }, hash)
64+
refute hash.key?(:_meta)
6165
end
6266

6367
test "to_h converts mime_type to mimeType" do
@@ -110,5 +114,21 @@ class ResourceTest < ActiveSupport::TestCase
110114
assert_equal "test.txt", parsed["name"]
111115
assert_equal "text/plain", parsed["mimeType"]
112116
end
117+
118+
test "rejects meta that is neither Hash-like nor nil" do
119+
assert_raises(ArgumentError) do
120+
Resource.new(uri: "file:///a", name: "a", meta: "bad")
121+
end
122+
end
123+
124+
test "accepts Hash-like meta via to_hash" do
125+
hashlike = Class.new do
126+
def to_hash = { ui: { prefersBorder: true } }
127+
end.new
128+
129+
resource = Resource.new(uri: "file:///a", name: "a", meta: hashlike)
130+
assert_equal({ ui: { prefersBorder: true } }, resource.meta)
131+
assert_equal({ ui: { prefersBorder: true } }, resource.to_h[:_meta])
132+
end
113133
end
114134
end

test/action_mcp/server/resources_test.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@ def self.readable_uri?(_uri) = false
5959
assert_equal "Resource not found", response[:error][:message]
6060
assert_equal({ uri: uri }, response[:error][:data])
6161
end
62+
63+
test "send_resource_read passes _meta through on contents" do
64+
transport = TestTransport.new
65+
transport.send_resource_read(101, { "uri" => "meta-echo://item/42" })
66+
67+
response = transport.responses.last
68+
assert_nil response[:error]
69+
content = response[:result][:contents].first
70+
assert_equal({ ui: { prefersBorder: true } }, content[:_meta])
71+
end
6272
end
6373
end
6474
end
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# frozen_string_literal: true
2+
3+
class MetaEchoTemplate < ApplicationMCPResTemplate
4+
description "Echo resource that includes _meta in its content"
5+
uri_template "meta-echo://item/{id}"
6+
mime_type "application/json"
7+
8+
parameter :id, description: "Item identifier", required: true
9+
10+
def resolve
11+
ActionMCP::Content::Resource.new(
12+
"meta-echo://item/#{id}",
13+
"application/json",
14+
text: "{}",
15+
meta: { ui: { prefersBorder: true } }
16+
)
17+
end
18+
end

0 commit comments

Comments
 (0)