html2md_mcp_client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/Gemfile +2 -0
- data/html2md_mcp_client.gemspec +22 -0
- data/lib/html2md_mcp_client/client.rb +160 -0
- data/lib/html2md_mcp_client/errors.rb +7 -0
- data/lib/html2md_mcp_client/transport/http.rb +83 -0
- data/lib/html2md_mcp_client/transport/stdio.rb +64 -0
- data/lib/html2md_mcp_client/version.rb +3 -0
- data/lib/html2md_mcp_client.rb +32 -0
- data/spec/html2md_mcp_client/client_spec.rb +294 -0
- data/spec/html2md_mcp_client/transport/http_spec.rb +138 -0
- data/spec/html2md_mcp_client/transport/stdio_spec.rb +121 -0
- data/spec/html2md_mcp_client_spec.rb +35 -0
- data/spec/spec_helper.rb +41 -0
- metadata +90 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 34d52357bfe3faf50a85c791fa1414d29eb0ab42d83b80626097b7adf833e124
|
|
4
|
+
data.tar.gz: 7dfb0223f53396746eaf21a5269ee67484a558b9b73cb3098f6a3e70e68a8f18
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 15e7d4a875fa147807dd36c0e718a1b09416a74851181cf0d3dea2fecc26c4cc769b00aad5e254a0960da224bc9ef817e3b64b60dc9684e10e1148216f53f420
|
|
7
|
+
data.tar.gz: ac8489cb3eef307458c09610f09dfb13f99f54ea282a7ce14cd630854af819b78d6880e8d1f0b0eedaf6cc7e897b2de5d73ddd735e6e7d34cf1d3337e88210b2
|
data/Gemfile
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
$:.push File.expand_path('../lib', __FILE__)
|
|
2
|
+
require 'html2md_mcp_client/version'
|
|
3
|
+
|
|
4
|
+
Gem::Specification.new do |s|
|
|
5
|
+
s.name = 'html2md_mcp_client'
|
|
6
|
+
s.version = Html2mdMcpClient::VERSION
|
|
7
|
+
s.platform = Gem::Platform::RUBY
|
|
8
|
+
s.authors = ['Searchbird']
|
|
9
|
+
s.email = ['dev@searchbird.io']
|
|
10
|
+
s.homepage = 'https://github.com/roscom/html2md_mcp_client'
|
|
11
|
+
s.summary = 'Ruby client for the Model Context Protocol (MCP)'
|
|
12
|
+
s.description = 'Connects to MCP servers over HTTP or stdio. Supports tools, resources, and prompts.'
|
|
13
|
+
s.license = 'MIT'
|
|
14
|
+
|
|
15
|
+
s.required_ruby_version = '>= 2.5'
|
|
16
|
+
|
|
17
|
+
s.files = Dir['lib/**/*'] + %w[Gemfile html2md_mcp_client.gemspec]
|
|
18
|
+
s.test_files = Dir['spec/**/*']
|
|
19
|
+
|
|
20
|
+
s.add_development_dependency 'rspec', '~> 3.0'
|
|
21
|
+
s.add_development_dependency 'webmock', '~> 3.0'
|
|
22
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'securerandom'
|
|
3
|
+
|
|
4
|
+
module Html2mdMcpClient
|
|
5
|
+
class Client
|
|
6
|
+
JSONRPC_VERSION = '2.0'.freeze
|
|
7
|
+
PROTOCOL_VERSION = '2025-03-26'.freeze
|
|
8
|
+
|
|
9
|
+
attr_reader :transport, :server_info, :capabilities
|
|
10
|
+
|
|
11
|
+
def initialize(transport, client_name: 'html2md_mcp_client', client_version: Html2mdMcpClient::VERSION)
|
|
12
|
+
@transport = transport
|
|
13
|
+
@client_name = client_name
|
|
14
|
+
@client_version = client_version
|
|
15
|
+
@request_id = 0
|
|
16
|
+
@connected = false
|
|
17
|
+
@tools_cache = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# --- Connection lifecycle ---
|
|
21
|
+
|
|
22
|
+
def connect!
|
|
23
|
+
return self if @connected
|
|
24
|
+
|
|
25
|
+
@transport.start if @transport.respond_to?(:start)
|
|
26
|
+
|
|
27
|
+
result = request('initialize', {
|
|
28
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
29
|
+
capabilities: {},
|
|
30
|
+
clientInfo: { name: @client_name, version: @client_version }
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
@server_info = result['serverInfo']
|
|
34
|
+
@capabilities = result['capabilities'] || {}
|
|
35
|
+
|
|
36
|
+
notify('notifications/initialized')
|
|
37
|
+
@connected = true
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def disconnect!
|
|
42
|
+
return unless @connected
|
|
43
|
+
@transport.close
|
|
44
|
+
@connected = false
|
|
45
|
+
@tools_cache = nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def connected?
|
|
49
|
+
@connected
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# --- Tools ---
|
|
53
|
+
|
|
54
|
+
# Returns array of tool definitions: [{ "name" => ..., "description" => ..., "inputSchema" => ... }]
|
|
55
|
+
def list_tools
|
|
56
|
+
ensure_connected!
|
|
57
|
+
@tools_cache ||= begin
|
|
58
|
+
result = request('tools/list', {})
|
|
59
|
+
result['tools'] || []
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Call a tool. Returns the content array.
|
|
64
|
+
# Raises ToolError if the server signals an error.
|
|
65
|
+
def call_tool(name, arguments = {})
|
|
66
|
+
ensure_connected!
|
|
67
|
+
result = request('tools/call', { name: name, arguments: arguments })
|
|
68
|
+
|
|
69
|
+
if result['isError']
|
|
70
|
+
texts = Array(result['content']).select { |c| c['type'] == 'text' }.map { |c| c['text'] }
|
|
71
|
+
raise ToolError, "Tool '#{name}' error: #{texts.join('; ')}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
result['content'] || []
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Convenience: call a tool and return joined text content.
|
|
78
|
+
def tool_text(name, arguments = {})
|
|
79
|
+
call_tool(name, arguments)
|
|
80
|
+
.select { |c| c['type'] == 'text' }
|
|
81
|
+
.map { |c| c['text'] }
|
|
82
|
+
.join("\n")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Find a tool definition by name. Returns nil if not found.
|
|
86
|
+
def find_tool(name)
|
|
87
|
+
list_tools.find { |t| t['name'] == name }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# --- Resources ---
|
|
91
|
+
|
|
92
|
+
def list_resources
|
|
93
|
+
ensure_connected!
|
|
94
|
+
result = request('resources/list', {})
|
|
95
|
+
result['resources'] || []
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def read_resource(uri)
|
|
99
|
+
ensure_connected!
|
|
100
|
+
result = request('resources/read', { uri: uri })
|
|
101
|
+
result['contents'] || []
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# --- Prompts ---
|
|
105
|
+
|
|
106
|
+
def list_prompts
|
|
107
|
+
ensure_connected!
|
|
108
|
+
result = request('prompts/list', {})
|
|
109
|
+
result['prompts'] || []
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def get_prompt(name, arguments = {})
|
|
113
|
+
ensure_connected!
|
|
114
|
+
request('prompts/get', { name: name, arguments: arguments })
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def ensure_connected!
|
|
120
|
+
raise NotConnectedError, 'Call connect! before making requests' unless @connected
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def next_id
|
|
124
|
+
@request_id += 1
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def request(method, params)
|
|
128
|
+
payload = {
|
|
129
|
+
jsonrpc: JSONRPC_VERSION,
|
|
130
|
+
id: next_id,
|
|
131
|
+
method: method,
|
|
132
|
+
params: params
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
response = @transport.send_request(payload)
|
|
136
|
+
validate_response!(response, payload[:id])
|
|
137
|
+
response['result']
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def notify(method, params = {})
|
|
141
|
+
payload = {
|
|
142
|
+
jsonrpc: JSONRPC_VERSION,
|
|
143
|
+
method: method,
|
|
144
|
+
params: params
|
|
145
|
+
}
|
|
146
|
+
@transport.send_notification(payload)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def validate_response!(response, expected_id)
|
|
150
|
+
if response['error']
|
|
151
|
+
err = response['error']
|
|
152
|
+
raise ProtocolError, "Server error #{err['code']}: #{err['message']}"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
unless response['id'] == expected_id
|
|
156
|
+
raise ProtocolError, "Response ID mismatch: expected #{expected_id}, got #{response['id']}"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
require 'net/http'
|
|
2
|
+
require 'json'
|
|
3
|
+
|
|
4
|
+
module Html2mdMcpClient
|
|
5
|
+
module Transport
|
|
6
|
+
class Http
|
|
7
|
+
attr_reader :session_id
|
|
8
|
+
|
|
9
|
+
def initialize(url, headers: {})
|
|
10
|
+
@uri = URI(url)
|
|
11
|
+
@headers = headers.merge(
|
|
12
|
+
'Content-Type' => 'application/json',
|
|
13
|
+
'Accept' => 'application/json, text/event-stream'
|
|
14
|
+
)
|
|
15
|
+
@session_id = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def send_request(payload)
|
|
19
|
+
http = build_http
|
|
20
|
+
post = Net::HTTP::Post.new(@uri.request_uri, request_headers)
|
|
21
|
+
post.body = payload.to_json
|
|
22
|
+
|
|
23
|
+
response = http.request(post)
|
|
24
|
+
capture_session_id(response)
|
|
25
|
+
|
|
26
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
27
|
+
raise ConnectionError, "HTTP #{response.code}: #{response.body}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
body = response.body.to_s.strip
|
|
31
|
+
|
|
32
|
+
if response['content-type']&.include?('text/event-stream')
|
|
33
|
+
body = parse_sse(body)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
JSON.parse(body)
|
|
37
|
+
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError => e
|
|
38
|
+
raise ConnectionError, "Cannot connect to #{@uri}: #{e.message}"
|
|
39
|
+
rescue JSON::ParserError => e
|
|
40
|
+
raise ProtocolError, "Invalid JSON response: #{e.message}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def send_notification(payload)
|
|
44
|
+
http = build_http
|
|
45
|
+
post = Net::HTTP::Post.new(@uri.request_uri, request_headers)
|
|
46
|
+
post.body = payload.to_json
|
|
47
|
+
http.request(post)
|
|
48
|
+
rescue StandardError
|
|
49
|
+
nil # fire-and-forget
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def close
|
|
53
|
+
@session_id = nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def build_http
|
|
59
|
+
http = Net::HTTP.new(@uri.host, @uri.port)
|
|
60
|
+
http.use_ssl = (@uri.scheme == 'https')
|
|
61
|
+
http.open_timeout = 10
|
|
62
|
+
http.read_timeout = 120
|
|
63
|
+
http
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def request_headers
|
|
67
|
+
h = @headers.dup
|
|
68
|
+
h['Mcp-Session-Id'] = @session_id if @session_id
|
|
69
|
+
h
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def capture_session_id(response)
|
|
73
|
+
@session_id = response['mcp-session-id'] if response['mcp-session-id']
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def parse_sse(body)
|
|
77
|
+
data_lines = body.lines.select { |l| l.start_with?('data:') }
|
|
78
|
+
return '{}' if data_lines.empty?
|
|
79
|
+
data_lines.last.sub(/^data:\s*/, '').strip
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
require 'open3'
|
|
2
|
+
require 'json'
|
|
3
|
+
|
|
4
|
+
module Html2mdMcpClient
|
|
5
|
+
module Transport
|
|
6
|
+
class Stdio
|
|
7
|
+
def initialize(command, args: [])
|
|
8
|
+
@command = command
|
|
9
|
+
@args = args
|
|
10
|
+
@stdin = nil
|
|
11
|
+
@stdout = nil
|
|
12
|
+
@stderr = nil
|
|
13
|
+
@wait_thread = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def start
|
|
17
|
+
@stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@command, *@args)
|
|
18
|
+
rescue Errno::ENOENT => e
|
|
19
|
+
raise ConnectionError, "Cannot start '#{@command}': #{e.message}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def send_request(payload)
|
|
23
|
+
write_line(payload.to_json)
|
|
24
|
+
|
|
25
|
+
loop do
|
|
26
|
+
line = @stdout.gets
|
|
27
|
+
raise ConnectionError, 'MCP server process terminated' if line.nil?
|
|
28
|
+
|
|
29
|
+
line = line.strip
|
|
30
|
+
next if line.empty?
|
|
31
|
+
|
|
32
|
+
begin
|
|
33
|
+
parsed = JSON.parse(line)
|
|
34
|
+
return parsed if parsed.key?('id')
|
|
35
|
+
rescue JSON::ParserError
|
|
36
|
+
next
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
rescue IOError, Errno::EPIPE => e
|
|
40
|
+
raise ConnectionError, "Lost connection to MCP server: #{e.message}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def send_notification(payload)
|
|
44
|
+
write_line(payload.to_json)
|
|
45
|
+
rescue IOError, Errno::EPIPE
|
|
46
|
+
nil # fire-and-forget
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def close
|
|
50
|
+
@stdin&.close rescue nil
|
|
51
|
+
@stdout&.close rescue nil
|
|
52
|
+
@stderr&.close rescue nil
|
|
53
|
+
@wait_thread&.kill rescue nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def write_line(data)
|
|
59
|
+
@stdin.puts(data)
|
|
60
|
+
@stdin.flush
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require 'html2md_mcp_client/version'
|
|
2
|
+
require 'html2md_mcp_client/errors'
|
|
3
|
+
require 'html2md_mcp_client/transport/http'
|
|
4
|
+
require 'html2md_mcp_client/transport/stdio'
|
|
5
|
+
require 'html2md_mcp_client/client'
|
|
6
|
+
|
|
7
|
+
module Html2mdMcpClient
|
|
8
|
+
# Connect to an MCP server over HTTP.
|
|
9
|
+
#
|
|
10
|
+
# client = Html2mdMcpClient.http("http://localhost:3001/mcp")
|
|
11
|
+
# client.connect!
|
|
12
|
+
# client.list_tools
|
|
13
|
+
# client.call_tool("my_tool", { arg: "value" })
|
|
14
|
+
# client.disconnect!
|
|
15
|
+
#
|
|
16
|
+
def self.http(url, headers: {}, **opts)
|
|
17
|
+
transport = Transport::Http.new(url, headers: headers)
|
|
18
|
+
Client.new(transport, **opts)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Connect to an MCP server over stdio (spawns a subprocess).
|
|
22
|
+
#
|
|
23
|
+
# client = Html2mdMcpClient.stdio("npx", args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"])
|
|
24
|
+
# client.connect!
|
|
25
|
+
# client.list_tools
|
|
26
|
+
# client.disconnect!
|
|
27
|
+
#
|
|
28
|
+
def self.stdio(command, args: [], **opts)
|
|
29
|
+
transport = Transport::Stdio.new(command, args: args)
|
|
30
|
+
Client.new(transport, **opts)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe Html2mdMcpClient::Client do
|
|
4
|
+
let(:transport) { instance_double('Transport') }
|
|
5
|
+
let(:client) { Html2mdMcpClient::Client.new(transport) }
|
|
6
|
+
|
|
7
|
+
def stub_connect!
|
|
8
|
+
allow(transport).to receive(:send_request)
|
|
9
|
+
.with(hash_including(method: 'initialize'))
|
|
10
|
+
.and_return(jsonrpc_response(1, initialize_result))
|
|
11
|
+
allow(transport).to receive(:send_notification)
|
|
12
|
+
client.connect!
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
describe '#connect!' do
|
|
16
|
+
it 'performs the initialize handshake' do
|
|
17
|
+
expect(transport).to receive(:send_request)
|
|
18
|
+
.with(hash_including(
|
|
19
|
+
jsonrpc: '2.0',
|
|
20
|
+
method: 'initialize',
|
|
21
|
+
params: hash_including(
|
|
22
|
+
protocolVersion: '2025-03-26',
|
|
23
|
+
clientInfo: hash_including(name: 'html2md_mcp_client')
|
|
24
|
+
)
|
|
25
|
+
))
|
|
26
|
+
.and_return(jsonrpc_response(1, initialize_result))
|
|
27
|
+
|
|
28
|
+
expect(transport).to receive(:send_notification)
|
|
29
|
+
.with(hash_including(method: 'notifications/initialized'))
|
|
30
|
+
|
|
31
|
+
client.connect!
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'stores server_info and capabilities' do
|
|
35
|
+
stub_connect!
|
|
36
|
+
expect(client.server_info).to eq('name' => 'test-server', 'version' => '1.0.0')
|
|
37
|
+
expect(client.capabilities).to eq('tools' => {})
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'returns self' do
|
|
41
|
+
stub_connect!
|
|
42
|
+
expect(client).to be_connected
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'is idempotent when already connected' do
|
|
46
|
+
stub_connect!
|
|
47
|
+
expect(transport).not_to receive(:send_request)
|
|
48
|
+
client.connect!
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'calls start on transport if it responds to start' do
|
|
52
|
+
transport_with_start = instance_double('StdioTransport', start: nil)
|
|
53
|
+
c = Html2mdMcpClient::Client.new(transport_with_start)
|
|
54
|
+
|
|
55
|
+
allow(transport_with_start).to receive(:send_request)
|
|
56
|
+
.and_return(jsonrpc_response(1, initialize_result))
|
|
57
|
+
allow(transport_with_start).to receive(:send_notification)
|
|
58
|
+
|
|
59
|
+
expect(transport_with_start).to receive(:start)
|
|
60
|
+
c.connect!
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
describe '#disconnect!' do
|
|
65
|
+
it 'closes the transport' do
|
|
66
|
+
stub_connect!
|
|
67
|
+
expect(transport).to receive(:close)
|
|
68
|
+
client.disconnect!
|
|
69
|
+
expect(client).not_to be_connected
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'does nothing when not connected' do
|
|
73
|
+
expect(transport).not_to receive(:close)
|
|
74
|
+
client.disconnect!
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'clears the tools cache' do
|
|
78
|
+
stub_connect!
|
|
79
|
+
|
|
80
|
+
allow(transport).to receive(:send_request)
|
|
81
|
+
.with(hash_including(method: 'tools/list'))
|
|
82
|
+
.and_return(jsonrpc_response(2, { 'tools' => [{ 'name' => 'foo' }] }))
|
|
83
|
+
|
|
84
|
+
client.list_tools
|
|
85
|
+
|
|
86
|
+
expect(transport).to receive(:close)
|
|
87
|
+
client.disconnect!
|
|
88
|
+
|
|
89
|
+
# Reconnect and verify cache is cleared
|
|
90
|
+
allow(transport).to receive(:send_request)
|
|
91
|
+
.with(hash_including(method: 'initialize'))
|
|
92
|
+
.and_return(jsonrpc_response(3, initialize_result))
|
|
93
|
+
allow(transport).to receive(:send_notification)
|
|
94
|
+
client.connect!
|
|
95
|
+
|
|
96
|
+
allow(transport).to receive(:send_request)
|
|
97
|
+
.with(hash_including(method: 'tools/list'))
|
|
98
|
+
.and_return(jsonrpc_response(4, { 'tools' => [{ 'name' => 'bar' }] }))
|
|
99
|
+
|
|
100
|
+
expect(client.list_tools.first['name']).to eq('bar')
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
describe '#list_tools' do
|
|
105
|
+
before { stub_connect! }
|
|
106
|
+
|
|
107
|
+
it 'returns tool definitions' do
|
|
108
|
+
tools = [
|
|
109
|
+
{ 'name' => 'html_to_markdown', 'description' => 'Convert HTML to Markdown', 'inputSchema' => {} }
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
allow(transport).to receive(:send_request)
|
|
113
|
+
.with(hash_including(method: 'tools/list'))
|
|
114
|
+
.and_return(jsonrpc_response(2, { 'tools' => tools }))
|
|
115
|
+
|
|
116
|
+
expect(client.list_tools).to eq(tools)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it 'caches the result' do
|
|
120
|
+
allow(transport).to receive(:send_request)
|
|
121
|
+
.with(hash_including(method: 'tools/list'))
|
|
122
|
+
.once
|
|
123
|
+
.and_return(jsonrpc_response(2, { 'tools' => [{ 'name' => 'foo' }] }))
|
|
124
|
+
|
|
125
|
+
client.list_tools
|
|
126
|
+
client.list_tools
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it 'returns empty array when no tools' do
|
|
130
|
+
allow(transport).to receive(:send_request)
|
|
131
|
+
.with(hash_including(method: 'tools/list'))
|
|
132
|
+
.and_return(jsonrpc_response(2, {}))
|
|
133
|
+
|
|
134
|
+
expect(client.list_tools).to eq([])
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it 'raises NotConnectedError when not connected' do
|
|
138
|
+
new_client = Html2mdMcpClient::Client.new(transport)
|
|
139
|
+
expect { new_client.list_tools }.to raise_error(Html2mdMcpClient::NotConnectedError)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
describe '#call_tool' do
|
|
144
|
+
before { stub_connect! }
|
|
145
|
+
|
|
146
|
+
it 'returns content array' do
|
|
147
|
+
content = [{ 'type' => 'text', 'text' => '# Hello' }]
|
|
148
|
+
|
|
149
|
+
allow(transport).to receive(:send_request)
|
|
150
|
+
.with(hash_including(method: 'tools/call', params: { name: 'my_tool', arguments: { url: 'http://x.com' } }))
|
|
151
|
+
.and_return(jsonrpc_response(2, { 'content' => content }))
|
|
152
|
+
|
|
153
|
+
expect(client.call_tool('my_tool', { url: 'http://x.com' })).to eq(content)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it 'raises ToolError when server signals error' do
|
|
157
|
+
allow(transport).to receive(:send_request)
|
|
158
|
+
.with(hash_including(method: 'tools/call'))
|
|
159
|
+
.and_return(jsonrpc_response(2, {
|
|
160
|
+
'isError' => true,
|
|
161
|
+
'content' => [{ 'type' => 'text', 'text' => 'Something went wrong' }]
|
|
162
|
+
}))
|
|
163
|
+
|
|
164
|
+
expect { client.call_tool('bad_tool') }.to raise_error(Html2mdMcpClient::ToolError, /Something went wrong/)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
it 'returns empty array when no content' do
|
|
168
|
+
allow(transport).to receive(:send_request)
|
|
169
|
+
.with(hash_including(method: 'tools/call'))
|
|
170
|
+
.and_return(jsonrpc_response(2, {}))
|
|
171
|
+
|
|
172
|
+
expect(client.call_tool('empty_tool')).to eq([])
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
describe '#tool_text' do
|
|
177
|
+
before { stub_connect! }
|
|
178
|
+
|
|
179
|
+
it 'returns joined text content' do
|
|
180
|
+
content = [
|
|
181
|
+
{ 'type' => 'text', 'text' => 'Line 1' },
|
|
182
|
+
{ 'type' => 'image', 'data' => 'base64...' },
|
|
183
|
+
{ 'type' => 'text', 'text' => 'Line 2' }
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
allow(transport).to receive(:send_request)
|
|
187
|
+
.with(hash_including(method: 'tools/call'))
|
|
188
|
+
.and_return(jsonrpc_response(2, { 'content' => content }))
|
|
189
|
+
|
|
190
|
+
expect(client.tool_text('my_tool')).to eq("Line 1\nLine 2")
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
describe '#find_tool' do
|
|
195
|
+
before { stub_connect! }
|
|
196
|
+
|
|
197
|
+
it 'returns matching tool definition' do
|
|
198
|
+
tools = [
|
|
199
|
+
{ 'name' => 'foo', 'description' => 'Foo tool' },
|
|
200
|
+
{ 'name' => 'bar', 'description' => 'Bar tool' }
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
allow(transport).to receive(:send_request)
|
|
204
|
+
.with(hash_including(method: 'tools/list'))
|
|
205
|
+
.and_return(jsonrpc_response(2, { 'tools' => tools }))
|
|
206
|
+
|
|
207
|
+
expect(client.find_tool('bar')).to eq({ 'name' => 'bar', 'description' => 'Bar tool' })
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
it 'returns nil when not found' do
|
|
211
|
+
allow(transport).to receive(:send_request)
|
|
212
|
+
.with(hash_including(method: 'tools/list'))
|
|
213
|
+
.and_return(jsonrpc_response(2, { 'tools' => [] }))
|
|
214
|
+
|
|
215
|
+
expect(client.find_tool('missing')).to be_nil
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
describe '#list_resources' do
|
|
220
|
+
before { stub_connect! }
|
|
221
|
+
|
|
222
|
+
it 'returns resource definitions' do
|
|
223
|
+
resources = [{ 'uri' => 'file:///tmp/data.json', 'name' => 'data.json' }]
|
|
224
|
+
|
|
225
|
+
allow(transport).to receive(:send_request)
|
|
226
|
+
.with(hash_including(method: 'resources/list'))
|
|
227
|
+
.and_return(jsonrpc_response(2, { 'resources' => resources }))
|
|
228
|
+
|
|
229
|
+
expect(client.list_resources).to eq(resources)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
describe '#read_resource' do
|
|
234
|
+
before { stub_connect! }
|
|
235
|
+
|
|
236
|
+
it 'returns resource contents' do
|
|
237
|
+
contents = [{ 'uri' => 'file:///tmp/data.json', 'text' => '{"key":"value"}' }]
|
|
238
|
+
|
|
239
|
+
allow(transport).to receive(:send_request)
|
|
240
|
+
.with(hash_including(method: 'resources/read', params: { uri: 'file:///tmp/data.json' }))
|
|
241
|
+
.and_return(jsonrpc_response(2, { 'contents' => contents }))
|
|
242
|
+
|
|
243
|
+
expect(client.read_resource('file:///tmp/data.json')).to eq(contents)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
describe '#list_prompts' do
|
|
248
|
+
before { stub_connect! }
|
|
249
|
+
|
|
250
|
+
it 'returns prompt definitions' do
|
|
251
|
+
prompts = [{ 'name' => 'summarize', 'description' => 'Summarize text' }]
|
|
252
|
+
|
|
253
|
+
allow(transport).to receive(:send_request)
|
|
254
|
+
.with(hash_including(method: 'prompts/list'))
|
|
255
|
+
.and_return(jsonrpc_response(2, { 'prompts' => prompts }))
|
|
256
|
+
|
|
257
|
+
expect(client.list_prompts).to eq(prompts)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
describe '#get_prompt' do
|
|
262
|
+
before { stub_connect! }
|
|
263
|
+
|
|
264
|
+
it 'returns prompt result' do
|
|
265
|
+
result = { 'messages' => [{ 'role' => 'user', 'content' => 'Summarize: hello' }] }
|
|
266
|
+
|
|
267
|
+
allow(transport).to receive(:send_request)
|
|
268
|
+
.with(hash_including(method: 'prompts/get', params: { name: 'summarize', arguments: { text: 'hello' } }))
|
|
269
|
+
.and_return(jsonrpc_response(2, result))
|
|
270
|
+
|
|
271
|
+
expect(client.get_prompt('summarize', { text: 'hello' })).to eq(result)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
describe 'error handling' do
|
|
276
|
+
before { stub_connect! }
|
|
277
|
+
|
|
278
|
+
it 'raises ProtocolError on JSON-RPC error response' do
|
|
279
|
+
allow(transport).to receive(:send_request)
|
|
280
|
+
.with(hash_including(method: 'tools/list'))
|
|
281
|
+
.and_return(jsonrpc_error(2, -32601, 'Method not found'))
|
|
282
|
+
|
|
283
|
+
expect { client.list_tools }.to raise_error(Html2mdMcpClient::ProtocolError, /Method not found/)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
it 'raises ProtocolError on ID mismatch' do
|
|
287
|
+
allow(transport).to receive(:send_request)
|
|
288
|
+
.with(hash_including(method: 'tools/list'))
|
|
289
|
+
.and_return(jsonrpc_response(999, { 'tools' => [] }))
|
|
290
|
+
|
|
291
|
+
expect { client.list_tools }.to raise_error(Html2mdMcpClient::ProtocolError, /ID mismatch/)
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe Html2mdMcpClient::Transport::Http do
|
|
4
|
+
let(:url) { 'http://localhost:3001/mcp' }
|
|
5
|
+
let(:transport) { described_class.new(url) }
|
|
6
|
+
|
|
7
|
+
describe '#send_request' do
|
|
8
|
+
it 'sends a POST with JSON body and returns parsed response' do
|
|
9
|
+
payload = { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }
|
|
10
|
+
response_body = { 'jsonrpc' => '2.0', 'id' => 1, 'result' => { 'ok' => true } }
|
|
11
|
+
|
|
12
|
+
stub_request(:post, url)
|
|
13
|
+
.with(
|
|
14
|
+
body: payload.to_json,
|
|
15
|
+
headers: { 'Content-Type' => 'application/json', 'Accept' => 'application/json, text/event-stream' }
|
|
16
|
+
)
|
|
17
|
+
.to_return(status: 200, body: response_body.to_json, headers: { 'Content-Type' => 'application/json' })
|
|
18
|
+
|
|
19
|
+
expect(transport.send_request(payload)).to eq(response_body)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'captures session ID from response headers' do
|
|
23
|
+
stub_request(:post, url)
|
|
24
|
+
.to_return(
|
|
25
|
+
status: 200,
|
|
26
|
+
body: { 'jsonrpc' => '2.0', 'id' => 1, 'result' => {} }.to_json,
|
|
27
|
+
headers: { 'Mcp-Session-Id' => 'abc-123' }
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
transport.send_request({ jsonrpc: '2.0', id: 1, method: 'test', params: {} })
|
|
31
|
+
expect(transport.session_id).to eq('abc-123')
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'sends session ID on subsequent requests' do
|
|
35
|
+
# First request sets session
|
|
36
|
+
stub_request(:post, url)
|
|
37
|
+
.to_return(
|
|
38
|
+
status: 200,
|
|
39
|
+
body: { 'jsonrpc' => '2.0', 'id' => 1, 'result' => {} }.to_json,
|
|
40
|
+
headers: { 'Mcp-Session-Id' => 'abc-123' }
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
transport.send_request({ jsonrpc: '2.0', id: 1, method: 'test', params: {} })
|
|
44
|
+
|
|
45
|
+
# Second request should include session header
|
|
46
|
+
stub_request(:post, url)
|
|
47
|
+
.with(headers: { 'Mcp-Session-Id' => 'abc-123' })
|
|
48
|
+
.to_return(
|
|
49
|
+
status: 200,
|
|
50
|
+
body: { 'jsonrpc' => '2.0', 'id' => 2, 'result' => {} }.to_json
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
transport.send_request({ jsonrpc: '2.0', id: 2, method: 'test2', params: {} })
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'parses SSE responses' do
|
|
57
|
+
sse_body = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"tools\":[]}}\n\n"
|
|
58
|
+
|
|
59
|
+
stub_request(:post, url)
|
|
60
|
+
.to_return(
|
|
61
|
+
status: 200,
|
|
62
|
+
body: sse_body,
|
|
63
|
+
headers: { 'Content-Type' => 'text/event-stream' }
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
result = transport.send_request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} })
|
|
67
|
+
expect(result).to eq({ 'jsonrpc' => '2.0', 'id' => 1, 'result' => { 'tools' => [] } })
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'raises ConnectionError on HTTP error status' do
|
|
71
|
+
stub_request(:post, url).to_return(status: 500, body: 'Internal Server Error')
|
|
72
|
+
|
|
73
|
+
expect { transport.send_request({ jsonrpc: '2.0', id: 1, method: 'test', params: {} }) }
|
|
74
|
+
.to raise_error(Html2mdMcpClient::ConnectionError, /HTTP 500/)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'raises ConnectionError on connection refused' do
|
|
78
|
+
stub_request(:post, url).to_raise(Errno::ECONNREFUSED)
|
|
79
|
+
|
|
80
|
+
expect { transport.send_request({ jsonrpc: '2.0', id: 1, method: 'test', params: {} }) }
|
|
81
|
+
.to raise_error(Html2mdMcpClient::ConnectionError, /Cannot connect/)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'raises ProtocolError on invalid JSON' do
|
|
85
|
+
stub_request(:post, url).to_return(status: 200, body: 'not json')
|
|
86
|
+
|
|
87
|
+
expect { transport.send_request({ jsonrpc: '2.0', id: 1, method: 'test', params: {} }) }
|
|
88
|
+
.to raise_error(Html2mdMcpClient::ProtocolError, /Invalid JSON/)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
describe '#send_notification' do
|
|
93
|
+
it 'sends a POST and does not raise on failure' do
|
|
94
|
+
stub_request(:post, url).to_return(status: 500)
|
|
95
|
+
expect { transport.send_notification({ jsonrpc: '2.0', method: 'notify' }) }.not_to raise_error
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
describe '#close' do
|
|
100
|
+
it 'clears the session ID' do
|
|
101
|
+
stub_request(:post, url)
|
|
102
|
+
.to_return(
|
|
103
|
+
status: 200,
|
|
104
|
+
body: { 'jsonrpc' => '2.0', 'id' => 1, 'result' => {} }.to_json,
|
|
105
|
+
headers: { 'Mcp-Session-Id' => 'abc-123' }
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
transport.send_request({ jsonrpc: '2.0', id: 1, method: 'test', params: {} })
|
|
109
|
+
expect(transport.session_id).to eq('abc-123')
|
|
110
|
+
|
|
111
|
+
transport.close
|
|
112
|
+
expect(transport.session_id).to be_nil
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
describe 'custom headers' do
|
|
117
|
+
it 'includes custom headers in requests' do
|
|
118
|
+
t = described_class.new(url, headers: { 'Authorization' => 'Bearer tok123' })
|
|
119
|
+
|
|
120
|
+
stub_request(:post, url)
|
|
121
|
+
.with(headers: { 'Authorization' => 'Bearer tok123' })
|
|
122
|
+
.to_return(status: 200, body: { 'jsonrpc' => '2.0', 'id' => 1, 'result' => {} }.to_json)
|
|
123
|
+
|
|
124
|
+
t.send_request({ jsonrpc: '2.0', id: 1, method: 'test', params: {} })
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
describe 'HTTPS' do
|
|
129
|
+
it 'enables SSL for https URLs' do
|
|
130
|
+
t = described_class.new('https://secure.example.com/mcp')
|
|
131
|
+
|
|
132
|
+
stub_request(:post, 'https://secure.example.com/mcp')
|
|
133
|
+
.to_return(status: 200, body: { 'jsonrpc' => '2.0', 'id' => 1, 'result' => {} }.to_json)
|
|
134
|
+
|
|
135
|
+
t.send_request({ jsonrpc: '2.0', id: 1, method: 'test', params: {} })
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe Html2mdMcpClient::Transport::Stdio do
|
|
4
|
+
let(:transport) { described_class.new('echo', args: ['test']) }
|
|
5
|
+
|
|
6
|
+
describe '#start' do
|
|
7
|
+
it 'spawns the subprocess' do
|
|
8
|
+
stdin = instance_double(IO)
|
|
9
|
+
stdout = instance_double(IO)
|
|
10
|
+
stderr = instance_double(IO)
|
|
11
|
+
thread = instance_double(Thread)
|
|
12
|
+
|
|
13
|
+
expect(Open3).to receive(:popen3).with('echo', 'test').and_return([stdin, stdout, stderr, thread])
|
|
14
|
+
transport.start
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'raises ConnectionError when command not found' do
|
|
18
|
+
bad = described_class.new('nonexistent_command_xyz')
|
|
19
|
+
expect { bad.start }.to raise_error(Html2mdMcpClient::ConnectionError, /Cannot start/)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
describe '#send_request' do
|
|
24
|
+
it 'writes JSON and reads the response' do
|
|
25
|
+
stdin = StringIO.new
|
|
26
|
+
stdout = StringIO.new("{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}\n")
|
|
27
|
+
stderr = StringIO.new
|
|
28
|
+
thread = instance_double(Thread)
|
|
29
|
+
|
|
30
|
+
allow(Open3).to receive(:popen3).and_return([stdin, stdout, stderr, thread])
|
|
31
|
+
transport.start
|
|
32
|
+
|
|
33
|
+
result = transport.send_request({ jsonrpc: '2.0', id: 1, method: 'test', params: {} })
|
|
34
|
+
expect(result).to eq({ 'jsonrpc' => '2.0', 'id' => 1, 'result' => { 'ok' => true } })
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'skips notifications (lines without id)' do
|
|
38
|
+
lines = [
|
|
39
|
+
"{\"jsonrpc\":\"2.0\",\"method\":\"notification\"}\n",
|
|
40
|
+
"{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"found\":true}}\n"
|
|
41
|
+
].join
|
|
42
|
+
stdin = StringIO.new
|
|
43
|
+
stdout = StringIO.new(lines)
|
|
44
|
+
stderr = StringIO.new
|
|
45
|
+
thread = instance_double(Thread)
|
|
46
|
+
|
|
47
|
+
allow(Open3).to receive(:popen3).and_return([stdin, stdout, stderr, thread])
|
|
48
|
+
transport.start
|
|
49
|
+
|
|
50
|
+
result = transport.send_request({ jsonrpc: '2.0', id: 1, method: 'test', params: {} })
|
|
51
|
+
expect(result['result']).to eq({ 'found' => true })
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'skips empty lines and invalid JSON' do
|
|
55
|
+
lines = [
|
|
56
|
+
"\n",
|
|
57
|
+
"not json\n",
|
|
58
|
+
"{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}\n"
|
|
59
|
+
].join
|
|
60
|
+
stdin = StringIO.new
|
|
61
|
+
stdout = StringIO.new(lines)
|
|
62
|
+
stderr = StringIO.new
|
|
63
|
+
thread = instance_double(Thread)
|
|
64
|
+
|
|
65
|
+
allow(Open3).to receive(:popen3).and_return([stdin, stdout, stderr, thread])
|
|
66
|
+
transport.start
|
|
67
|
+
|
|
68
|
+
result = transport.send_request({ jsonrpc: '2.0', id: 1, method: 'test', params: {} })
|
|
69
|
+
expect(result).to eq({ 'jsonrpc' => '2.0', 'id' => 1, 'result' => {} })
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'raises ConnectionError when process terminates (nil from gets)' do
|
|
73
|
+
stdin = StringIO.new
|
|
74
|
+
stdout = StringIO.new # empty — gets returns nil
|
|
75
|
+
stderr = StringIO.new
|
|
76
|
+
thread = instance_double(Thread)
|
|
77
|
+
|
|
78
|
+
allow(Open3).to receive(:popen3).and_return([stdin, stdout, stderr, thread])
|
|
79
|
+
transport.start
|
|
80
|
+
|
|
81
|
+
expect { transport.send_request({ jsonrpc: '2.0', id: 1, method: 'test', params: {} }) }
|
|
82
|
+
.to raise_error(Html2mdMcpClient::ConnectionError, /terminated/)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
describe '#send_notification' do
|
|
87
|
+
it 'writes without raising on pipe error' do
|
|
88
|
+
stdin = instance_double(IO)
|
|
89
|
+
allow(stdin).to receive(:puts).and_raise(Errno::EPIPE)
|
|
90
|
+
allow(stdin).to receive(:flush)
|
|
91
|
+
|
|
92
|
+
stdout = StringIO.new
|
|
93
|
+
stderr = StringIO.new
|
|
94
|
+
thread = instance_double(Thread)
|
|
95
|
+
|
|
96
|
+
allow(Open3).to receive(:popen3).and_return([stdin, stdout, stderr, thread])
|
|
97
|
+
transport.start
|
|
98
|
+
|
|
99
|
+
expect { transport.send_notification({ jsonrpc: '2.0', method: 'notify' }) }.not_to raise_error
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
describe '#close' do
|
|
104
|
+
it 'closes all IO streams and kills the thread' do
|
|
105
|
+
stdin = instance_double(IO, close: nil)
|
|
106
|
+
stdout = instance_double(IO, close: nil)
|
|
107
|
+
stderr = instance_double(IO, close: nil)
|
|
108
|
+
thread = instance_double(Thread, kill: nil)
|
|
109
|
+
|
|
110
|
+
allow(Open3).to receive(:popen3).and_return([stdin, stdout, stderr, thread])
|
|
111
|
+
transport.start
|
|
112
|
+
|
|
113
|
+
expect(stdin).to receive(:close)
|
|
114
|
+
expect(stdout).to receive(:close)
|
|
115
|
+
expect(stderr).to receive(:close)
|
|
116
|
+
expect(thread).to receive(:kill)
|
|
117
|
+
|
|
118
|
+
transport.close
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe Html2mdMcpClient do
|
|
4
|
+
describe '.http' do
|
|
5
|
+
it 'returns a Client with Http transport' do
|
|
6
|
+
client = Html2mdMcpClient.http('http://localhost:3001/mcp')
|
|
7
|
+
expect(client).to be_a(Html2mdMcpClient::Client)
|
|
8
|
+
expect(client.transport).to be_a(Html2mdMcpClient::Transport::Http)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'passes custom headers to transport' do
|
|
12
|
+
client = Html2mdMcpClient.http('http://localhost:3001/mcp', headers: { 'Authorization' => 'Bearer x' })
|
|
13
|
+
expect(client.transport).to be_a(Html2mdMcpClient::Transport::Http)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'passes client_name option' do
|
|
17
|
+
client = Html2mdMcpClient.http('http://localhost:3001/mcp', client_name: 'searchbird')
|
|
18
|
+
expect(client).to be_a(Html2mdMcpClient::Client)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
describe '.stdio' do
|
|
23
|
+
it 'returns a Client with Stdio transport' do
|
|
24
|
+
client = Html2mdMcpClient.stdio('echo', args: ['test'])
|
|
25
|
+
expect(client).to be_a(Html2mdMcpClient::Client)
|
|
26
|
+
expect(client.transport).to be_a(Html2mdMcpClient::Transport::Stdio)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
describe 'VERSION' do
|
|
31
|
+
it 'is defined' do
|
|
32
|
+
expect(Html2mdMcpClient::VERSION).to match(/\A\d+\.\d+\.\d+\z/)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/spec/spec_helper.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require 'html2md_mcp_client'
|
|
2
|
+
require 'webmock/rspec'
|
|
3
|
+
|
|
4
|
+
WebMock.disable_net_connect!
|
|
5
|
+
|
|
6
|
+
# Shared helpers for building JSON-RPC responses
|
|
7
|
+
module JsonRpcHelpers
|
|
8
|
+
def jsonrpc_response(id, result)
|
|
9
|
+
{ 'jsonrpc' => '2.0', 'id' => id, 'result' => result }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def jsonrpc_error(id, code, message)
|
|
13
|
+
{ 'jsonrpc' => '2.0', 'id' => id, 'error' => { 'code' => code, 'message' => message } }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize_result
|
|
17
|
+
{
|
|
18
|
+
'serverInfo' => { 'name' => 'test-server', 'version' => '1.0.0' },
|
|
19
|
+
'capabilities' => { 'tools' => {} }
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Stubs the initialize handshake for HTTP transport tests
|
|
24
|
+
def stub_mcp_init(url)
|
|
25
|
+
stub_request(:post, url)
|
|
26
|
+
.with { |req| JSON.parse(req.body)['method'] == 'initialize' }
|
|
27
|
+
.to_return(
|
|
28
|
+
status: 200,
|
|
29
|
+
headers: { 'Content-Type' => 'application/json', 'Mcp-Session-Id' => 'test-session-123' },
|
|
30
|
+
body: jsonrpc_response(1, initialize_result).to_json
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
stub_request(:post, url)
|
|
34
|
+
.with { |req| JSON.parse(req.body)['method'] == 'notifications/initialized' }
|
|
35
|
+
.to_return(status: 200, body: '')
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
RSpec.configure do |config|
|
|
40
|
+
config.include JsonRpcHelpers
|
|
41
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: html2md_mcp_client
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Searchbird
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-03-14 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: rspec
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '3.0'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '3.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: webmock
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '3.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '3.0'
|
|
41
|
+
description: Connects to MCP servers over HTTP or stdio. Supports tools, resources,
|
|
42
|
+
and prompts.
|
|
43
|
+
email:
|
|
44
|
+
- dev@searchbird.io
|
|
45
|
+
executables: []
|
|
46
|
+
extensions: []
|
|
47
|
+
extra_rdoc_files: []
|
|
48
|
+
files:
|
|
49
|
+
- Gemfile
|
|
50
|
+
- html2md_mcp_client.gemspec
|
|
51
|
+
- lib/html2md_mcp_client.rb
|
|
52
|
+
- lib/html2md_mcp_client/client.rb
|
|
53
|
+
- lib/html2md_mcp_client/errors.rb
|
|
54
|
+
- lib/html2md_mcp_client/transport/http.rb
|
|
55
|
+
- lib/html2md_mcp_client/transport/stdio.rb
|
|
56
|
+
- lib/html2md_mcp_client/version.rb
|
|
57
|
+
- spec/html2md_mcp_client/client_spec.rb
|
|
58
|
+
- spec/html2md_mcp_client/transport/http_spec.rb
|
|
59
|
+
- spec/html2md_mcp_client/transport/stdio_spec.rb
|
|
60
|
+
- spec/html2md_mcp_client_spec.rb
|
|
61
|
+
- spec/spec_helper.rb
|
|
62
|
+
homepage: https://github.com/roscom/html2md_mcp_client
|
|
63
|
+
licenses:
|
|
64
|
+
- MIT
|
|
65
|
+
metadata: {}
|
|
66
|
+
post_install_message:
|
|
67
|
+
rdoc_options: []
|
|
68
|
+
require_paths:
|
|
69
|
+
- lib
|
|
70
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '2.5'
|
|
75
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - ">="
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: '0'
|
|
80
|
+
requirements: []
|
|
81
|
+
rubygems_version: 3.0.3.1
|
|
82
|
+
signing_key:
|
|
83
|
+
specification_version: 4
|
|
84
|
+
summary: Ruby client for the Model Context Protocol (MCP)
|
|
85
|
+
test_files:
|
|
86
|
+
- spec/html2md_mcp_client/transport/http_spec.rb
|
|
87
|
+
- spec/html2md_mcp_client/transport/stdio_spec.rb
|
|
88
|
+
- spec/html2md_mcp_client/client_spec.rb
|
|
89
|
+
- spec/spec_helper.rb
|
|
90
|
+
- spec/html2md_mcp_client_spec.rb
|