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 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,2 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
@@ -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,7 @@
1
+ module Html2mdMcpClient
2
+ class Error < StandardError; end
3
+ class ConnectionError < Error; end
4
+ class ProtocolError < Error; end
5
+ class ToolError < Error; end
6
+ class NotConnectedError < Error; end
7
+ 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,3 @@
1
+ module Html2mdMcpClient
2
+ VERSION = '0.1.0'.freeze
3
+ 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
@@ -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