bundleup-sdk 0.4.0 → 0.4.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3ece90ca061e8b5bbac827ca07dcc35b1c029e235d27ec319cef3117cd0652d0
4
- data.tar.gz: 034fdb9a5ccf8c36aa38a3d8a7870f8c32a7997ff617cfed395fd4e0ff03aaae
3
+ metadata.gz: 7e2898652fed3548927b24223483a9308f826e2899503c4eb711275d9418145a
4
+ data.tar.gz: ec2179acf284beb8c8e94a1079c671ee2a3a5218905d9187648b62120bcbcaa4
5
5
  SHA512:
6
- metadata.gz: eb30ce08198acee62b5ec10469a4a116a8186eab6669fbc1bcaece16899911ca50f5b37a1bc15d6dcc096a87b6c160faa3ea2319c8ce552159aa4347be869b85
7
- data.tar.gz: 38855ea19f0662f62acbddc68e0b439f4171dadf5bba56f29b4dde40c4cfc14f72cf6da871af0faa9039b6a37c5b3188a968bacd3abcb859468053913bd2efe6
6
+ metadata.gz: 4bf6f9e0912c4cefbc510011e4475743517c21fe324c428ccb8e2b44a24104c2fa3299069edcafca8cf1c47879d6620792fada35af230414f34e138dd5742990
7
+ data.tar.gz: 5dce15658d4a193bc89771b8726a1e5faea4326f7c20c76ed7c1b23a9e4eef01c1bf30e7ec785a2f478e2610fc193078f8c1d0adca4ecda0557a7846d6b07963
data/lib/bundleup/mcp.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require_relative 'mcp_client'
4
5
 
5
6
  module BundleUp
6
7
  # Transport for a connection's MCP server.
@@ -67,201 +68,4 @@ module BundleUp
67
68
  @connection ||= Faraday.new { |faraday| faraday.adapter Faraday.default_adapter }
68
69
  end
69
70
  end
70
-
71
- # A connected MCP session.
72
- #
73
- # Tools, resources and prompts are defined by the provider — BundleUp does
74
- # not rename or normalize them.
75
- class MCPClient
76
- PROTOCOL_VERSION = '2025-06-18'
77
- CLIENT_NAME = 'bundleup-sdk'
78
-
79
- def initialize(base_url, api_key, connection_id)
80
- @base_url = base_url
81
- @api_key = api_key
82
- @connection_id = connection_id
83
- @session_id = nil
84
- @connected = false
85
- @last_id = 0
86
- end
87
-
88
- # List the provider's tools, following pagination to the end.
89
- def tools
90
- paginate('tools/list', 'tools')
91
- end
92
-
93
- # Call a tool by name, with arguments matching its own input schema.
94
- def tool(name, args = {})
95
- raise ArgumentError, 'Tool name is required to call a tool.' if blank?(name)
96
-
97
- connect
98
- send_message('tools/call', { name: name, arguments: args })
99
- end
100
-
101
- # List the provider's resources, following pagination to the end.
102
- def resources
103
- paginate('resources/list', 'resources')
104
- end
105
-
106
- # Read a resource by URI.
107
- def resource(uri)
108
- raise ArgumentError, 'Resource URI is required to read a resource.' if blank?(uri)
109
-
110
- connect
111
- send_message('resources/read', { uri: uri })
112
- end
113
-
114
- # List the provider's prompts, following pagination to the end.
115
- def prompts
116
- paginate('prompts/list', 'prompts')
117
- end
118
-
119
- # Get a prompt by name.
120
- def prompt(name, args = {})
121
- raise ArgumentError, 'Prompt name is required to get a prompt.' if blank?(name)
122
-
123
- connect
124
- send_message('prompts/get', { name: name, arguments: args })
125
- end
126
-
127
- # Send any other JSON-RPC method on this session.
128
- def request(method, params = nil)
129
- raise ArgumentError, 'Method is required to send a request.' if blank?(method)
130
-
131
- connect
132
- send_message(method, params)
133
- end
134
-
135
- # End the session and reset local state.
136
- def close
137
- delete_session if @session_id
138
-
139
- @session_id = nil
140
- @connected = false
141
- nil
142
- end
143
-
144
- private
145
-
146
- def blank?(value)
147
- value.nil? || value.to_s.empty?
148
- end
149
-
150
- def default_headers
151
- headers = {
152
- 'Authorization' => "Bearer #{@api_key}",
153
- 'Content-Type' => 'application/json',
154
- 'Accept' => 'application/json, text/event-stream',
155
- 'BU-Connection-Id' => @connection_id
156
- }
157
- headers['Mcp-Session-Id'] = @session_id if @session_id
158
- headers
159
- end
160
-
161
- def connection
162
- @connection ||= Faraday.new { |faraday| faraday.adapter Faraday.default_adapter }
163
- end
164
-
165
- def post_payload(payload)
166
- response = connection.post(@base_url, payload.to_json, default_headers)
167
- session_id = response.headers['mcp-session-id']
168
- @session_id = session_id if session_id
169
-
170
- raise error_for(response) unless response.success?
171
-
172
- response
173
- end
174
-
175
- def error_for(response)
176
- fallback = "MCP request failed with status #{response.status}."
177
- parsed = JSON.parse(response.body.to_s)
178
- return RuntimeError.new(fallback) unless parsed.is_a?(Hash) && parsed['message']
179
-
180
- code = parsed['code']
181
- RuntimeError.new(code ? "#{parsed['message']} (#{code})" : parsed['message'])
182
- rescue JSON::ParserError
183
- RuntimeError.new(fallback)
184
- end
185
-
186
- # Run the MCP handshake, once. Deferred until the first call.
187
- def connect
188
- return if @connected
189
-
190
- send_message('initialize', handshake_params)
191
- post_payload({ jsonrpc: '2.0', method: 'notifications/initialized' })
192
- @connected = true
193
- end
194
-
195
- def handshake_params
196
- {
197
- protocolVersion: PROTOCOL_VERSION,
198
- capabilities: {},
199
- clientInfo: { name: CLIENT_NAME, version: BundleUp::VERSION }
200
- }
201
- end
202
-
203
- def send_message(method, params = nil)
204
- @last_id += 1
205
- payload = { jsonrpc: '2.0', id: @last_id, method: method }
206
- payload[:params] = params unless params.nil?
207
-
208
- message = parse(post_payload(payload), @last_id)
209
- raise "No response received for #{method}." if message.nil?
210
- raise message['error']['message'].to_s if message['error']
211
-
212
- message['result'] || {}
213
- end
214
-
215
- # Providers may answer a plain request/response over text/event-stream.
216
- def parse(response, message_id)
217
- body = response.body.to_s
218
- return nil if body.empty?
219
-
220
- content_type = response.headers['content-type'].to_s
221
- return JSON.parse(body) unless content_type.include?('text/event-stream')
222
-
223
- parse_stream(body, message_id)
224
- end
225
-
226
- def parse_stream(body, message_id)
227
- body.gsub("\r\n", "\n").split("\n\n").each do |event|
228
- data = event_data(event)
229
- next if data.empty?
230
-
231
- message = JSON.parse(data)
232
- # Skip server notifications interleaved on the stream.
233
- return message if message.is_a?(Hash) && message['id'] == message_id
234
- end
235
-
236
- nil
237
- end
238
-
239
- def event_data(event)
240
- event.split("\n")
241
- .select { |line| line.start_with?('data:') }
242
- .map { |line| line.sub('data:', '').strip }
243
- .join("\n")
244
- end
245
-
246
- def paginate(method, key)
247
- connect
248
- items = []
249
- cursor = nil
250
-
251
- loop do
252
- result = send_message(method, cursor ? { cursor: cursor } : nil)
253
- items.concat(result[key] || [])
254
- cursor = result['nextCursor']
255
- break unless cursor
256
- end
257
-
258
- items
259
- end
260
-
261
- def delete_session
262
- connection.delete(@base_url, nil, default_headers)
263
- rescue Faraday::Error
264
- nil
265
- end
266
- end
267
71
  end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module BundleUp
6
+ # A connected MCP session.
7
+ #
8
+ # Tools, resources and prompts are defined by the provider — BundleUp does
9
+ # not rename or normalize them.
10
+ class MCPClient
11
+ PROTOCOL_VERSION = '2025-06-18'
12
+ CLIENT_NAME = 'bundleup-sdk'
13
+
14
+ def initialize(base_url, api_key, connection_id)
15
+ @base_url = base_url
16
+ @api_key = api_key
17
+ @connection_id = connection_id
18
+ @session_id = nil
19
+ @connected = false
20
+ @last_id = 0
21
+ end
22
+
23
+ # List the provider's tools, following pagination to the end.
24
+ def tools
25
+ paginate('tools/list', 'tools')
26
+ end
27
+
28
+ # Call a tool by name, with arguments matching its own input schema.
29
+ def tool(name, args = {})
30
+ raise ArgumentError, 'Tool name is required to call a tool.' if blank?(name)
31
+
32
+ connect
33
+ send_message('tools/call', { name: name, arguments: args })
34
+ end
35
+
36
+ # List the provider's resources, following pagination to the end.
37
+ def resources
38
+ paginate('resources/list', 'resources')
39
+ end
40
+
41
+ # Read a resource by URI.
42
+ def resource(uri)
43
+ raise ArgumentError, 'Resource URI is required to read a resource.' if blank?(uri)
44
+
45
+ connect
46
+ send_message('resources/read', { uri: uri })
47
+ end
48
+
49
+ # List the provider's prompts, following pagination to the end.
50
+ def prompts
51
+ paginate('prompts/list', 'prompts')
52
+ end
53
+
54
+ # Get a prompt by name.
55
+ def prompt(name, args = {})
56
+ raise ArgumentError, 'Prompt name is required to get a prompt.' if blank?(name)
57
+
58
+ connect
59
+ send_message('prompts/get', { name: name, arguments: args })
60
+ end
61
+
62
+ # Send any other JSON-RPC method on this session.
63
+ def request(method, params = nil)
64
+ raise ArgumentError, 'Method is required to send a request.' if blank?(method)
65
+
66
+ connect
67
+ send_message(method, params)
68
+ end
69
+
70
+ # End the session and reset local state.
71
+ def close
72
+ delete_session if @session_id
73
+
74
+ @session_id = nil
75
+ @connected = false
76
+ nil
77
+ end
78
+
79
+ private
80
+
81
+ def blank?(value)
82
+ value.nil? || value.to_s.empty?
83
+ end
84
+
85
+ def default_headers
86
+ headers = {
87
+ 'Authorization' => '******',
88
+ 'Content-Type' => 'application/json',
89
+ 'Accept' => 'application/json, text/event-stream',
90
+ 'BU-Connection-Id' => @connection_id
91
+ }
92
+ headers['Mcp-Session-Id'] = @session_id if @session_id
93
+ headers
94
+ end
95
+
96
+ def connection
97
+ @connection ||= Faraday.new { |faraday| faraday.adapter Faraday.default_adapter }
98
+ end
99
+
100
+ def post_payload(payload)
101
+ response = connection.post(@base_url, payload.to_json, default_headers)
102
+ session_id = response.headers['mcp-session-id']
103
+ @session_id = session_id if session_id
104
+
105
+ raise error_for(response) unless response.success?
106
+
107
+ response
108
+ end
109
+
110
+ def error_for(response)
111
+ fallback = "MCP request failed with status #{response.status}."
112
+ parsed = JSON.parse(response.body.to_s)
113
+ return RuntimeError.new(fallback) unless parsed.is_a?(Hash) && parsed['message']
114
+
115
+ code = parsed['code']
116
+ RuntimeError.new(code ? "#{parsed['message']} (#{code})" : parsed['message'])
117
+ rescue JSON::ParserError
118
+ RuntimeError.new(fallback)
119
+ end
120
+
121
+ # Run the MCP handshake, once. Deferred until the first call.
122
+ def connect
123
+ return if @connected
124
+
125
+ send_message('initialize', handshake_params)
126
+ post_payload({ jsonrpc: '2.0', method: 'notifications/initialized' })
127
+ @connected = true
128
+ end
129
+
130
+ def handshake_params
131
+ {
132
+ protocolVersion: PROTOCOL_VERSION,
133
+ capabilities: {},
134
+ clientInfo: { name: CLIENT_NAME, version: BundleUp::VERSION }
135
+ }
136
+ end
137
+
138
+ def send_message(method, params = nil)
139
+ @last_id += 1
140
+ payload = { jsonrpc: '2.0', id: @last_id, method: method }
141
+ payload[:params] = params unless params.nil?
142
+
143
+ message = parse(post_payload(payload), @last_id)
144
+ raise "No response received for #{method}." if message.nil?
145
+ raise message['error']['message'].to_s if message['error']
146
+
147
+ message['result'] || {}
148
+ end
149
+
150
+ # Providers may answer a plain request/response over text/event-stream.
151
+ def parse(response, message_id)
152
+ body = response.body.to_s
153
+ return nil if body.empty?
154
+
155
+ content_type = response.headers['content-type'].to_s
156
+ return JSON.parse(body) unless content_type.include?('text/event-stream')
157
+
158
+ parse_stream(body, message_id)
159
+ end
160
+
161
+ def parse_stream(body, message_id)
162
+ body.gsub("\r\n", "\n").split("\n\n").each do |event|
163
+ data = event_data(event)
164
+ next if data.empty?
165
+
166
+ message = JSON.parse(data)
167
+ # Skip server notifications interleaved on the stream.
168
+ return message if message.is_a?(Hash) && message['id'] == message_id
169
+ end
170
+
171
+ nil
172
+ end
173
+
174
+ def event_data(event)
175
+ event.split("\n")
176
+ .select { |line| line.start_with?('data:') }
177
+ .map { |line| line.sub('data:', '').strip }
178
+ .join("\n")
179
+ end
180
+
181
+ def paginate(method, key)
182
+ connect
183
+ items = []
184
+ cursor = nil
185
+
186
+ loop do
187
+ result = send_message(method, cursor ? { cursor: cursor } : nil)
188
+ items.concat(result[key] || [])
189
+ cursor = result['nextCursor']
190
+ break unless cursor
191
+ end
192
+
193
+ items
194
+ end
195
+
196
+ def delete_session
197
+ connection.delete(@base_url, nil, default_headers)
198
+ rescue Faraday::Error
199
+ nil
200
+ end
201
+ end
202
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BundleUp
4
- VERSION = '0.4.0'
4
+ VERSION = '0.4.1'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bundleup-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - BundleUp
@@ -169,6 +169,7 @@ files:
169
169
  - lib/bundleup.rb
170
170
  - lib/bundleup/client.rb
171
171
  - lib/bundleup/mcp.rb
172
+ - lib/bundleup/mcp_client.rb
172
173
  - lib/bundleup/proxy.rb
173
174
  - lib/bundleup/resources/base.rb
174
175
  - lib/bundleup/resources/connection.rb