bundleup-sdk 0.5.0 → 0.7.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: ab7e53fa4d67aa688569b213a6b9f090cce9b8e557203e598ef4ce946e4ac508
4
- data.tar.gz: 5226846b47d962f8c616eb14e6c47ee8836bde8a63f610cfc155fda1eec879df
3
+ metadata.gz: a3feaef0f54fcef0ff3dba477b926513d0ed0801e2f383a80b0ca13f44218c5a
4
+ data.tar.gz: d44bc81b00d6afa88e0b4d58de1f832a98d81538f00a6169d3f5838e52865d1f
5
5
  SHA512:
6
- metadata.gz: 0a8085e498cfd804bbd5d7b93840997f0d719a4d040d4d0639f2d51621976217b1cfb1038db537e0ca46d917c0440a8ed6b7ab83b3e362c34d45c064dff7a1df
7
- data.tar.gz: 823c5ebb016f7c2f31cdae1a838556f2ea7d62a25d0d6193aead188dfe045392cbf940892f258d24a2167fbc5f620816d03274154d07535031c66535c232909d
6
+ metadata.gz: 95b41fa793e45e3aaa62f01beda29dce8172b5547da1e5959c0c4cf6927de71fca5892f9c2d8d2a87a1fecc3a43af53c7cc2139e1afff4a215f3c5287a057a9d
7
+ data.tar.gz: e597e588f6cd074e365660d54908ef489e456fcec0b9f5238c87ad946be29b007e636fb541439429fd25a6b6486fa89c535eab2d4c76e5dd824d0427248d8b6d
data/README.md CHANGED
@@ -72,7 +72,7 @@ The BundleUp SDK is tested and supported on:
72
72
  - 🔌 **100+ Integrations** - Connect to Slack, GitHub, Jira, Linear, and many more
73
73
  - ðŸŽŊ **Unified API** - Consistent interface across all integrations via Unify API
74
74
  - 🔑 **Proxy API** - Direct access to underlying integration APIs
75
- - ðŸĪ– **MCP** - Connect agents to a provider's own MCP server or to BundleUp's Unified MCP
75
+ - ðŸĪ– **MCP** - Hand a connection to any MCP client, or to OpenAI and Anthropic's hosted MCP
76
76
  - ðŸŠķ **Lightweight** - Minimal dependencies
77
77
  - ðŸ›Ąïļ **Error Handling** - Comprehensive error messages and validation
78
78
  - 📚 **Well Documented** - Extensive documentation and examples
@@ -84,7 +84,7 @@ Runnable examples are available in the [`examples/`](./examples) directory:
84
84
 
85
85
  - [`examples/basic_usage.rb`](./examples/basic_usage.rb) - Client setup, connections, integrations, and webhooks
86
86
  - [`examples/proxy_api.rb`](./examples/proxy_api.rb) - Proxy API GET request with a connection
87
- - [`examples/unify_api.rb`](./examples/unify_api.rb) - Unify Chat, Git, PM, Ticketing, Drive, and Calendar endpoint usage
87
+ - [`examples/unify_api.rb`](./examples/unify_api.rb) - Unify Chat, Git, Tasks, Helpdesk, Drive, and Calendar endpoint usage
88
88
  - [`examples/README.md`](./examples/README.md) - Setup and execution instructions
89
89
 
90
90
  ## Quick Start
@@ -542,6 +542,16 @@ url = client.auth.build_authorization_url(
542
542
  # Redirect the user to `url`
543
543
  ```
544
544
 
545
+ The URL needs no API key, so you can also build it without a client:
546
+
547
+ ```ruby
548
+ url = BundleUp.build_authorization_url(
549
+ client_id: 'your-client-id',
550
+ integration_id: 'github',
551
+ redirect_uri: 'https://app.example.com/callback'
552
+ )
553
+ ```
554
+
545
555
  **Parameters:**
546
556
 
547
557
  - `client_id` (String, required): Your workspace client ID
@@ -1084,20 +1094,20 @@ provider's default branch is used.
1084
1094
  }
1085
1095
  ```
1086
1096
 
1087
- #### Ticketing API
1097
+ #### Tasks API
1088
1098
 
1089
- The Ticketing API provides a unified interface for ticketing and project management platforms like Jira, Linear, and Asana.
1099
+ The Tasks API provides a unified interface for issue trackers and project management tools like Jira, Linear, Asana, Trello, ClickUp, monday.com, Basecamp, Wrike, and Todoist.
1090
1100
 
1091
- ##### List Tickets
1101
+ ##### List Tasks
1092
1102
 
1093
1103
  ```ruby
1094
- result = unify.ticketing.tickets(
1104
+ result = unify.tasks.tasks(
1095
1105
  limit: 100,
1096
1106
  after: nil,
1097
1107
  include_raw: false
1098
1108
  )
1099
1109
 
1100
- puts "Tickets: #{result['data']}"
1110
+ puts "Tasks: #{result['data']}"
1101
1111
  ```
1102
1112
 
1103
1113
  **Response:**
@@ -1124,18 +1134,18 @@ puts "Tickets: #{result['data']}"
1124
1134
  **Filtering and sorting:**
1125
1135
 
1126
1136
  ```ruby
1127
- open_tickets = result['data'].select { |ticket| ticket['status'] == 'open' }
1128
- sorted_by_date = result['data'].sort_by { |ticket| Time.parse(ticket['created_at']) }.reverse
1137
+ open_tasks = result['data'].select { |task| task['status'] == 'open' }
1138
+ sorted_by_date = result['data'].sort_by { |task| Time.parse(task['created_at']) }.reverse
1129
1139
  ```
1130
1140
 
1131
- ##### Get a Ticket
1141
+ ##### Get a Task
1132
1142
 
1133
- Fetch one ticket by ID. Not supported by Basecamp, whose API only serves a to-do underneath its project.
1143
+ Fetch one task by ID. Not supported by Basecamp, whose API only serves a to-do underneath its project.
1134
1144
 
1135
1145
  ```ruby
1136
- result = unify.ticketing.ticket('PROJ-123')
1146
+ result = unify.tasks.task('PROJ-123')
1137
1147
 
1138
- puts "Ticket: #{result['data']}"
1148
+ puts "Task: #{result['data']}"
1139
1149
  ```
1140
1150
 
1141
1151
  **Response:**
@@ -1159,7 +1169,7 @@ A single resource carries no pagination, so there is no `metadata` on this respo
1159
1169
  ##### List Projects
1160
1170
 
1161
1171
  ```ruby
1162
- result = unify.ticketing.projects(
1172
+ result = unify.tasks.projects(
1163
1173
  limit: 100,
1164
1174
  after: nil,
1165
1175
  include_raw: false
@@ -1191,6 +1201,57 @@ puts "Projects: #{result['data']}"
1191
1201
 
1192
1202
  **Note:** Not every platform returns every field. Jira does not expose creation or update timestamps for projects, so `created_at` and `updated_at` are `nil` for Jira connections, and `status` is only set when Jira reports whether the project is archived.
1193
1203
 
1204
+ #### Helpdesk API
1205
+
1206
+ The Helpdesk API provides a unified interface for customer support tools like Zendesk, ServiceNow, Zoho Desk, Gorgias, and Help Scout.
1207
+
1208
+ ##### List Tickets
1209
+
1210
+ ```ruby
1211
+ result = unify.helpdesk.tickets(
1212
+ limit: 100,
1213
+ after: nil,
1214
+ include_raw: false
1215
+ )
1216
+
1217
+ puts "Tickets: #{result['data']}"
1218
+ ```
1219
+
1220
+ **Response:**
1221
+
1222
+ ```ruby
1223
+ {
1224
+ 'data' => [
1225
+ {
1226
+ 'id' => '4182',
1227
+ 'title' => 'Refund for order #1043',
1228
+ 'description' => 'Hi, my order arrived damaged...',
1229
+ 'status' => 'open',
1230
+ 'priority' => 'high',
1231
+ 'requester' => { 'id' => '381920', 'name' => 'Dana Ruiz', 'email' => 'dana@example.com' },
1232
+ 'url' => 'https://acme.zendesk.com/agent/tickets/4182',
1233
+ 'created_at' => '2024-01-15T10:30:00Z',
1234
+ 'updated_at' => '2024-01-20T14:22:00Z'
1235
+ }
1236
+ ],
1237
+ 'metadata' => {
1238
+ 'next' => 'cursor_def456'
1239
+ }
1240
+ }
1241
+ ```
1242
+
1243
+ ##### Get a Ticket
1244
+
1245
+ Fetch one support ticket by ID. On ServiceNow the ID is the incident's `sys_id`.
1246
+
1247
+ ```ruby
1248
+ result = unify.helpdesk.ticket('4182')
1249
+
1250
+ puts "Ticket: #{result['data']}"
1251
+ ```
1252
+
1253
+ A single resource carries no pagination, so there is no `metadata` on this response.
1254
+
1194
1255
  #### CRM API
1195
1256
 
1196
1257
  The CRM API provides a unified interface for CRM platforms like Attio, HubSpot, PipeDrive, Salesforce and Zoho.
@@ -1340,47 +1401,14 @@ Reach a provider's own MCP server using a connection's credentials. BundleUp inj
1340
1401
 
1341
1402
  Supported for providers that run a first-party MCP server — see the [integrations page](https://www.bundleup.io/integrations). Others return an `mcp_not_supported` error.
1342
1403
 
1343
- `post` and `delete` are transport only, like the Proxy API — responses come back untouched as `Faraday::Response` objects. `connect` layers a managed session on top when you would rather not drive the protocol yourself.
1404
+ BundleUp does not ship an MCP client. Hand `hosted` or `transport` to the one you already use, or send JSON-RPC yourself with `post` and `delete`, which return the response untouched as `Faraday::Response` objects, like the Proxy API.
1344
1405
 
1345
- #### Creating an MCP Client
1406
+ #### Creating an MCP Instance
1346
1407
 
1347
1408
  ```ruby
1348
1409
  mcp = client.mcp('conn_123abc')
1349
1410
  ```
1350
1411
 
1351
- #### Managed Sessions
1352
-
1353
- `connect` returns a client that handles the handshake, session ID and response decoding, and exposes what the provider offers.
1354
-
1355
- ```ruby
1356
- mcp = client.mcp('conn_123abc').connect
1357
-
1358
- tools = mcp.list_tools
1359
- result = mcp.call_tool('create_issue', { title: 'Login broken' })
1360
-
1361
- mcp.close
1362
- ```
1363
-
1364
- Resources and prompts follow the same shape:
1365
-
1366
- ```ruby
1367
- resources = mcp.list_resources
1368
- contents = mcp.read_resource('file:///readme.md')
1369
-
1370
- prompts = mcp.list_prompts
1371
- messages = mcp.get_prompt('summarize', { id: '123' })
1372
- ```
1373
-
1374
- Anything else in the protocol:
1375
-
1376
- ```ruby
1377
- mcp.request('logging/setLevel', { level: 'debug' })
1378
- ```
1379
-
1380
- The handshake runs lazily on the first call and once per client, list methods follow `nextCursor` to the end, and `text/event-stream` responses are decoded for you. Results are hashes with string keys. Errors raise a `RuntimeError` with the provider's message, or BundleUp's with its code appended — `Missing or invalid connection ID (connection_invalid)`.
1381
-
1382
- Call `close` when you are done to end the session upstream.
1383
-
1384
1412
  #### Model-Hosted MCP
1385
1413
 
1386
1414
  OpenAI and Anthropic can connect to an MCP server themselves, with no tool mapping or dispatch loop on your side. Both accept only a single credential and no custom headers, so `hosted` returns the server URL alongside the API key and connection joined into one bearer.
@@ -1403,7 +1431,7 @@ response = openai.responses.create(
1403
1431
  )
1404
1432
  ```
1405
1433
 
1406
- Anthropic's connector takes the same pair as `url` and `authorization_token`. `client.unify('conn_123abc').mcp.hosted` returns them for Unified MCP.
1434
+ Anthropic's connector takes the same pair as `url` and `authorization_token`.
1407
1435
 
1408
1436
  `server_url` must be exactly the URL `hosted` returns — the proxy rebuilds the upstream URL from the provider's own base, so any path or query you append is ignored rather than rejected.
1409
1437
 
@@ -1506,45 +1534,29 @@ end
1506
1534
 
1507
1535
  Every JSON-RPC message counts toward the rate limit of 100 requests per 60 seconds, per connection — including the `initialize` handshake.
1508
1536
 
1509
- #### Merging Several Connections
1537
+ #### Several Connections
1510
1538
 
1511
- An agent often needs more than one provider for the same end user. There is no merge helper in the SDK — how tools are namespaced, filtered and recovered from differs enough per agent that it is better written where you can see it:
1539
+ An agent often needs more than one provider for the same end user. With model-hosted MCP there is nothing to merge — pass one `mcp` tool per connection and the model provider keeps them apart by `server_label`:
1512
1540
 
1513
1541
  ```ruby
1514
- clients = {
1515
- 'slack' => client.mcp(user.slack_connection).connect,
1516
- 'linear' => client.mcp(user.linear_connection).connect,
1517
- 'crm' => client.unify(user.hubspot_connection).mcp
1518
- }
1542
+ connections = { 'slack' => user.slack_connection, 'linear' => user.linear_connection }
1519
1543
 
1520
- # One namespaced list: slack__send_message, linear__create_issue, â€Ķ
1521
- tools = clients.flat_map do |label, mcp|
1522
- mcp.list_tools.map { |tool| tool.merge('name' => "#{label}__#{tool['name']}") }
1523
- end
1544
+ tools = connections.map do |label, connection_id|
1545
+ hosted = client.mcp(connection_id).hosted
1524
1546
 
1525
- # Route a call back to the client that owns it
1526
- call = lambda do |name, args|
1527
- label, tool_name = name.split('__', 2)
1528
- clients.fetch(label).call_tool(tool_name, args)
1547
+ {
1548
+ type: 'mcp',
1549
+ server_label: label,
1550
+ server_url: hosted[:url],
1551
+ authorization: hosted[:token],
1552
+ require_approval: 'never'
1553
+ }
1529
1554
  end
1530
1555
  ```
1531
1556
 
1532
- Anything that exposes `list_tools` and `call_tool(name, args)` fits the same shape, so an internal tool layer of your own can sit in that map alongside BundleUp connections.
1533
-
1534
- Two things worth handling that the sketch above skips. **Filter before you hand the list to a model** — three providers is easily sixty tools, and accuracy drops as that list grows, so select the ones the agent actually needs rather than passing everything. And decide what an unreachable provider should do: as written, one failing `list_tools` raises and fails the whole list, while a `rescue` per client lets the others through.
1535
-
1536
- #### Unified MCP
1537
-
1538
- BundleUp's normalized tools instead of the provider's, on the same protocol. Tools only — Unified MCP exposes no resources or prompts.
1539
-
1540
- ```ruby
1541
- mcp = client.unify('conn_123abc').mcp
1542
-
1543
- tools = mcp.list_tools
1544
- result = mcp.call_tool('send_message', { text: 'Deploy finished' })
1545
- ```
1557
+ With an MCP client in your own backend, open one client per connection from its `transport`, prefix each tool name with a label (`slack__send_message`), and route calls back by that prefix. There is no merge helper in the SDK — how tools are namespaced, filtered and recovered from differs enough per agent that it is better written where you can see it.
1546
1558
 
1547
- `unify.mcp` is memoized per Unify client, so the handshake runs once no matter how often you call it. The server itself is stateless and POST-only, so there is no session to close.
1559
+ **Filter before you hand tools to a model** — three providers is easily sixty tools, and accuracy drops as that list grows, so give the agent only the ones it needs.
1548
1560
 
1549
1561
  ## Error Handling
1550
1562
 
@@ -1589,7 +1601,7 @@ lib/
1589
1601
  │ ├── client.rb # Main client class
1590
1602
  │ ├── auth.rb # Auth API (authorization URL + code exchange)
1591
1603
  │ ├── proxy.rb # Proxy API implementation
1592
- │ ├── mcp.rb # MCP API (transport + managed sessions)
1604
+ │ ├── mcp.rb # MCP API (transport + hosted)
1593
1605
  │ ├── unify.rb # Unify API client wrapper
1594
1606
  │ ├── version.rb # Gem version
1595
1607
  │ ├── resources/
@@ -1601,7 +1613,8 @@ lib/
1601
1613
  │ ├── base.rb # Base Unify class
1602
1614
  │ ├── chat.rb # Chat Unify API
1603
1615
  │ ├── git.rb # Git Unify API
1604
- │ ├── ticketing.rb # Ticketing Unify API
1616
+ │ ├── tasks.rb # Tasks Unify API
1617
+ │ ├── helpdesk.rb # Helpdesk Unify API
1605
1618
  │ ├── crm.rb # CRM Unify API
1606
1619
  │ ├── drive.rb # Drive Unify API
1607
1620
  │ └── calendar.rb # Calendar Unify API
data/lib/bundleup/auth.rb CHANGED
@@ -14,7 +14,10 @@ module BundleUp
14
14
  end
15
15
 
16
16
  # Builds the hosted authorization URL to send the end user to.
17
- def build_authorization_url(client_id:, integration_id:, redirect_uri:, external_id: nil, state: nil)
17
+ #
18
+ # Needs no API key: /authorize only takes the public client and
19
+ # integration ids, so this is safe to call where the key must not live.
20
+ def self.build_authorization_url(client_id:, integration_id:, redirect_uri:, external_id: nil, state: nil)
18
21
  raise ArgumentError, 'client_id is required' if blank?(client_id)
19
22
  raise ArgumentError, 'integration_id is required' if blank?(integration_id)
20
23
  raise ArgumentError, 'redirect_uri is required' if blank?(redirect_uri)
@@ -30,6 +33,16 @@ module BundleUp
30
33
  "#{BASE_URL}/authorize?#{URI.encode_www_form(params)}"
31
34
  end
32
35
 
36
+ def self.blank?(value)
37
+ value.nil? || value.to_s.empty?
38
+ end
39
+ private_class_method :blank?
40
+
41
+ # Builds the hosted authorization URL to send the end user to.
42
+ def build_authorization_url(**params)
43
+ self.class.build_authorization_url(**params)
44
+ end
45
+
33
46
  # Exchanges the one-time code from the redirect for the connection.
34
47
  # Call this server-side; the code expires after 5 minutes and works once.
35
48
  # Returns { 'connection_id', 'external_id', 'integration_id' }.
data/lib/bundleup/mcp.rb CHANGED
@@ -3,11 +3,11 @@
3
3
  require 'json'
4
4
 
5
5
  module BundleUp
6
- # Transport for a connection's MCP server.
6
+ # A connection's MCP server.
7
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.
8
+ # BundleUp does not ship an MCP client. Hand +transport+ or +hosted+ to the
9
+ # client you already use, or drive the protocol yourself with +post+ and
10
+ # +delete+, which return the raw Faraday response the way Proxy does.
11
11
  class MCP
12
12
  BASE_URL = 'https://mcp.bundleup.io'
13
13
 
@@ -47,11 +47,6 @@ module BundleUp
47
47
  connection.delete(BASE_URL, nil, default_headers.merge(headers))
48
48
  end
49
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
50
  private
56
51
 
57
52
  def default_headers
@@ -67,201 +62,4 @@ module BundleUp
67
62
  @connection ||= Faraday.new { |faraday| faraday.adapter Faraday.default_adapter }
68
63
  end
69
64
  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 list_tools
90
- paginate('tools/list', 'tools')
91
- end
92
-
93
- # Call a tool by name, with arguments matching its own input schema.
94
- def call_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 list_resources
103
- paginate('resources/list', 'resources')
104
- end
105
-
106
- # Read a resource by URI.
107
- def read_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 list_prompts
116
- paginate('prompts/list', 'prompts')
117
- end
118
-
119
- # Get a prompt by name.
120
- def get_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
65
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundleUp
4
+ module Unify
5
+ # Client for helpdesk Unify endpoints (customer support tools).
6
+ class Helpdesk < Base
7
+ # Fetches support tickets from the connected helpdesk.
8
+ def tickets(params = {})
9
+ response = connection.get('helpdesk/tickets') do |req|
10
+ req.params = params
11
+ end
12
+
13
+ raise "Failed to fetch helpdesk/tickets: #{response.status}" unless response.success?
14
+
15
+ response.body
16
+ end
17
+
18
+ # Fetches a single support ticket by ID (a sys_id on ServiceNow).
19
+ def ticket(ticket_id, params = {})
20
+ raise ArgumentError, 'ticket_id is required to fetch a ticket.' if blank?(ticket_id)
21
+
22
+ encoded_id = URI.encode_www_form_component(ticket_id)
23
+
24
+ response = connection.get("helpdesk/tickets/#{encoded_id}") do |req|
25
+ req.params = params
26
+ end
27
+
28
+ raise "Failed to fetch helpdesk/tickets/#{encoded_id}: #{response.status}" unless response.success?
29
+
30
+ response.body
31
+ end
32
+
33
+ private
34
+
35
+ def blank?(value)
36
+ value.nil? || value.to_s.empty?
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundleUp
4
+ module Unify
5
+ # Client for tasks Unify endpoints (issue trackers and project tools).
6
+ class Tasks < Base
7
+ # Fetches tasks from the connected issue tracker or project tool.
8
+ def tasks(params = {})
9
+ response = connection.get('tasks/tasks') do |req|
10
+ req.params = params
11
+ end
12
+
13
+ raise "Failed to fetch tasks/tasks: #{response.status}" unless response.success?
14
+
15
+ response.body
16
+ end
17
+
18
+ # Fetches a single task by ID from the connected issue tracker or project tool.
19
+ #
20
+ # Not supported by Basecamp, whose API only serves a to-do underneath its
21
+ # project — an id on its own cannot address one.
22
+ def task(task_id, params = {})
23
+ raise ArgumentError, 'task_id is required to fetch a task.' if blank?(task_id)
24
+
25
+ encoded_id = URI.encode_www_form_component(task_id)
26
+
27
+ response = connection.get("tasks/tasks/#{encoded_id}") do |req|
28
+ req.params = params
29
+ end
30
+
31
+ raise "Failed to fetch tasks/tasks/#{encoded_id}: #{response.status}" unless response.success?
32
+
33
+ response.body
34
+ end
35
+
36
+ # Fetches projects from the connected issue tracker or project tool.
37
+ def projects(params = {})
38
+ response = connection.get('tasks/projects') do |req|
39
+ req.params = params
40
+ end
41
+
42
+ raise "Failed to fetch tasks/projects: #{response.status}" unless response.success?
43
+
44
+ response.body
45
+ end
46
+
47
+ private
48
+
49
+ def blank?(value)
50
+ value.nil? || value.to_s.empty?
51
+ end
52
+ end
53
+ end
54
+ end
@@ -7,7 +7,8 @@ module BundleUp
7
7
  attr_reader :api_key, :connection_id
8
8
 
9
9
  def initialize(api_key, connection_id)
10
- @ticketing = BundleUp::Unify::Ticketing.new(api_key, connection_id)
10
+ @tasks = BundleUp::Unify::Tasks.new(api_key, connection_id)
11
+ @helpdesk = BundleUp::Unify::Helpdesk.new(api_key, connection_id)
11
12
  @chat = BundleUp::Unify::Chat.new(api_key, connection_id)
12
13
  @git = BundleUp::Unify::Git.new(api_key, connection_id)
13
14
  @crm = BundleUp::Unify::CRM.new(api_key, connection_id)
@@ -32,9 +33,14 @@ module BundleUp
32
33
  @crm ||= BundleUp::Unify::CRM.new(api_key, connection_id)
33
34
  end
34
35
 
35
- # Access the Ticketing API for the connection.
36
- def ticketing
37
- @ticketing ||= BundleUp::Unify::Ticketing.new(api_key, connection_id)
36
+ # Access the Tasks API (issue trackers and project tools) for the connection.
37
+ def tasks
38
+ @tasks ||= BundleUp::Unify::Tasks.new(api_key, connection_id)
39
+ end
40
+
41
+ # Access the Helpdesk API (customer support tools) for the connection.
42
+ def helpdesk
43
+ @helpdesk ||= BundleUp::Unify::Helpdesk.new(api_key, connection_id)
38
44
  end
39
45
 
40
46
  # Access the Drive API for the connection.
@@ -54,11 +60,6 @@ module BundleUp
54
60
  def me(params = {})
55
61
  (@me ||= BundleUp::Unify::Me.new(api_key, connection_id)).get(params)
56
62
  end
57
-
58
- # Access the Unified MCP server for the connection.
59
- def mcp
60
- @mcp ||= BundleUp::Unify::MCP.new(api_key, connection_id)
61
- end
62
63
  end
63
64
  end
64
65
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BundleUp
4
- VERSION = '0.5.0'
4
+ VERSION = '0.7.0'
5
5
  end
data/lib/bundleup.rb CHANGED
@@ -19,13 +19,18 @@ require_relative 'bundleup/resources/webhook'
19
19
  require_relative 'bundleup/unify/base'
20
20
  require_relative 'bundleup/unify/chat'
21
21
  require_relative 'bundleup/unify/git'
22
- require_relative 'bundleup/unify/ticketing'
22
+ require_relative 'bundleup/unify/tasks'
23
+ require_relative 'bundleup/unify/helpdesk'
23
24
  require_relative 'bundleup/unify/crm'
24
25
  require_relative 'bundleup/unify/drive'
25
26
  require_relative 'bundleup/unify/calendar'
26
- require_relative 'bundleup/unify/mcp'
27
27
  require_relative 'bundleup/unify/me'
28
28
 
29
29
  # Main module for the BundleUp SDK.
30
30
  module BundleUp
31
+ # Builds the hosted authorization URL without an API key.
32
+ # See BundleUp::Auth.build_authorization_url.
33
+ def self.build_authorization_url(**params)
34
+ Auth.build_authorization_url(**params)
35
+ end
31
36
  end
@@ -1,4 +1,12 @@
1
1
  module BundleUp
2
+ def self.build_authorization_url: (
3
+ client_id: String,
4
+ integration_id: String,
5
+ redirect_uri: String,
6
+ ?external_id: String?,
7
+ ?state: String?
8
+ ) -> String
9
+
2
10
  class Auth
3
11
  BASE_URL: String
4
12
 
@@ -12,6 +20,16 @@ module BundleUp
12
20
 
13
21
  def initialize: (String api_key) -> void
14
22
 
23
+ def self.blank?: (untyped value) -> bool
24
+
25
+ def self.build_authorization_url: (
26
+ client_id: String,
27
+ integration_id: String,
28
+ redirect_uri: String,
29
+ ?external_id: String?,
30
+ ?state: String?
31
+ ) -> String
32
+
15
33
  def build_authorization_url: (
16
34
  client_id: String,
17
35
  integration_id: String,
@@ -0,0 +1,33 @@
1
+ module BundleUp
2
+ module Unify
3
+ class Helpdesk < Base
4
+ type requester = {
5
+ id: String?,
6
+ name: String?,
7
+ email: String?
8
+ }
9
+
10
+ type ticket = {
11
+ id: String,
12
+ title: String?,
13
+ description: String?,
14
+ status: String?,
15
+ priority: String?,
16
+ requester: requester?,
17
+ url: String?,
18
+ created_at: String?,
19
+ updated_at: String?
20
+ }
21
+
22
+ type metadata = { next: String? }
23
+
24
+ def tickets: (?Hash[Symbol, untyped] params) -> { data: Array[ticket], metadata: metadata }
25
+
26
+ def ticket: (String ticket_id, ?Hash[Symbol, untyped] params) -> { data: ticket }
27
+
28
+ private
29
+
30
+ def blank?: (untyped) -> bool
31
+ end
32
+ end
33
+ end
@@ -1,7 +1,7 @@
1
1
  module BundleUp
2
2
  module Unify
3
- class Ticketing < Base
4
- type ticketing_ticket = {
3
+ class Tasks < Base
4
+ type task_item = {
5
5
  id: String,
6
6
  title: String,
7
7
  status: String,
@@ -11,7 +11,7 @@ module BundleUp
11
11
  updated_at: String
12
12
  }
13
13
 
14
- type ticket = {
14
+ type task = {
15
15
  id: String,
16
16
  title: String,
17
17
  status: String?,
@@ -21,7 +21,7 @@ module BundleUp
21
21
  updated_at: String?
22
22
  }
23
23
 
24
- type ticketing_project = {
24
+ type project = {
25
25
  id: String,
26
26
  name: String,
27
27
  status: String?,
@@ -33,11 +33,11 @@ module BundleUp
33
33
 
34
34
  type metadata = { next: String? }
35
35
 
36
- def tickets: (?Hash[Symbol, untyped] params) -> { data: Array[ticketing_ticket], metadata: metadata }
36
+ def tasks: (?Hash[Symbol, untyped] params) -> { data: Array[task_item], metadata: metadata }
37
37
 
38
- def ticket: (String ticket_id, ?Hash[Symbol, untyped] params) -> { data: ticket }
38
+ def task: (String task_id, ?Hash[Symbol, untyped] params) -> { data: task }
39
39
 
40
- def projects: (?Hash[Symbol, untyped] params) -> { data: Array[ticketing_project], metadata: metadata }
40
+ def projects: (?Hash[Symbol, untyped] params) -> { data: Array[project], metadata: metadata }
41
41
 
42
42
  private
43
43
 
@@ -8,7 +8,8 @@ module BundleUp
8
8
 
9
9
  def chat: () -> Chat
10
10
  def git: () -> Git
11
- def ticketing: () -> Ticketing
11
+ def tasks: () -> Tasks
12
+ def helpdesk: () -> Helpdesk
12
13
  def crm: () -> CRM
13
14
  def drive: () -> Drive
14
15
  def calendar: () -> Calendar
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.5.0
4
+ version: 0.7.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-09-30 00:00:00.000000000 Z
11
+ date: 2026-10-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -182,9 +182,9 @@ files:
182
182
  - lib/bundleup/unify/crm.rb
183
183
  - lib/bundleup/unify/drive.rb
184
184
  - lib/bundleup/unify/git.rb
185
- - lib/bundleup/unify/mcp.rb
185
+ - lib/bundleup/unify/helpdesk.rb
186
186
  - lib/bundleup/unify/me.rb
187
- - lib/bundleup/unify/ticketing.rb
187
+ - lib/bundleup/unify/tasks.rb
188
188
  - lib/bundleup/version.rb
189
189
  - sig/bundleup/auth.rbs
190
190
  - sig/bundleup/unify.rbs
@@ -194,8 +194,9 @@ files:
194
194
  - sig/bundleup/unify/crm.rbs
195
195
  - sig/bundleup/unify/drive.rbs
196
196
  - sig/bundleup/unify/git.rbs
197
+ - sig/bundleup/unify/helpdesk.rbs
197
198
  - sig/bundleup/unify/me.rbs
198
- - sig/bundleup/unify/ticketing.rbs
199
+ - sig/bundleup/unify/tasks.rbs
199
200
  homepage: https://github.com/bundleup/bundleup-sdk-ruby
200
201
  licenses:
201
202
  - MIT
@@ -1,44 +0,0 @@
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 list_tools
33
- @client.list_tools
34
- end
35
-
36
- # Call a unified tool with optional arguments.
37
- def call_tool(name, args = {})
38
- raise ArgumentError, 'Tool name is required to call a tool.' if name.nil? || name.to_s.empty?
39
-
40
- @client.call_tool(name, args)
41
- end
42
- end
43
- end
44
- end
@@ -1,54 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module BundleUp
4
- module Unify
5
- # Client for ticketing Unify endpoints.
6
- class Ticketing < Base
7
- # Fetches tickets from the connected ticketing tool.
8
- def tickets(params = {})
9
- response = connection.get('ticketing/tickets') do |req|
10
- req.params = params
11
- end
12
-
13
- raise "Failed to fetch ticketing/tickets: #{response.status}" unless response.success?
14
-
15
- response.body
16
- end
17
-
18
- # Fetches a single ticket by ID from the connected ticketing tool.
19
- #
20
- # Not supported by Basecamp, whose API only serves a to-do underneath its
21
- # project — an id on its own cannot address one.
22
- def ticket(ticket_id, params = {})
23
- raise ArgumentError, 'ticket_id is required to fetch a ticket.' if blank?(ticket_id)
24
-
25
- encoded_id = URI.encode_www_form_component(ticket_id)
26
-
27
- response = connection.get("ticketing/tickets/#{encoded_id}") do |req|
28
- req.params = params
29
- end
30
-
31
- raise "Failed to fetch ticketing/tickets/#{encoded_id}: #{response.status}" unless response.success?
32
-
33
- response.body
34
- end
35
-
36
- # Fetches projects from the connected ticketing tool.
37
- def projects(params = {})
38
- response = connection.get('ticketing/projects') do |req|
39
- req.params = params
40
- end
41
-
42
- raise "Failed to fetch ticketing/projects: #{response.status}" unless response.success?
43
-
44
- response.body
45
- end
46
-
47
- private
48
-
49
- def blank?(value)
50
- value.nil? || value.to_s.empty?
51
- end
52
- end
53
- end
54
- end