From 4b729d5f78117f7098087a4370be6b182691f53a Mon Sep 17 00:00:00 2001 From: Szymon Kurcab Date: Sun, 15 Feb 2026 00:01:37 +0100 Subject: [PATCH 1/2] resource link: implement for MCP 2025-11-25 --- lib/mcp_client.rb | 25 +++- lib/mcp_client/resource_link.rb | 53 ++++++++ .../lib/mcp_client/parse_tool_content_spec.rb | 85 +++++++++++++ spec/lib/mcp_client/resource_link_spec.rb | 118 ++++++++++++++++++ 4 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 lib/mcp_client/resource_link.rb create mode 100644 spec/lib/mcp_client/parse_tool_content_spec.rb create mode 100644 spec/lib/mcp_client/resource_link_spec.rb diff --git a/lib/mcp_client.rb b/lib/mcp_client.rb index b248daf..ad973b0 100644 --- a/lib/mcp_client.rb +++ b/lib/mcp_client.rb @@ -7,7 +7,7 @@ require_relative 'mcp_client/resource' require_relative 'mcp_client/resource_template' require_relative 'mcp_client/resource_content' -require_relative 'mcp_client/audio_content' +require_relative 'mcp_client/resource_link' require_relative 'mcp_client/root' require_relative 'mcp_client/elicitation_validator' require_relative 'mcp_client/server_base' @@ -468,4 +468,27 @@ def self.streamable_http_config(base_url:, endpoint: '/rpc', headers: {}, read_t faraday_config: faraday_config } end + + # Parse a single content item from a tool result into a typed object + # Recognizes 'resource_link' type and returns an MCPClient::ResourceLink. + # Unrecognized types are returned as-is (the original Hash). + # @param item [Hash] a content item with a 'type' field + # @return [MCPClient::ResourceLink, Hash] typed object or raw hash + def self.parse_content_item(item) + case item['type'] + when 'resource_link' + ResourceLink.from_json(item) + else + item + end + end + + # Parse the content array from a tool result into typed objects + # Each item with type 'resource_link' is converted to an MCPClient::ResourceLink. + # Other items are returned as-is. + # @param content [Array] content array from a tool result + # @return [Array] array of typed objects or raw hashes + def self.parse_tool_content(content) + Array(content).map { |item| parse_content_item(item) } + end end diff --git a/lib/mcp_client/resource_link.rb b/lib/mcp_client/resource_link.rb new file mode 100644 index 0000000..6b30a6f --- /dev/null +++ b/lib/mcp_client/resource_link.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module MCPClient + # Representation of an MCP resource link in tool result content + # A resource link references a server resource that can be read separately. + # Used in tool results to point clients to available resources (MCP 2025-11-25). + class ResourceLink + # @!attribute [r] uri + # @return [String] URI of the linked resource + # @!attribute [r] name + # @return [String] the name of the linked resource + # @!attribute [r] description + # @return [String, nil] optional human-readable description + # @!attribute [r] mime_type + # @return [String, nil] optional MIME type of the resource + # @!attribute [r] annotations + # @return [Hash, nil] optional annotations that provide hints to clients + attr_reader :uri, :name, :description, :mime_type, :annotations + + # Initialize a resource link + # @param uri [String] URI of the linked resource + # @param name [String] the name of the linked resource + # @param description [String, nil] optional human-readable description + # @param mime_type [String, nil] optional MIME type of the resource + # @param annotations [Hash, nil] optional annotations that provide hints to clients + def initialize(uri:, name:, description: nil, mime_type: nil, annotations: nil) + @uri = uri + @name = name + @description = description + @mime_type = mime_type + @annotations = annotations + end + + # Create a ResourceLink instance from JSON data + # @param data [Hash] JSON data from MCP server (content item with type 'resource_link') + # @return [MCPClient::ResourceLink] resource link instance + def self.from_json(data) + new( + uri: data['uri'], + name: data['name'], + description: data['description'], + mime_type: data['mimeType'], + annotations: data['annotations'] + ) + end + + # The content type identifier for this content type + # @return [String] 'resource_link' + def type + 'resource_link' + end + end +end diff --git a/spec/lib/mcp_client/parse_tool_content_spec.rb b/spec/lib/mcp_client/parse_tool_content_spec.rb new file mode 100644 index 0000000..73ed40f --- /dev/null +++ b/spec/lib/mcp_client/parse_tool_content_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'MCPClient.parse_tool_content' do + describe '.parse_content_item' do + it 'converts resource_link type to ResourceLink' do + item = { + 'type' => 'resource_link', + 'uri' => 'file:///docs/guide.md', + 'name' => 'guide.md', + 'description' => 'User guide', + 'mimeType' => 'text/markdown' + } + result = MCPClient.parse_content_item(item) + expect(result).to be_a(MCPClient::ResourceLink) + expect(result.uri).to eq('file:///docs/guide.md') + expect(result.name).to eq('guide.md') + expect(result.description).to eq('User guide') + expect(result.mime_type).to eq('text/markdown') + end + + it 'returns text content as-is' do + item = { 'type' => 'text', 'text' => 'hello world' } + result = MCPClient.parse_content_item(item) + expect(result).to eq(item) + end + + it 'returns image content as-is' do + item = { 'type' => 'image', 'data' => 'base64data', 'mimeType' => 'image/png' } + result = MCPClient.parse_content_item(item) + expect(result).to eq(item) + end + + it 'returns unknown types as-is' do + item = { 'type' => 'custom', 'data' => 'something' } + result = MCPClient.parse_content_item(item) + expect(result).to eq(item) + end + end + + describe '.parse_tool_content' do + it 'parses a mixed content array' do + content = [ + { 'type' => 'text', 'text' => 'Found 2 relevant files' }, + { + 'type' => 'resource_link', + 'uri' => 'file:///src/main.rb', + 'name' => 'main.rb', + 'description' => 'Main entry point', + 'mimeType' => 'application/x-ruby' + }, + { + 'type' => 'resource_link', + 'uri' => 'file:///src/helper.rb', + 'name' => 'helper.rb' + } + ] + + results = MCPClient.parse_tool_content(content) + expect(results.size).to eq(3) + + expect(results[0]).to be_a(Hash) + expect(results[0]['type']).to eq('text') + + expect(results[1]).to be_a(MCPClient::ResourceLink) + expect(results[1].uri).to eq('file:///src/main.rb') + expect(results[1].name).to eq('main.rb') + expect(results[1].description).to eq('Main entry point') + + expect(results[2]).to be_a(MCPClient::ResourceLink) + expect(results[2].uri).to eq('file:///src/helper.rb') + expect(results[2].name).to eq('helper.rb') + expect(results[2].description).to be_nil + end + + it 'handles empty content array' do + expect(MCPClient.parse_tool_content([])).to eq([]) + end + + it 'handles nil content' do + expect(MCPClient.parse_tool_content(nil)).to eq([]) + end + end +end diff --git a/spec/lib/mcp_client/resource_link_spec.rb b/spec/lib/mcp_client/resource_link_spec.rb new file mode 100644 index 0000000..c476732 --- /dev/null +++ b/spec/lib/mcp_client/resource_link_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe MCPClient::ResourceLink do + let(:link_uri) { 'file:///project/README.md' } + let(:link_name) { 'README.md' } + let(:link_description) { 'Project documentation' } + let(:link_mime_type) { 'text/markdown' } + let(:link_annotations) { { 'audience' => ['user'], 'priority' => 0.8 } } + + describe '#initialize' do + context 'with all attributes' do + let(:resource_link) do + described_class.new( + uri: link_uri, + name: link_name, + description: link_description, + mime_type: link_mime_type, + annotations: link_annotations + ) + end + + it 'sets the attributes correctly' do + expect(resource_link.uri).to eq(link_uri) + expect(resource_link.name).to eq(link_name) + expect(resource_link.description).to eq(link_description) + expect(resource_link.mime_type).to eq(link_mime_type) + expect(resource_link.annotations).to eq(link_annotations) + end + + it 'returns resource_link as the type' do + expect(resource_link.type).to eq('resource_link') + end + end + + context 'with required attributes only' do + let(:resource_link) do + described_class.new(uri: link_uri, name: link_name) + end + + it 'sets required attributes and defaults optional ones to nil' do + expect(resource_link.uri).to eq(link_uri) + expect(resource_link.name).to eq(link_name) + expect(resource_link.description).to be_nil + expect(resource_link.mime_type).to be_nil + expect(resource_link.annotations).to be_nil + end + end + end + + describe '.from_json' do + context 'with all fields' do + let(:json_data) do + { + 'type' => 'resource_link', + 'uri' => link_uri, + 'name' => link_name, + 'description' => link_description, + 'mimeType' => link_mime_type, + 'annotations' => link_annotations + } + end + + it 'creates a resource link from JSON data' do + link = described_class.from_json(json_data) + expect(link.uri).to eq(link_uri) + expect(link.name).to eq(link_name) + expect(link.description).to eq(link_description) + expect(link.mime_type).to eq(link_mime_type) + expect(link.annotations).to eq(link_annotations) + end + end + + context 'with required fields only' do + let(:json_data) do + { + 'type' => 'resource_link', + 'uri' => link_uri, + 'name' => link_name + } + end + + it 'creates a resource link with nil optional fields' do + link = described_class.from_json(json_data) + expect(link.uri).to eq(link_uri) + expect(link.name).to eq(link_name) + expect(link.description).to be_nil + expect(link.mime_type).to be_nil + expect(link.annotations).to be_nil + end + end + end + + describe '#type' do + it 'returns resource_link' do + link = described_class.new(uri: link_uri, name: link_name) + expect(link.type).to eq('resource_link') + end + end + + describe 'annotations' do + it 'accepts all valid annotation fields' do + link = described_class.new( + uri: link_uri, + name: link_name, + annotations: { + 'audience' => %w[user assistant], + 'priority' => 0.5, + 'lastModified' => '2025-11-25T10:00:00Z' + } + ) + expect(link.annotations['audience']).to eq(%w[user assistant]) + expect(link.annotations['priority']).to eq(0.5) + expect(link.annotations['lastModified']).to eq('2025-11-25T10:00:00Z') + end + end +end From 8077400fe596e7e97e821f7539d2838df3ce19c0 Mon Sep 17 00:00:00 2001 From: Szymon Kurcab Date: Sun, 15 Feb 2026 00:25:57 +0100 Subject: [PATCH 2/2] fix: preserve title/size fields when parsing resource links --- lib/mcp_client.rb | 1 + lib/mcp_client/resource_link.rb | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/mcp_client.rb b/lib/mcp_client.rb index ad973b0..45ddef6 100644 --- a/lib/mcp_client.rb +++ b/lib/mcp_client.rb @@ -7,6 +7,7 @@ require_relative 'mcp_client/resource' require_relative 'mcp_client/resource_template' require_relative 'mcp_client/resource_content' +require_relative 'mcp_client/audio_content' require_relative 'mcp_client/resource_link' require_relative 'mcp_client/root' require_relative 'mcp_client/elicitation_validator' diff --git a/lib/mcp_client/resource_link.rb b/lib/mcp_client/resource_link.rb index 6b30a6f..273b992 100644 --- a/lib/mcp_client/resource_link.rb +++ b/lib/mcp_client/resource_link.rb @@ -15,7 +15,11 @@ class ResourceLink # @return [String, nil] optional MIME type of the resource # @!attribute [r] annotations # @return [Hash, nil] optional annotations that provide hints to clients - attr_reader :uri, :name, :description, :mime_type, :annotations + # @!attribute [r] title + # @return [String, nil] optional display title for the resource + # @!attribute [r] size + # @return [Integer, nil] optional size of the resource in bytes + attr_reader :uri, :name, :description, :mime_type, :annotations, :title, :size # Initialize a resource link # @param uri [String] URI of the linked resource @@ -23,12 +27,16 @@ class ResourceLink # @param description [String, nil] optional human-readable description # @param mime_type [String, nil] optional MIME type of the resource # @param annotations [Hash, nil] optional annotations that provide hints to clients - def initialize(uri:, name:, description: nil, mime_type: nil, annotations: nil) + # @param title [String, nil] optional display title for the resource + # @param size [Integer, nil] optional size of the resource in bytes + def initialize(uri:, name:, description: nil, mime_type: nil, annotations: nil, title: nil, size: nil) @uri = uri @name = name @description = description @mime_type = mime_type @annotations = annotations + @title = title + @size = size end # Create a ResourceLink instance from JSON data @@ -40,7 +48,9 @@ def self.from_json(data) name: data['name'], description: data['description'], mime_type: data['mimeType'], - annotations: data['annotations'] + annotations: data['annotations'], + title: data['title'], + size: data['size'] ) end