bundleup-sdk 0.4.3 → 0.6.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: 6a81131b46ad1f04a1714e8e7a07d87d69ee3419648e966daa9099cba31fb4a9
4
- data.tar.gz: b1861d43ace1fd90f86e89e8b161b91c135f1fc53215cf0dad3defd95351a048
3
+ metadata.gz: 9265bf377a655ed9056e02bd22d1ec24fec68304e7ad4881c99013c42ca516f2
4
+ data.tar.gz: 4f9dac8314ce2d26640048984f7bef53e8921dfa9d4a7be5c5857412af5d3b00
5
5
  SHA512:
6
- metadata.gz: 13c18248fe861fb462cf3d9019764d13a7925031b49aa40d37f5ab7c3b106e019407140873dda6467057777313bc58cc6cb661daac8f102ff462b318aec4c413
7
- data.tar.gz: c3bc826ddfb41e39616049a651b180dffd36bb3ee939f27ee315bd350400ae25dbec72666025f22860944d20f82e1945dc20f856b7a2c2cc0302cb671b7b0f9f
6
+ metadata.gz: 9e24b6ae2f52e09f5a2eb65e697b1080b4f8a36c5052097ea660b145fec36c7b85aef08ce85979d21023e65aa2788b4444071993e2f3ba3ea5b1f11bdbd2b48f
7
+ data.tar.gz: 71008902408646b7d4e171c186bc28337f0b67c7b36e218f01b2cf637402b06edf27cc2fa070dd036a35d9d428eecdad51f7935e1f2618e7dfdbd61b92b2949e
data/README.md CHANGED
@@ -72,6 +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** - Hand a connection to any MCP client, or to OpenAI and Anthropic's hosted MCP
75
76
  - ðŸŠķ **Lightweight** - Minimal dependencies
76
77
  - ðŸ›Ąïļ **Error Handling** - Comprehensive error messages and validation
77
78
  - 📚 **Well Documented** - Extensive documentation and examples
@@ -175,6 +176,10 @@ BUNDLEUP_CLIENT = BundleUp::Client.new(ENV['BUNDLEUP_API_KEY'])
175
176
 
176
177
  ## Core Concepts
177
178
 
179
+ ### Auth API
180
+
181
+ The **Auth API** runs the hosted authorization flow: build the URL that sends a user to connect an integration, then exchange the one-time `code` from the redirect for a `connection_id`. See [Authorization Flow](https://docs.bundleup.io/authorization-flow).
182
+
178
183
  ### Platform API
179
184
 
180
185
  The **Platform API** provides access to core BundleUp features like managing connections and integrations. Use this API to list, retrieve, and delete connections, as well as discover available integrations.
@@ -187,6 +192,10 @@ The **Proxy API** allows you to make direct HTTP requests to the underlying inte
187
192
 
188
193
  The **Unify API** provides a standardized, normalized interface across different integrations. For example, you can fetch chat channels from Slack, Discord, or Microsoft Teams using the same API call.
189
194
 
195
+ ### MCP API
196
+
197
+ The **MCP API** reaches a provider's own MCP server using a connection's stored credentials. Tools are defined by the provider, not by BundleUp. Because the connection is chosen per client, one agent can serve many end users without ever handling a token.
198
+
190
199
  ## API Reference
191
200
 
192
201
  ### Connections
@@ -515,6 +524,62 @@ class WebhooksController < ApplicationController
515
524
  end
516
525
  ```
517
526
 
527
+ ### Auth API
528
+
529
+ Connect an end user's account and get back a `connection_id`. See [Authorization Flow](https://docs.bundleup.io/authorization-flow) for the full flow.
530
+
531
+ #### Build the Authorization URL
532
+
533
+ ```ruby
534
+ url = client.auth.build_authorization_url(
535
+ client_id: 'your-client-id',
536
+ integration_id: 'github',
537
+ redirect_uri: 'https://app.example.com/callback',
538
+ external_id: 'user_42', # optional
539
+ state: 'random-csrf-token' # optional
540
+ )
541
+
542
+ # Redirect the user to `url`
543
+ ```
544
+
545
+ **Parameters:**
546
+
547
+ - `client_id` (String, required): Your workspace client ID
548
+ - `integration_id` (String, required): The integration to connect
549
+ - `redirect_uri` (String, required): Must exactly match a redirect URI registered in your dashboard
550
+ - `external_id` (String, optional): Your own reference, stored on the connection
551
+ - `state` (String, optional): Returned unchanged on the redirect
552
+
553
+ #### Exchange the Code for a Connection
554
+
555
+ BundleUp redirects back to `redirect_uri` with a one-time `code`. Exchange it server-side:
556
+
557
+ ```ruby
558
+ connection = client.auth.get_connection_from_code(
559
+ code: params[:code],
560
+ redirect_uri: 'https://app.example.com/callback'
561
+ )
562
+
563
+ puts connection['connection_id']
564
+ ```
565
+
566
+ **Parameters:**
567
+
568
+ - `code` (String, required): The `code` query parameter from the redirect
569
+ - `redirect_uri` (String, required): The same redirect URI used to build the authorization URL
570
+
571
+ **Response:**
572
+
573
+ ```ruby
574
+ {
575
+ 'connection_id' => 'conn_abc123',
576
+ 'external_id' => 'user_42',
577
+ 'integration_id' => 'github'
578
+ }
579
+ ```
580
+
581
+ The code expires after 5 minutes and can only be exchanged once. An invalid, expired or reused code raises a `RuntimeError` that includes the API's error body. Missing arguments raise `ArgumentError`.
582
+
518
583
  ### Proxy API
519
584
 
520
585
  Make direct HTTP requests to integration APIs through BundleUp.
@@ -873,6 +938,38 @@ puts "Pull Requests: #{result['data']}"
873
938
  }
874
939
  ```
875
940
 
941
+ ##### List Issues
942
+
943
+ ```ruby
944
+ result = unify.git.issues('organization/repo-name', limit: 20)
945
+
946
+ puts "Issues: #{result['data']}"
947
+ ```
948
+
949
+ **Response:**
950
+
951
+ ```ruby
952
+ {
953
+ 'data' => [
954
+ {
955
+ 'id' => 67890,
956
+ 'number' => 17,
957
+ 'title' => 'Timestamps drift on retry',
958
+ 'description' => 'Retried requests report the first attempt time',
959
+ 'state' => 'open',
960
+ 'url' => 'https://github.com/org/repo/issues/17',
961
+ 'user' => 'john-doe',
962
+ 'created_at' => '2024-01-15T10:30:00Z',
963
+ 'updated_at' => '2024-01-20T14:22:00Z',
964
+ 'closed_at' => nil
965
+ }
966
+ ],
967
+ 'metadata' => {
968
+ 'next' => nil
969
+ }
970
+ }
971
+ ```
972
+
876
973
  ##### List Tags
877
974
 
878
975
  ```ruby
@@ -956,6 +1053,37 @@ puts "Branches: #{result['data']}"
956
1053
  }
957
1054
  ```
958
1055
 
1056
+ ##### List Commits
1057
+
1058
+ ```ruby
1059
+ result = unify.git.commits('organization/repo-name', branch: 'main', limit: 20)
1060
+
1061
+ puts "Commits: #{result['data']}"
1062
+ ```
1063
+
1064
+ `branch` is optional and accepts a branch name, tag or commit SHA. When it is omitted the
1065
+ provider's default branch is used.
1066
+
1067
+ **Response:**
1068
+
1069
+ ```ruby
1070
+ {
1071
+ 'data' => [
1072
+ {
1073
+ 'sha' => 'abc123def4567890abc123def4567890abc123de',
1074
+ 'message' => 'Add commits endpoint',
1075
+ 'url' => 'https://github.com/org/repo/commit/abc123def4567890abc123def4567890abc123de',
1076
+ 'author' => 'Jane Doe',
1077
+ 'author_email' => 'jane@example.com',
1078
+ 'committed_at' => '2024-01-15T10:30:00Z'
1079
+ }
1080
+ ],
1081
+ 'metadata' => {
1082
+ 'next' => nil
1083
+ }
1084
+ }
1085
+ ```
1086
+
959
1087
  #### Ticketing API
960
1088
 
961
1089
  The Ticketing API provides a unified interface for ticketing and project management platforms like Jira, Linear, and Asana.
@@ -1028,6 +1156,41 @@ puts "Ticket: #{result['data']}"
1028
1156
 
1029
1157
  A single resource carries no pagination, so there is no `metadata` on this response.
1030
1158
 
1159
+ ##### List Projects
1160
+
1161
+ ```ruby
1162
+ result = unify.ticketing.projects(
1163
+ limit: 100,
1164
+ after: nil,
1165
+ include_raw: false
1166
+ )
1167
+
1168
+ puts "Projects: #{result['data']}"
1169
+ ```
1170
+
1171
+ **Response:**
1172
+
1173
+ ```ruby
1174
+ {
1175
+ 'data' => [
1176
+ {
1177
+ 'id' => '10001',
1178
+ 'name' => 'Website Redesign',
1179
+ 'status' => 'active',
1180
+ 'url' => 'https://jira.example.com/browse/PROJ',
1181
+ 'description' => 'All the work for the new marketing site',
1182
+ 'created_at' => '2024-01-15T10:30:00Z',
1183
+ 'updated_at' => '2024-01-20T14:22:00Z'
1184
+ }
1185
+ ],
1186
+ 'metadata' => {
1187
+ 'next' => 'cursor_def456'
1188
+ }
1189
+ }
1190
+ ```
1191
+
1192
+ **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
+
1031
1194
  #### CRM API
1032
1195
 
1033
1196
  The CRM API provides a unified interface for CRM platforms like Attio, HubSpot, PipeDrive, Salesforce and Zoho.
@@ -1171,6 +1334,169 @@ puts "Events: #{result['data']}"
1171
1334
 
1172
1335
  Recurring events are expanded into their occurrences. All-day events carry a `YYYY-MM-DD` date rather than a timestamp. Attendees, conferencing links and organizers are available through `include_raw` or the Proxy API.
1173
1336
 
1337
+ ### MCP API
1338
+
1339
+ Reach a provider's own MCP server using a connection's credentials. BundleUp injects and refreshes the access token, so the connection ID is the only thing your agent needs to know about a user.
1340
+
1341
+ 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
+
1343
+ 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
+
1345
+ #### Creating an MCP Instance
1346
+
1347
+ ```ruby
1348
+ mcp = client.mcp('conn_123abc')
1349
+ ```
1350
+
1351
+ #### Model-Hosted MCP
1352
+
1353
+ 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.
1354
+
1355
+ ```ruby
1356
+ hosted = client.mcp('conn_123abc').hosted
1357
+
1358
+ response = openai.responses.create(
1359
+ model: 'gpt-4o',
1360
+ input: 'What issues are assigned to me?',
1361
+ tools: [
1362
+ {
1363
+ type: 'mcp',
1364
+ server_label: 'linear',
1365
+ server_url: hosted[:url],
1366
+ authorization: hosted[:token],
1367
+ require_approval: 'never'
1368
+ }
1369
+ ]
1370
+ )
1371
+ ```
1372
+
1373
+ Anthropic's connector takes the same pair as `url` and `authorization_token`.
1374
+
1375
+ `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.
1376
+
1377
+ This sends your API key to the model provider, whose servers make the request. Use an MCP client in your own backend if that is not acceptable.
1378
+
1379
+ #### Using an MCP Client Library
1380
+
1381
+ `transport` returns the URL and headers if you would rather use an existing MCP client. Pass them to any client that supports the streamable HTTP transport.
1382
+
1383
+ ```ruby
1384
+ transport = client.mcp('conn_123abc').transport
1385
+
1386
+ transport[:url] # => "https://mcp.bundleup.io"
1387
+ transport[:headers] # => { "Authorization" => "Bearer ...", "BU-Connection-Id" => "conn_123abc", ... }
1388
+ ```
1389
+
1390
+ #### Sending JSON-RPC Directly
1391
+
1392
+ MCP requires an `initialize` handshake before any other method. The session ID comes back on that first response and must be sent on every call after it.
1393
+
1394
+ ```ruby
1395
+ mcp = client.mcp('conn_123abc')
1396
+
1397
+ # 1. Handshake
1398
+ init = mcp.post({
1399
+ jsonrpc: '2.0',
1400
+ id: 1,
1401
+ method: 'initialize',
1402
+ params: {
1403
+ protocolVersion: '2025-06-18',
1404
+ capabilities: {},
1405
+ clientInfo: { name: 'my-agent', version: '1.0.0' }
1406
+ }
1407
+ })
1408
+
1409
+ session_id = init.headers['mcp-session-id']
1410
+ session = session_id ? { 'Mcp-Session-Id' => session_id } : {}
1411
+
1412
+ # 2. Confirm the handshake (a notification — no id, no response body)
1413
+ mcp.post({ jsonrpc: '2.0', method: 'notifications/initialized' }, headers: session)
1414
+
1415
+ # 3. List tools
1416
+ response = mcp.post({ jsonrpc: '2.0', id: 2, method: 'tools/list' }, headers: session)
1417
+ result = parse(response)['result']
1418
+
1419
+ puts result['tools']
1420
+ ```
1421
+
1422
+ Providers may answer with `text/event-stream` rather than JSON, so responses need unwrapping either way:
1423
+
1424
+ ```ruby
1425
+ require 'json'
1426
+
1427
+ def parse(response)
1428
+ return JSON.parse(response.body) unless response.headers['content-type'].to_s.include?('text/event-stream')
1429
+
1430
+ data = response.body.lines
1431
+ .select { |line| line.start_with?('data:') }
1432
+ .map { |line| line[5..].strip }
1433
+ .join("\n")
1434
+
1435
+ JSON.parse(data)
1436
+ end
1437
+ ```
1438
+
1439
+ Tool lists can be paginated. If `result['nextCursor']` is set, call `tools/list` again with `params: { cursor: result['nextCursor'] }` until it comes back empty.
1440
+
1441
+ Calling a tool follows the same shape:
1442
+
1443
+ ```ruby
1444
+ response = mcp.post({
1445
+ jsonrpc: '2.0',
1446
+ id: 3,
1447
+ method: 'tools/call',
1448
+ params: { name: 'create_issue', arguments: { title: 'Login broken' } }
1449
+ }, headers: session)
1450
+ ```
1451
+
1452
+ #### Sessions
1453
+
1454
+ MCP sessions live on the provider's server — BundleUp holds no session state. Close one when you are done:
1455
+
1456
+ ```ruby
1457
+ mcp.delete(headers: { 'Mcp-Session-Id' => session_id })
1458
+ ```
1459
+
1460
+ #### Errors
1461
+
1462
+ BundleUp rejects a request before it reaches the provider by returning an HTTP error with a JSON body — the response is passed straight through, so check `response.success?` yourself.
1463
+
1464
+ ```ruby
1465
+ response = mcp.post(body)
1466
+
1467
+ unless response.success?
1468
+ error = JSON.parse(response.body)
1469
+ # connection_invalid, connection_refresh_failed, mcp_not_supported, rate_limit
1470
+ warn "#{error['code']}: #{error['message']}"
1471
+ end
1472
+ ```
1473
+
1474
+ Every JSON-RPC message counts toward the rate limit of 100 requests per 60 seconds, per connection — including the `initialize` handshake.
1475
+
1476
+ #### Several Connections
1477
+
1478
+ 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`:
1479
+
1480
+ ```ruby
1481
+ connections = { 'slack' => user.slack_connection, 'linear' => user.linear_connection }
1482
+
1483
+ tools = connections.map do |label, connection_id|
1484
+ hosted = client.mcp(connection_id).hosted
1485
+
1486
+ {
1487
+ type: 'mcp',
1488
+ server_label: label,
1489
+ server_url: hosted[:url],
1490
+ authorization: hosted[:token],
1491
+ require_approval: 'never'
1492
+ }
1493
+ end
1494
+ ```
1495
+
1496
+ 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.
1497
+
1498
+ **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.
1499
+
1174
1500
  ## Error Handling
1175
1501
 
1176
1502
  The SDK raises exceptions for errors. Always wrap SDK calls in rescue blocks for proper error handling.
@@ -1212,7 +1538,9 @@ lib/
1212
1538
  ├── bundleup.rb # Main entry point
1213
1539
  ├── bundleup/
1214
1540
  │ ├── client.rb # Main client class
1541
+ │ ├── auth.rb # Auth API (authorization URL + code exchange)
1215
1542
  │ ├── proxy.rb # Proxy API implementation
1543
+ │ ├── mcp.rb # MCP API (transport + hosted)
1216
1544
  │ ├── unify.rb # Unify API client wrapper
1217
1545
  │ ├── version.rb # Gem version
1218
1546
  │ ├── resources/
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module BundleUp
6
+ # Client for the hosted authorization flow.
7
+ class Auth
8
+ BASE_URL = 'https://auth.bundleup.io'
9
+
10
+ attr_reader :api_key
11
+
12
+ def initialize(api_key)
13
+ @api_key = api_key
14
+ end
15
+
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)
18
+ raise ArgumentError, 'client_id is required' if blank?(client_id)
19
+ raise ArgumentError, 'integration_id is required' if blank?(integration_id)
20
+ raise ArgumentError, 'redirect_uri is required' if blank?(redirect_uri)
21
+
22
+ params = {
23
+ client_id: client_id,
24
+ integration_id: integration_id,
25
+ redirect_uri: redirect_uri
26
+ }
27
+ params[:external_id] = external_id unless blank?(external_id)
28
+ params[:state] = state unless blank?(state)
29
+
30
+ "#{BASE_URL}/authorize?#{URI.encode_www_form(params)}"
31
+ end
32
+
33
+ # Exchanges the one-time code from the redirect for the connection.
34
+ # Call this server-side; the code expires after 5 minutes and works once.
35
+ # Returns { 'connection_id', 'external_id', 'integration_id' }.
36
+ def get_connection_from_code(code:, redirect_uri:)
37
+ raise ArgumentError, 'code is required' if blank?(code)
38
+ raise ArgumentError, 'redirect_uri is required' if blank?(redirect_uri)
39
+
40
+ response = connection.post('connection', { code: code, redirect_uri: redirect_uri })
41
+
42
+ raise "Failed to get connection: #{response.status}: #{response.body}" 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
+
53
+ # Memoize the Faraday connection to reuse it across requests
54
+ def connection
55
+ @connection ||= Faraday.new(url: BASE_URL) do |faraday|
56
+ faraday.headers = {
57
+ 'Authorization' => "Bearer #{@api_key}",
58
+ 'Content-Type' => 'application/json'
59
+ }
60
+
61
+ faraday.request :json
62
+ faraday.response :json, content_type: /\bjson$/
63
+ faraday.adapter Faraday.default_adapter
64
+ end
65
+ end
66
+ end
67
+ end
@@ -11,6 +11,10 @@ module BundleUp
11
11
  @api_key = api_key
12
12
  end
13
13
 
14
+ def auth
15
+ @auth ||= BundleUp::Auth.new(@api_key)
16
+ end
17
+
14
18
  def connections
15
19
  @connections ||= BundleUp::Resources::Connection.new(@api_key)
16
20
  end
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
@@ -30,6 +30,21 @@ module BundleUp
30
30
  response.body
31
31
  end
32
32
 
33
+ # Fetches issues for a specific repository from the connected Git provider.
34
+ def issues(repo_name, params = {})
35
+ raise ArgumentError, 'repo_name is required to fetch issues.' if blank?(repo_name)
36
+
37
+ encoded_repo_name = URI.encode_www_form_component(repo_name)
38
+
39
+ response = connection.get("git/repos/#{encoded_repo_name}/issues") do |req|
40
+ req.params = params
41
+ end
42
+
43
+ raise "Failed to fetch git/repos/#{encoded_repo_name}/issues: #{response.status}" unless response.success?
44
+
45
+ response.body
46
+ end
47
+
33
48
  # Fetches tags for a specific repository from the connected Git provider.
34
49
  def tags(repo_name, params = {})
35
50
  raise ArgumentError, 'repo_name is required to fetch tags.' if blank?(repo_name)
@@ -33,6 +33,17 @@ module BundleUp
33
33
  response.body
34
34
  end
35
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
+
36
47
  private
37
48
 
38
49
  def blank?(value)
@@ -54,11 +54,6 @@ module BundleUp
54
54
  def me(params = {})
55
55
  (@me ||= BundleUp::Unify::Me.new(api_key, connection_id)).get(params)
56
56
  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
57
  end
63
58
  end
64
59
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BundleUp
4
- VERSION = '0.4.3'
4
+ VERSION = '0.6.0'
5
5
  end
data/lib/bundleup.rb CHANGED
@@ -4,6 +4,7 @@ require 'faraday'
4
4
 
5
5
  require_relative 'bundleup/version'
6
6
  require_relative 'bundleup/client'
7
+ require_relative 'bundleup/auth'
7
8
  require_relative 'bundleup/proxy'
8
9
  require_relative 'bundleup/unify'
9
10
  require_relative 'bundleup/mcp'
@@ -22,7 +23,6 @@ require_relative 'bundleup/unify/ticketing'
22
23
  require_relative 'bundleup/unify/crm'
23
24
  require_relative 'bundleup/unify/drive'
24
25
  require_relative 'bundleup/unify/calendar'
25
- require_relative 'bundleup/unify/mcp'
26
26
  require_relative 'bundleup/unify/me'
27
27
 
28
28
  # Main module for the BundleUp SDK.
@@ -0,0 +1,31 @@
1
+ module BundleUp
2
+ class Auth
3
+ BASE_URL: String
4
+
5
+ type connection = {
6
+ "connection_id" => String,
7
+ "external_id" => String?,
8
+ "integration_id" => String
9
+ }
10
+
11
+ attr_reader api_key: String
12
+
13
+ def initialize: (String api_key) -> void
14
+
15
+ def build_authorization_url: (
16
+ client_id: String,
17
+ integration_id: String,
18
+ redirect_uri: String,
19
+ ?external_id: String?,
20
+ ?state: String?
21
+ ) -> String
22
+
23
+ def get_connection_from_code: (code: String, redirect_uri: String) -> connection
24
+
25
+ private
26
+
27
+ def blank?: (untyped value) -> bool
28
+
29
+ def connection: () -> untyped
30
+ end
31
+ end
@@ -26,6 +26,19 @@ module BundleUp
26
26
  merged_at: String?
27
27
  }
28
28
 
29
+ type git_issue = {
30
+ id: Integer,
31
+ number: Integer,
32
+ title: String,
33
+ description: String?,
34
+ state: String,
35
+ url: String,
36
+ user: String?,
37
+ created_at: String,
38
+ updated_at: String,
39
+ closed_at: String?
40
+ }
41
+
29
42
  type git_tag = { name: String, commit_sha: String }
30
43
 
31
44
  type git_release = {
@@ -41,13 +54,24 @@ module BundleUp
41
54
 
42
55
  type git_branch = { name: String, commit_sha: String, protected: bool }
43
56
 
57
+ type git_commit = {
58
+ sha: String,
59
+ message: String?,
60
+ url: String,
61
+ author: String?,
62
+ author_email: String?,
63
+ committed_at: String?
64
+ }
65
+
44
66
  type metadata = { next: String? }
45
67
 
46
68
  def repos: (?Hash[Symbol, untyped] params) -> { data: Array[git_repo], metadata: metadata }
47
69
  def pulls: (String repo_name, ?Hash[Symbol, untyped] params) -> { data: Array[git_pull], metadata: metadata }
70
+ def issues: (String repo_name, ?Hash[Symbol, untyped] params) -> { data: Array[git_issue], metadata: metadata }
48
71
  def tags: (String repo_name, ?Hash[Symbol, untyped] params) -> { data: Array[git_tag], metadata: metadata }
49
72
  def releases: (String repo_name, ?Hash[Symbol, untyped] params) -> { data: Array[git_release], metadata: metadata }
50
73
  def branches: (String repo_name, ?Hash[Symbol, untyped] params) -> { data: Array[git_branch], metadata: metadata }
74
+ def commits: (String repo_name, ?Hash[Symbol, untyped] params) -> { data: Array[git_commit], metadata: metadata }
51
75
  end
52
76
  end
53
77
  end
@@ -21,12 +21,24 @@ module BundleUp
21
21
  updated_at: String?
22
22
  }
23
23
 
24
+ type ticketing_project = {
25
+ id: String,
26
+ name: String,
27
+ status: String?,
28
+ url: String?,
29
+ description: String?,
30
+ created_at: String?,
31
+ updated_at: String?
32
+ }
33
+
24
34
  type metadata = { next: String? }
25
35
 
26
36
  def tickets: (?Hash[Symbol, untyped] params) -> { data: Array[ticketing_ticket], metadata: metadata }
27
37
 
28
38
  def ticket: (String ticket_id, ?Hash[Symbol, untyped] params) -> { data: ticket }
29
39
 
40
+ def projects: (?Hash[Symbol, untyped] params) -> { data: Array[ticketing_project], metadata: metadata }
41
+
30
42
  private
31
43
 
32
44
  def blank?: (untyped) -> bool
@@ -8,7 +8,7 @@ module BundleUp
8
8
 
9
9
  def chat: () -> Chat
10
10
  def git: () -> Git
11
- def pm: () -> PM
11
+ def ticketing: () -> Ticketing
12
12
  def crm: () -> CRM
13
13
  def drive: () -> Drive
14
14
  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.4.3
4
+ version: 0.6.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-08-27 00:00:00.000000000 Z
11
+ date: 2026-10-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -167,9 +167,9 @@ files:
167
167
  - LICENSE
168
168
  - README.md
169
169
  - lib/bundleup.rb
170
+ - lib/bundleup/auth.rb
170
171
  - lib/bundleup/client.rb
171
172
  - lib/bundleup/mcp.rb
172
- - lib/bundleup/mcp_client.rb
173
173
  - lib/bundleup/proxy.rb
174
174
  - lib/bundleup/resources/base.rb
175
175
  - lib/bundleup/resources/connection.rb
@@ -182,10 +182,10 @@ 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
186
185
  - lib/bundleup/unify/me.rb
187
186
  - lib/bundleup/unify/ticketing.rb
188
187
  - lib/bundleup/version.rb
188
+ - sig/bundleup/auth.rbs
189
189
  - sig/bundleup/unify.rbs
190
190
  - sig/bundleup/unify/base.rbs
191
191
  - sig/bundleup/unify/calendar.rbs
@@ -1,202 +0,0 @@
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,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