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 +4 -4
- data/README.md +328 -0
- data/lib/bundleup/auth.rb +67 -0
- data/lib/bundleup/client.rb +4 -0
- data/lib/bundleup/mcp.rb +4 -206
- data/lib/bundleup/unify/git.rb +15 -0
- data/lib/bundleup/unify/ticketing.rb +11 -0
- data/lib/bundleup/unify.rb +0 -5
- data/lib/bundleup/version.rb +1 -1
- data/lib/bundleup.rb +1 -1
- data/sig/bundleup/auth.rbs +31 -0
- data/sig/bundleup/unify/git.rbs +24 -0
- data/sig/bundleup/unify/ticketing.rbs +12 -0
- data/sig/bundleup/unify.rbs +1 -1
- metadata +4 -4
- data/lib/bundleup/mcp_client.rb +0 -202
- data/lib/bundleup/unify/mcp.rb +0 -44
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9265bf377a655ed9056e02bd22d1ec24fec68304e7ad4881c99013c42ca516f2
|
|
4
|
+
data.tar.gz: 4f9dac8314ce2d26640048984f7bef53e8921dfa9d4a7be5c5857412af5d3b00
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/bundleup/client.rb
CHANGED
data/lib/bundleup/mcp.rb
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
|
|
5
5
|
module BundleUp
|
|
6
|
-
#
|
|
6
|
+
# A connection's MCP server.
|
|
7
7
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# response
|
|
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
|
data/lib/bundleup/unify/git.rb
CHANGED
|
@@ -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)
|
data/lib/bundleup/unify.rb
CHANGED
|
@@ -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
|
data/lib/bundleup/version.rb
CHANGED
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
|
data/sig/bundleup/unify/git.rbs
CHANGED
|
@@ -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
|
data/sig/bundleup/unify.rbs
CHANGED
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
|
+
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-
|
|
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
|
data/lib/bundleup/mcp_client.rb
DELETED
|
@@ -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
|
data/lib/bundleup/unify/mcp.rb
DELETED
|
@@ -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
|