bundleup-sdk 0.4.0 → 0.4.2

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: e98412b5b8723a8137da05f6996554d0247bfa7947e2d9ec3955a1656cb1d386
4
+ data.tar.gz: 8639a58d6d076123db499bfc30bababe63374821189e586aeaaaf415c23192ff
5
5
  SHA512:
6
- metadata.gz: eb30ce08198acee62b5ec10469a4a116a8186eab6669fbc1bcaece16899911ca50f5b37a1bc15d6dcc096a87b6c160faa3ea2319c8ce552159aa4347be869b85
7
- data.tar.gz: 38855ea19f0662f62acbddc68e0b439f4171dadf5bba56f29b4dde40c4cfc14f72cf6da871af0faa9039b6a37c5b3188a968bacd3abcb859468053913bd2efe6
6
+ metadata.gz: b46029fe10adf813158115754593ed3d0e64ac44ce1ddc31c648a40a8580e35e93944a8701599ac777864ab0f605a0e014197b06af9830190cff01c76bb31602
7
+ data.tar.gz: 49d99e240a23e1e3ca9e961b1b7a06b9383bfb71e5a27cf552755db0d20ef1f2f42939c6ffbe58273ea630ad86c25d2f9e0c3dacb9d7bad7bb7054f4918f7b58
data/README.md CHANGED
@@ -146,7 +146,7 @@ client = BundleUp::Client.new(ENV['BUNDLEUP_API_KEY'])
146
146
  **Example `.env` file:**
147
147
 
148
148
  ```bash
149
- BUNDLEUP_API_KEY=bu_live_1234567890abcdefghijklmnopqrstuvwxyz
149
+ BUNDLEUP_API_KEY=Zw7Lt7JacsDyMCEpnZdGptgnJaOdMzFVH9QtIthnL5RviYP5WeH6e9FWP2HzEO
150
150
  ```
151
151
 
152
152
  **Loading environment variables (using dotenv):**
data/lib/bundleup/mcp.rb CHANGED
@@ -86,12 +86,12 @@ module BundleUp
86
86
  end
87
87
 
88
88
  # List the provider's tools, following pagination to the end.
89
- def tools
89
+ def list_tools
90
90
  paginate('tools/list', 'tools')
91
91
  end
92
92
 
93
93
  # Call a tool by name, with arguments matching its own input schema.
94
- def tool(name, args = {})
94
+ def call_tool(name, args = {})
95
95
  raise ArgumentError, 'Tool name is required to call a tool.' if blank?(name)
96
96
 
97
97
  connect
@@ -99,12 +99,12 @@ module BundleUp
99
99
  end
100
100
 
101
101
  # List the provider's resources, following pagination to the end.
102
- def resources
102
+ def list_resources
103
103
  paginate('resources/list', 'resources')
104
104
  end
105
105
 
106
106
  # Read a resource by URI.
107
- def resource(uri)
107
+ def read_resource(uri)
108
108
  raise ArgumentError, 'Resource URI is required to read a resource.' if blank?(uri)
109
109
 
110
110
  connect
@@ -112,12 +112,12 @@ module BundleUp
112
112
  end
113
113
 
114
114
  # List the provider's prompts, following pagination to the end.
115
- def prompts
115
+ def list_prompts
116
116
  paginate('prompts/list', 'prompts')
117
117
  end
118
118
 
119
119
  # Get a prompt by name.
120
- def prompt(name, args = {})
120
+ def get_prompt(name, args = {})
121
121
  raise ArgumentError, 'Prompt name is required to get a prompt.' if blank?(name)
122
122
 
123
123
  connect
@@ -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
@@ -29,15 +29,15 @@ module BundleUp
29
29
  end
30
30
 
31
31
  # List the available unified tools.
32
- def tools
33
- @client.tools
32
+ def list_tools
33
+ @client.list_tools
34
34
  end
35
35
 
36
36
  # Call a unified tool with optional arguments.
37
- def tool(name, args = {})
37
+ def call_tool(name, args = {})
38
38
  raise ArgumentError, 'Tool name is required to call a tool.' if name.nil? || name.to_s.empty?
39
39
 
40
- @client.tool(name, args)
40
+ @client.call_tool(name, args)
41
41
  end
42
42
  end
43
43
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundleUp
4
+ module Unify
5
+ # Client for the root-level identity endpoint.
6
+ #
7
+ # `me` is the one unified method every provider implements, so the API mounts
8
+ # it at the root rather than under a vertical. It is exposed on the Unify
9
+ # client as `unify.me` rather than as a namespace.
10
+ class Me < Base
11
+ # Fetches the account the connection is authenticated as.
12
+ #
13
+ # Providers that authorize per workspace, portal, tenant or company return
14
+ # that account instead of a user, and fields the provider does not expose
15
+ # come back as nil.
16
+ def get(params = {})
17
+ response = connection.get('me') do |req|
18
+ req.params = params
19
+ end
20
+
21
+ raise "Failed to fetch me: #{response.status}" unless response.success?
22
+
23
+ response.body
24
+ end
25
+ end
26
+ end
27
+ end
@@ -41,6 +41,14 @@ module BundleUp
41
41
  @drive ||= BundleUp::Unify::Drive.new(api_key, connection_id)
42
42
  end
43
43
 
44
+ # Fetch the account this connection is authenticated as.
45
+ #
46
+ # `me` is the one unified method every provider implements, so it hangs off
47
+ # the Unify client directly instead of a vertical namespace.
48
+ def me(params = {})
49
+ (@me ||= BundleUp::Unify::Me.new(api_key, connection_id)).get(params)
50
+ end
51
+
44
52
  # Access the Unified MCP server for the connection.
45
53
  def mcp
46
54
  @mcp ||= BundleUp::Unify::MCP.new(api_key, connection_id)
@@ -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.2'
5
5
  end
data/lib/bundleup.rb CHANGED
@@ -22,6 +22,7 @@ require_relative 'bundleup/unify/ticketing'
22
22
  require_relative 'bundleup/unify/crm'
23
23
  require_relative 'bundleup/unify/drive'
24
24
  require_relative 'bundleup/unify/mcp'
25
+ require_relative 'bundleup/unify/me'
25
26
 
26
27
  # Main module for the BundleUp SDK.
27
28
  module BundleUp
@@ -0,0 +1,14 @@
1
+ module BundleUp
2
+ module Unify
3
+ class Me < Base
4
+ type account = {
5
+ id: String?,
6
+ name: String?,
7
+ email: String?,
8
+ avatar_url: String?
9
+ }
10
+
11
+ def get: (?Hash[Symbol, untyped] params) -> { data: account }
12
+ end
13
+ end
14
+ end
@@ -11,6 +11,7 @@ module BundleUp
11
11
  def pm: () -> PM
12
12
  def crm: () -> CRM
13
13
  def drive: () -> Drive
14
+ def me: (?Hash[Symbol, untyped] params) -> { data: Me::account }
14
15
  end
15
16
  end
16
17
  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.2
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
@@ -181,6 +182,7 @@ files:
181
182
  - lib/bundleup/unify/drive.rb
182
183
  - lib/bundleup/unify/git.rb
183
184
  - lib/bundleup/unify/mcp.rb
185
+ - lib/bundleup/unify/me.rb
184
186
  - lib/bundleup/unify/ticketing.rb
185
187
  - lib/bundleup/version.rb
186
188
  - sig/bundleup/unify.rbs
@@ -189,6 +191,7 @@ files:
189
191
  - sig/bundleup/unify/crm.rbs
190
192
  - sig/bundleup/unify/drive.rbs
191
193
  - sig/bundleup/unify/git.rbs
194
+ - sig/bundleup/unify/me.rbs
192
195
  - sig/bundleup/unify/ticketing.rbs
193
196
  homepage: https://github.com/bundleup/bundleup-sdk-ruby
194
197
  licenses: