bundleup-sdk 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e385086d7a4207cb36ec82ac134eab03155093407c098488ea40478d62f79d3
4
- data.tar.gz: ca70fa9281ad486160c66a731e81c0ad56f200ad4e8fccf15e818340e43ea5d6
3
+ metadata.gz: 3ece90ca061e8b5bbac827ca07dcc35b1c029e235d27ec319cef3117cd0652d0
4
+ data.tar.gz: 034fdb9a5ccf8c36aa38a3d8a7870f8c32a7997ff617cfed395fd4e0ff03aaae
5
5
  SHA512:
6
- metadata.gz: 9245dcc515bff7307c4073bbc223ce3b2e5bdd8e3ff91dbb83cfdf95437c89bc7e03a7c3b8c9c0db0ca2bb0962df0c9597d127c0a3209d2bcb909e934c787f65
7
- data.tar.gz: e472e19410d920eb5fbbe6abab03c2af1d1ea05945d7249d1be90c7d47ab7375d67dc503ba2698e57ce7b40ed9fed423400847b3dffefde47b51931c5ee74117
6
+ metadata.gz: eb30ce08198acee62b5ec10469a4a116a8186eab6669fbc1bcaece16899911ca50f5b37a1bc15d6dcc096a87b6c160faa3ea2319c8ce552159aa4347be869b85
7
+ data.tar.gz: 38855ea19f0662f62acbddc68e0b439f4171dadf5bba56f29b4dde40c4cfc14f72cf6da871af0faa9039b6a37c5b3188a968bacd3abcb859468053913bd2efe6
@@ -38,5 +38,13 @@ module BundleUp
38
38
 
39
39
  BundleUp::Unify::Client.new(@api_key, connection_id)
40
40
  end
41
+
42
+ def mcp(connection_id)
43
+ if connection_id.nil? || connection_id.empty?
44
+ raise ArgumentError, 'Connection ID is required to create an MCP instance.'
45
+ end
46
+
47
+ BundleUp::MCP.new(@api_key, connection_id)
48
+ end
41
49
  end
42
50
  end
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module BundleUp
6
+ # Transport for a connection's MCP server.
7
+ #
8
+ # +post+ and +delete+ return the raw Faraday response, the way Proxy does.
9
+ # Use +connect+ for a managed session that handles the handshake and
10
+ # response decoding.
11
+ class MCP
12
+ BASE_URL = 'https://mcp.bundleup.io'
13
+
14
+ # Separates the API key from an appended connection ID. Safe to split on:
15
+ # API keys are alphanumeric and connection IDs are cuids.
16
+ CREDENTIAL_SEPARATOR = '.'
17
+
18
+ attr_reader :api_key, :connection_id
19
+
20
+ def initialize(api_key, connection_id)
21
+ @api_key = api_key
22
+ @connection_id = connection_id
23
+ end
24
+
25
+ # The URL and headers for an MCP client running in your own process.
26
+ def transport
27
+ { url: BASE_URL, headers: default_headers }
28
+ end
29
+
30
+ # The URL and a single bearer token carrying both the API key and the
31
+ # connection, for model-hosted MCP clients that cannot set custom headers.
32
+ # Note this hands your API key to the model provider.
33
+ def hosted
34
+ { url: BASE_URL, token: "#{@api_key}#{CREDENTIAL_SEPARATOR}#{@connection_id}" }
35
+ end
36
+
37
+ # Send a JSON-RPC message and return the raw response. Pass
38
+ # +Mcp-Session-Id+ in +headers+ to stay on an existing session.
39
+ def post(body, headers: {})
40
+ payload = body.is_a?(String) ? body : body.to_json
41
+
42
+ connection.post(BASE_URL, payload, default_headers.merge(headers))
43
+ end
44
+
45
+ # End an MCP session. Pass the session's +Mcp-Session-Id+ in +headers+.
46
+ def delete(headers: {})
47
+ connection.delete(BASE_URL, nil, default_headers.merge(headers))
48
+ end
49
+
50
+ # Open a managed MCP session for this connection.
51
+ def connect
52
+ MCPClient.new(BASE_URL, @api_key, @connection_id)
53
+ end
54
+
55
+ private
56
+
57
+ def default_headers
58
+ {
59
+ 'Authorization' => "Bearer #{@api_key}",
60
+ 'Content-Type' => 'application/json',
61
+ 'Accept' => 'application/json, text/event-stream',
62
+ 'BU-Connection-Id' => @connection_id
63
+ }
64
+ end
65
+
66
+ def connection
67
+ @connection ||= Faraday.new { |faraday| faraday.adapter Faraday.default_adapter }
68
+ end
69
+ 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
+ end
@@ -17,6 +17,8 @@ module BundleUp
17
17
 
18
18
  # Fetches pull requests for a specific repository from the connected Git provider.
19
19
  def pulls(repo_name, params = {})
20
+ raise ArgumentError, 'repo_name is required to fetch pulls.' if blank?(repo_name)
21
+
20
22
  encoded_repo_name = URI.encode_www_form_component(repo_name)
21
23
 
22
24
  response = connection.get("git/repos/#{encoded_repo_name}/pulls") do |req|
@@ -30,6 +32,8 @@ module BundleUp
30
32
 
31
33
  # Fetches tags for a specific repository from the connected Git provider.
32
34
  def tags(repo_name, params = {})
35
+ raise ArgumentError, 'repo_name is required to fetch tags.' if blank?(repo_name)
36
+
33
37
  encoded_repo_name = URI.encode_www_form_component(repo_name)
34
38
 
35
39
  response = connection.get("git/repos/#{encoded_repo_name}/tags") do |req|
@@ -43,6 +47,8 @@ module BundleUp
43
47
 
44
48
  # Fetches releases for a specific repository from the connected Git provider.
45
49
  def releases(repo_name, params = {})
50
+ raise ArgumentError, 'repo_name is required to fetch releases.' if blank?(repo_name)
51
+
46
52
  encoded_repo_name = URI.encode_www_form_component(repo_name)
47
53
 
48
54
  response = connection.get("git/repos/#{encoded_repo_name}/releases") do |req|
@@ -56,6 +62,8 @@ module BundleUp
56
62
 
57
63
  # Fetches branches for a specific repository from the connected Git provider.
58
64
  def branches(repo_name, params = {})
65
+ raise ArgumentError, 'repo_name is required to fetch branches.' if blank?(repo_name)
66
+
59
67
  encoded_repo_name = URI.encode_www_form_component(repo_name)
60
68
 
61
69
  response = connection.get("git/repos/#{encoded_repo_name}/branches") do |req|
@@ -66,6 +74,27 @@ module BundleUp
66
74
 
67
75
  response.body
68
76
  end
77
+
78
+ # Fetches commits for a specific repository from the connected Git provider.
79
+ def commits(repo_name, params = {})
80
+ raise ArgumentError, 'repo_name is required to fetch commits.' if blank?(repo_name)
81
+
82
+ encoded_repo_name = URI.encode_www_form_component(repo_name)
83
+
84
+ response = connection.get("git/repos/#{encoded_repo_name}/commits") do |req|
85
+ req.params = params
86
+ end
87
+
88
+ raise "Failed to fetch git/repos/#{encoded_repo_name}/commits: #{response.status}" unless response.success?
89
+
90
+ response.body
91
+ end
92
+
93
+ private
94
+
95
+ def blank?(value)
96
+ value.nil? || value.to_s.empty?
97
+ end
69
98
  end
70
99
  end
71
100
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundleUp
4
+ module Unify
5
+ # The Unified MCP server.
6
+ #
7
+ # Same protocol and headers as Proxy MCP, but the tools are BundleUp's
8
+ # normalized ones rather than the provider's. Tools only — Unified MCP
9
+ # exposes no resources or prompts.
10
+ #
11
+ # The server is stateless and POST-only, so there is no session to close.
12
+ class MCP
13
+ BASE_URL = 'https://unify.bundleup.io/v1/mcp'
14
+
15
+ attr_reader :api_key, :connection_id
16
+
17
+ def initialize(api_key, connection_id)
18
+ @api_key = api_key
19
+ @connection_id = connection_id
20
+ @client = ::BundleUp::MCPClient.new(BASE_URL, api_key, connection_id)
21
+ end
22
+
23
+ # The URL and a single bearer token carrying both the API key and the
24
+ # connection, for model-hosted MCP clients that cannot set headers.
25
+ def hosted
26
+ separator = ::BundleUp::MCP::CREDENTIAL_SEPARATOR
27
+
28
+ { url: BASE_URL, token: "#{@api_key}#{separator}#{@connection_id}" }
29
+ end
30
+
31
+ # List the available unified tools.
32
+ def tools
33
+ @client.tools
34
+ end
35
+
36
+ # Call a unified tool with optional arguments.
37
+ def tool(name, args = {})
38
+ raise ArgumentError, 'Tool name is required to call a tool.' if name.nil? || name.to_s.empty?
39
+
40
+ @client.tool(name, args)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -40,6 +40,11 @@ module BundleUp
40
40
  def drive
41
41
  @drive ||= BundleUp::Unify::Drive.new(api_key, connection_id)
42
42
  end
43
+
44
+ # Access the Unified MCP server for the connection.
45
+ def mcp
46
+ @mcp ||= BundleUp::Unify::MCP.new(api_key, connection_id)
47
+ end
43
48
  end
44
49
  end
45
50
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BundleUp
4
- VERSION = '0.3.0'
4
+ VERSION = '0.4.0'
5
5
  end
data/lib/bundleup.rb CHANGED
@@ -6,6 +6,7 @@ require_relative 'bundleup/version'
6
6
  require_relative 'bundleup/client'
7
7
  require_relative 'bundleup/proxy'
8
8
  require_relative 'bundleup/unify'
9
+ require_relative 'bundleup/mcp'
9
10
 
10
11
  # Resources
11
12
  require_relative 'bundleup/resources/base'
@@ -20,6 +21,7 @@ require_relative 'bundleup/unify/git'
20
21
  require_relative 'bundleup/unify/ticketing'
21
22
  require_relative 'bundleup/unify/crm'
22
23
  require_relative 'bundleup/unify/drive'
24
+ require_relative 'bundleup/unify/mcp'
23
25
 
24
26
  # Main module for the BundleUp SDK.
25
27
  module BundleUp
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bundleup-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - BundleUp
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-15 00:00:00.000000000 Z
11
+ date: 2026-08-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -168,6 +168,7 @@ files:
168
168
  - README.md
169
169
  - lib/bundleup.rb
170
170
  - lib/bundleup/client.rb
171
+ - lib/bundleup/mcp.rb
171
172
  - lib/bundleup/proxy.rb
172
173
  - lib/bundleup/resources/base.rb
173
174
  - lib/bundleup/resources/connection.rb
@@ -179,6 +180,7 @@ files:
179
180
  - lib/bundleup/unify/crm.rb
180
181
  - lib/bundleup/unify/drive.rb
181
182
  - lib/bundleup/unify/git.rb
183
+ - lib/bundleup/unify/mcp.rb
182
184
  - lib/bundleup/unify/ticketing.rb
183
185
  - lib/bundleup/version.rb
184
186
  - sig/bundleup/unify.rbs