Skip to content

Commit 8136960

Browse files
authored
Merge pull request #173 from dhq-boiler/pr-chain/2-mcp-tool-adapter
Add McpToolAdapter to convert MCP tools to LLM::Function
2 parents 3d088c6 + 1112dc6 commit 8136960

2 files changed

Lines changed: 139 additions & 0 deletions

File tree

app/services/mcp_tool_adapter.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
class McpToolAdapter
2+
class << self
3+
def to_llm_functions(mcp_tools)
4+
mcp_tools.map { build_function(it) }
5+
end
6+
7+
private
8+
9+
def build_function(mcp_tool)
10+
server_url = mcp_tool.mcp_server.url
11+
tool_name = mcp_tool.name
12+
13+
LLM::Function.new(tool_name) do |fn|
14+
fn.description mcp_tool.description
15+
set_params(fn, mcp_tool.input_schema)
16+
fn.define ->(**arguments) {
17+
McpClient.new(server_url).tap(&:initialize_connection!).call_tool!(tool_name, arguments)
18+
}
19+
end
20+
end
21+
22+
def set_params(fn, input_schema)
23+
return unless input_schema.is_a?(Hash)
24+
25+
schema = input_schema.deep_symbolize_keys
26+
fn.instance_variable_set(:@params, schema)
27+
end
28+
end
29+
end
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
require 'rails_helper'
2+
3+
RSpec.describe McpToolAdapter do
4+
let(:user) { User.create!(email: "test@example.com", google_id: "google-123") }
5+
let(:mcp_server) do
6+
McpServer.create!(
7+
user: user,
8+
name: "Test Server",
9+
url: "https://example.com/mcp",
10+
active: true,
11+
server_name: "test-server",
12+
server_version: "1.0.0",
13+
protocol_version: "2025-03-26"
14+
)
15+
end
16+
17+
let(:mcp_tool) do
18+
McpTool.create!(
19+
mcp_server: mcp_server,
20+
name: "read_file",
21+
description: "Read a file from disk",
22+
input_schema: {
23+
"type" => "object",
24+
"properties" => {
25+
"path" => { "type" => "string", "description" => "File path to read" }
26+
},
27+
"required" => [ "path" ]
28+
},
29+
active: true
30+
)
31+
end
32+
33+
describe '.to_llm_functions' do
34+
it 'converts MCP tools to LLM::Function objects' do
35+
functions = described_class.to_llm_functions([ mcp_tool ])
36+
37+
expect(functions.length).to eq(1)
38+
expect(functions[0]).to be_a(LLM::Function)
39+
expect(functions[0].name).to eq("read_file")
40+
expect(functions[0].description).to eq("Read a file from disk")
41+
end
42+
43+
it 'sets input schema as function params' do
44+
functions = described_class.to_llm_functions([ mcp_tool ])
45+
fn = functions[0]
46+
47+
params = fn.params
48+
expect(params[:type]).to eq("object")
49+
expect(params[:properties][:path][:type]).to eq("string")
50+
expect(params[:required]).to eq([ "path" ])
51+
end
52+
53+
it 'creates a callable runner that invokes MCP server' do
54+
functions = described_class.to_llm_functions([ mcp_tool ])
55+
fn = functions[0]
56+
57+
mock_client = instance_double(McpClient)
58+
allow(McpClient).to receive(:new).with("https://example.com/mcp").and_return(mock_client)
59+
allow(mock_client).to receive(:initialize_connection!)
60+
allow(mock_client).to receive(:call_tool!).with("read_file", { path: "/tmp/test.txt" }).and_return(
61+
{ "content" => [ { "type" => "text", "text" => "file content" } ] }
62+
)
63+
64+
fn.id = "call-123"
65+
fn.arguments = { path: "/tmp/test.txt" }
66+
result = fn.call
67+
68+
expect(result).to be_a(LLM::Function::Return)
69+
expect(result.id).to eq("call-123")
70+
expect(result.name).to eq("read_file")
71+
expect(result.value["content"][0]["text"]).to eq("file content")
72+
end
73+
74+
it 'handles multiple tools' do
75+
tool2 = McpTool.create!(
76+
mcp_server: mcp_server,
77+
name: "write_file",
78+
description: "Write a file to disk",
79+
input_schema: {
80+
"type" => "object",
81+
"properties" => {
82+
"path" => { "type" => "string" },
83+
"content" => { "type" => "string" }
84+
},
85+
"required" => [ "path", "content" ]
86+
},
87+
active: true
88+
)
89+
90+
functions = described_class.to_llm_functions([ mcp_tool, tool2 ])
91+
92+
expect(functions.length).to eq(2)
93+
expect(functions.map(&:name)).to eq([ "read_file", "write_file" ])
94+
end
95+
96+
it 'handles tool with nil input_schema gracefully' do
97+
tool_no_schema = McpTool.new(
98+
mcp_server: mcp_server,
99+
name: "ping",
100+
description: "Ping the server",
101+
input_schema: { "type" => "object" },
102+
active: true
103+
)
104+
tool_no_schema.save!
105+
106+
functions = described_class.to_llm_functions([ tool_no_schema ])
107+
expect(functions[0].name).to eq("ping")
108+
end
109+
end
110+
end

0 commit comments

Comments
 (0)