bundleup-sdk 0.3.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: 6e385086d7a4207cb36ec82ac134eab03155093407c098488ea40478d62f79d3
4
- data.tar.gz: ca70fa9281ad486160c66a731e81c0ad56f200ad4e8fccf15e818340e43ea5d6
3
+ metadata.gz: 7e2898652fed3548927b24223483a9308f826e2899503c4eb711275d9418145a
4
+ data.tar.gz: ec2179acf284beb8c8e94a1079c671ee2a3a5218905d9187648b62120bcbcaa4
5
5
  SHA512:
6
- metadata.gz: 9245dcc515bff7307c4073bbc223ce3b2e5bdd8e3ff91dbb83cfdf95437c89bc7e03a7c3b8c9c0db0ca2bb0962df0c9597d127c0a3209d2bcb909e934c787f65
7
- data.tar.gz: e472e19410d920eb5fbbe6abab03c2af1d1ea05945d7249d1be90c7d47ab7375d67dc503ba2698e57ce7b40ed9fed423400847b3dffefde47b51931c5ee74117
6
+ metadata.gz: 4bf6f9e0912c4cefbc510011e4475743517c21fe324c428ccb8e2b44a24104c2fa3299069edcafca8cf1c47879d6620792fada35af230414f34e138dd5742990
7
+ data.tar.gz: 5dce15658d4a193bc89771b8726a1e5faea4326f7c20c76ed7c1b23a9e4eef01c1bf30e7ec785a2f478e2610fc193078f8c1d0adca4ecda0557a7846d6b07963
@@ -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,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'mcp_client'
5
+
6
+ module BundleUp
7
+ # Transport for a connection's MCP server.
8
+ #
9
+ # +post+ and +delete+ return the raw Faraday response, the way Proxy does.
10
+ # Use +connect+ for a managed session that handles the handshake and
11
+ # response decoding.
12
+ class MCP
13
+ BASE_URL = 'https://mcp.bundleup.io'
14
+
15
+ # Separates the API key from an appended connection ID. Safe to split on:
16
+ # API keys are alphanumeric and connection IDs are cuids.
17
+ CREDENTIAL_SEPARATOR = '.'
18
+
19
+ attr_reader :api_key, :connection_id
20
+
21
+ def initialize(api_key, connection_id)
22
+ @api_key = api_key
23
+ @connection_id = connection_id
24
+ end
25
+
26
+ # The URL and headers for an MCP client running in your own process.
27
+ def transport
28
+ { url: BASE_URL, headers: default_headers }
29
+ end
30
+
31
+ # The URL and a single bearer token carrying both the API key and the
32
+ # connection, for model-hosted MCP clients that cannot set custom headers.
33
+ # Note this hands your API key to the model provider.
34
+ def hosted
35
+ { url: BASE_URL, token: "#{@api_key}#{CREDENTIAL_SEPARATOR}#{@connection_id}" }
36
+ end
37
+
38
+ # Send a JSON-RPC message and return the raw response. Pass
39
+ # +Mcp-Session-Id+ in +headers+ to stay on an existing session.
40
+ def post(body, headers: {})
41
+ payload = body.is_a?(String) ? body : body.to_json
42
+
43
+ connection.post(BASE_URL, payload, default_headers.merge(headers))
44
+ end
45
+
46
+ # End an MCP session. Pass the session's +Mcp-Session-Id+ in +headers+.
47
+ def delete(headers: {})
48
+ connection.delete(BASE_URL, nil, default_headers.merge(headers))
49
+ end
50
+
51
+ # Open a managed MCP session for this connection.
52
+ def connect
53
+ MCPClient.new(BASE_URL, @api_key, @connection_id)
54
+ end
55
+
56
+ private
57
+
58
+ def default_headers
59
+ {
60
+ 'Authorization' => "Bearer #{@api_key}",
61
+ 'Content-Type' => 'application/json',
62
+ 'Accept' => 'application/json, text/event-stream',
63
+ 'BU-Connection-Id' => @connection_id
64
+ }
65
+ end
66
+
67
+ def connection
68
+ @connection ||= Faraday.new { |faraday| faraday.adapter Faraday.default_adapter }
69
+ end
70
+ end
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
@@ -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.1'
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.1
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,8 @@ files:
168
168
  - README.md
169
169
  - lib/bundleup.rb
170
170
  - lib/bundleup/client.rb
171
+ - lib/bundleup/mcp.rb
172
+ - lib/bundleup/mcp_client.rb
171
173
  - lib/bundleup/proxy.rb
172
174
  - lib/bundleup/resources/base.rb
173
175
  - lib/bundleup/resources/connection.rb
@@ -179,6 +181,7 @@ files:
179
181
  - lib/bundleup/unify/crm.rb
180
182
  - lib/bundleup/unify/drive.rb
181
183
  - lib/bundleup/unify/git.rb
184
+ - lib/bundleup/unify/mcp.rb
182
185
  - lib/bundleup/unify/ticketing.rb
183
186
  - lib/bundleup/version.rb
184
187
  - sig/bundleup/unify.rbs