bundleup-sdk 0.4.3 → 0.5.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: ab7e53fa4d67aa688569b213a6b9f090cce9b8e557203e598ef4ce946e4ac508
4
+ data.tar.gz: 5226846b47d962f8c616eb14e6c47ee8836bde8a63f610cfc155fda1eec879df
5
5
  SHA512:
6
- metadata.gz: 13c18248fe861fb462cf3d9019764d13a7925031b49aa40d37f5ab7c3b106e019407140873dda6467057777313bc58cc6cb661daac8f102ff462b318aec4c413
7
- data.tar.gz: c3bc826ddfb41e39616049a651b180dffd36bb3ee939f27ee315bd350400ae25dbec72666025f22860944d20f82e1945dc20f856b7a2c2cc0302cb671b7b0f9f
6
+ metadata.gz: 0a8085e498cfd804bbd5d7b93840997f0d719a4d040d4d0639f2d51621976217b1cfb1038db537e0ca46d917c0440a8ed6b7ab83b3e362c34d45c064dff7a1df
7
+ data.tar.gz: 823c5ebb016f7c2f31cdae1a838556f2ea7d62a25d0d6193aead188dfe045392cbf940892f258d24a2167fbc5f620816d03274154d07535031c66535c232909d
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** - Connect agents to a provider's own MCP server or to BundleUp's Unified 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,218 @@ 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
+ `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.
1344
+
1345
+ #### Creating an MCP Client
1346
+
1347
+ ```ruby
1348
+ mcp = client.mcp('conn_123abc')
1349
+ ```
1350
+
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
+ #### Model-Hosted MCP
1385
+
1386
+ 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.
1387
+
1388
+ ```ruby
1389
+ hosted = client.mcp('conn_123abc').hosted
1390
+
1391
+ response = openai.responses.create(
1392
+ model: 'gpt-4o',
1393
+ input: 'What issues are assigned to me?',
1394
+ tools: [
1395
+ {
1396
+ type: 'mcp',
1397
+ server_label: 'linear',
1398
+ server_url: hosted[:url],
1399
+ authorization: hosted[:token],
1400
+ require_approval: 'never'
1401
+ }
1402
+ ]
1403
+ )
1404
+ ```
1405
+
1406
+ Anthropic's connector takes the same pair as `url` and `authorization_token`. `client.unify('conn_123abc').mcp.hosted` returns them for Unified MCP.
1407
+
1408
+ `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
+
1410
+ 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.
1411
+
1412
+ #### Using an MCP Client Library
1413
+
1414
+ `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.
1415
+
1416
+ ```ruby
1417
+ transport = client.mcp('conn_123abc').transport
1418
+
1419
+ transport[:url] # => "https://mcp.bundleup.io"
1420
+ transport[:headers] # => { "Authorization" => "Bearer ...", "BU-Connection-Id" => "conn_123abc", ... }
1421
+ ```
1422
+
1423
+ #### Sending JSON-RPC Directly
1424
+
1425
+ 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.
1426
+
1427
+ ```ruby
1428
+ mcp = client.mcp('conn_123abc')
1429
+
1430
+ # 1. Handshake
1431
+ init = mcp.post({
1432
+ jsonrpc: '2.0',
1433
+ id: 1,
1434
+ method: 'initialize',
1435
+ params: {
1436
+ protocolVersion: '2025-06-18',
1437
+ capabilities: {},
1438
+ clientInfo: { name: 'my-agent', version: '1.0.0' }
1439
+ }
1440
+ })
1441
+
1442
+ session_id = init.headers['mcp-session-id']
1443
+ session = session_id ? { 'Mcp-Session-Id' => session_id } : {}
1444
+
1445
+ # 2. Confirm the handshake (a notification — no id, no response body)
1446
+ mcp.post({ jsonrpc: '2.0', method: 'notifications/initialized' }, headers: session)
1447
+
1448
+ # 3. List tools
1449
+ response = mcp.post({ jsonrpc: '2.0', id: 2, method: 'tools/list' }, headers: session)
1450
+ result = parse(response)['result']
1451
+
1452
+ puts result['tools']
1453
+ ```
1454
+
1455
+ Providers may answer with `text/event-stream` rather than JSON, so responses need unwrapping either way:
1456
+
1457
+ ```ruby
1458
+ require 'json'
1459
+
1460
+ def parse(response)
1461
+ return JSON.parse(response.body) unless response.headers['content-type'].to_s.include?('text/event-stream')
1462
+
1463
+ data = response.body.lines
1464
+ .select { |line| line.start_with?('data:') }
1465
+ .map { |line| line[5..].strip }
1466
+ .join("\n")
1467
+
1468
+ JSON.parse(data)
1469
+ end
1470
+ ```
1471
+
1472
+ Tool lists can be paginated. If `result['nextCursor']` is set, call `tools/list` again with `params: { cursor: result['nextCursor'] }` until it comes back empty.
1473
+
1474
+ Calling a tool follows the same shape:
1475
+
1476
+ ```ruby
1477
+ response = mcp.post({
1478
+ jsonrpc: '2.0',
1479
+ id: 3,
1480
+ method: 'tools/call',
1481
+ params: { name: 'create_issue', arguments: { title: 'Login broken' } }
1482
+ }, headers: session)
1483
+ ```
1484
+
1485
+ #### Sessions
1486
+
1487
+ MCP sessions live on the provider's server — BundleUp holds no session state. Close one when you are done:
1488
+
1489
+ ```ruby
1490
+ mcp.delete(headers: { 'Mcp-Session-Id' => session_id })
1491
+ ```
1492
+
1493
+ #### Errors
1494
+
1495
+ 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.
1496
+
1497
+ ```ruby
1498
+ response = mcp.post(body)
1499
+
1500
+ unless response.success?
1501
+ error = JSON.parse(response.body)
1502
+ # connection_invalid, connection_refresh_failed, mcp_not_supported, rate_limit
1503
+ warn "#{error['code']}: #{error['message']}"
1504
+ end
1505
+ ```
1506
+
1507
+ Every JSON-RPC message counts toward the rate limit of 100 requests per 60 seconds, per connection — including the `initialize` handshake.
1508
+
1509
+ #### Merging Several Connections
1510
+
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:
1512
+
1513
+ ```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
+ }
1519
+
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
1524
+
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)
1529
+ end
1530
+ ```
1531
+
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
+ ```
1546
+
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.
1548
+
1174
1549
  ## Error Handling
1175
1550
 
1176
1551
  The SDK raises exceptions for errors. Always wrap SDK calls in rescue blocks for proper error handling.
@@ -1212,7 +1587,9 @@ lib/
1212
1587
  ├── bundleup.rb # Main entry point
1213
1588
  ├── bundleup/
1214
1589
  │ ├── client.rb # Main client class
1590
+ │ ├── auth.rb # Auth API (authorization URL + code exchange)
1215
1591
  │ ├── proxy.rb # Proxy API implementation
1592
+ │ ├── mcp.rb # MCP API (transport + managed sessions)
1216
1593
  │ ├── unify.rb # Unify API client wrapper
1217
1594
  │ ├── version.rb # Gem version
1218
1595
  │ ├── 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
@@ -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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BundleUp
4
- VERSION = '0.4.3'
4
+ VERSION = '0.5.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'
@@ -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.5.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-09-30 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
@@ -186,6 +186,7 @@ files:
186
186
  - lib/bundleup/unify/me.rb
187
187
  - lib/bundleup/unify/ticketing.rb
188
188
  - lib/bundleup/version.rb
189
+ - sig/bundleup/auth.rbs
189
190
  - sig/bundleup/unify.rbs
190
191
  - sig/bundleup/unify/base.rbs
191
192
  - 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