Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions lib/mcp_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
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'
Expand Down Expand Up @@ -468,4 +469,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<Hash>] content array from a tool result
# @return [Array<MCPClient::ResourceLink, Hash>] array of typed objects or raw hashes
def self.parse_tool_content(content)
Array(content).map { |item| parse_content_item(item) }
end
end
63 changes: 63 additions & 0 deletions lib/mcp_client/resource_link.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# 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
# @!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
# @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
# @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
# @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'],
title: data['title'],
size: data['size']
)
end

# The content type identifier for this content type
# @return [String] 'resource_link'
def type
'resource_link'
end
end
end
85 changes: 85 additions & 0 deletions spec/lib/mcp_client/parse_tool_content_spec.rb
Original file line number Diff line number Diff line change
@@ -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
118 changes: 118 additions & 0 deletions spec/lib/mcp_client/resource_link_spec.rb
Original file line number Diff line number Diff line change
@@ -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