bundleup-sdk 0.4.2 â 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 +4 -4
- data/README.md +480 -2
- data/lib/bundleup/auth.rb +67 -0
- data/lib/bundleup/client.rb +4 -0
- data/lib/bundleup/unify/calendar.rb +35 -0
- data/lib/bundleup/unify/chat.rb +20 -0
- data/lib/bundleup/unify/git.rb +15 -0
- data/lib/bundleup/unify/ticketing.rb +35 -0
- data/lib/bundleup/unify.rb +6 -0
- data/lib/bundleup/version.rb +1 -1
- data/lib/bundleup.rb +2 -0
- data/sig/bundleup/auth.rbs +31 -0
- data/sig/bundleup/unify/calendar.rbs +23 -0
- data/sig/bundleup/unify/chat.rbs +11 -0
- data/sig/bundleup/unify/git.rbs +24 -0
- data/sig/bundleup/unify/ticketing.rbs +29 -1
- data/sig/bundleup/unify.rbs +2 -1
- metadata +6 -3
- data/lib/bundleup/mcp_client.rb +0 -202
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ab7e53fa4d67aa688569b213a6b9f090cce9b8e557203e598ef4ce946e4ac508
|
|
4
|
+
data.tar.gz: 5226846b47d962f8c616eb14e6c47ee8836bde8a63f610cfc155fda1eec879df
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
@@ -83,7 +84,7 @@ Runnable examples are available in the [`examples/`](./examples) directory:
|
|
|
83
84
|
|
|
84
85
|
- [`examples/basic_usage.rb`](./examples/basic_usage.rb) - Client setup, connections, integrations, and webhooks
|
|
85
86
|
- [`examples/proxy_api.rb`](./examples/proxy_api.rb) - Proxy API GET request with a connection
|
|
86
|
-
- [`examples/unify_api.rb`](./examples/unify_api.rb) - Unify Chat, Git, PM, Ticketing, and
|
|
87
|
+
- [`examples/unify_api.rb`](./examples/unify_api.rb) - Unify Chat, Git, PM, Ticketing, Drive, and Calendar endpoint usage
|
|
87
88
|
- [`examples/README.md`](./examples/README.md) - Setup and execution instructions
|
|
88
89
|
|
|
89
90
|
## Quick Start
|
|
@@ -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.
|
|
@@ -737,6 +802,35 @@ end
|
|
|
737
802
|
puts "Fetched #{all_channels.length} total channels"
|
|
738
803
|
```
|
|
739
804
|
|
|
805
|
+
##### List Messages
|
|
806
|
+
|
|
807
|
+
Messages in one channel, newest first. `author.name` is nil on Slack, which returns only a user id on a message.
|
|
808
|
+
|
|
809
|
+
```ruby
|
|
810
|
+
result = unify.chat.messages('C123', limit: 100, after: nil, include_raw: false)
|
|
811
|
+
|
|
812
|
+
puts "Messages: #{result['data']}"
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
**Response:**
|
|
816
|
+
|
|
817
|
+
```ruby
|
|
818
|
+
{
|
|
819
|
+
'data' => [
|
|
820
|
+
{
|
|
821
|
+
'id' => '1755712345.123456',
|
|
822
|
+
'text' => 'Deploy finished',
|
|
823
|
+
'author' => { 'id' => 'U024BE7LH', 'name' => nil },
|
|
824
|
+
'created_at' => '2026-08-20T18:32:25.123Z',
|
|
825
|
+
'thread_id' => nil
|
|
826
|
+
}
|
|
827
|
+
],
|
|
828
|
+
'metadata' => {
|
|
829
|
+
'next' => 'cursor_def456'
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
```
|
|
833
|
+
|
|
740
834
|
##### Send Message
|
|
741
835
|
|
|
742
836
|
Send a message to a channel on the connected chat platform.
|
|
@@ -844,6 +938,38 @@ puts "Pull Requests: #{result['data']}"
|
|
|
844
938
|
}
|
|
845
939
|
```
|
|
846
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
|
+
|
|
847
973
|
##### List Tags
|
|
848
974
|
|
|
849
975
|
```ruby
|
|
@@ -927,6 +1053,37 @@ puts "Branches: #{result['data']}"
|
|
|
927
1053
|
}
|
|
928
1054
|
```
|
|
929
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
|
+
|
|
930
1087
|
#### Ticketing API
|
|
931
1088
|
|
|
932
1089
|
The Ticketing API provides a unified interface for ticketing and project management platforms like Jira, Linear, and Asana.
|
|
@@ -971,6 +1128,69 @@ open_tickets = result['data'].select { |ticket| ticket['status'] == 'open' }
|
|
|
971
1128
|
sorted_by_date = result['data'].sort_by { |ticket| Time.parse(ticket['created_at']) }.reverse
|
|
972
1129
|
```
|
|
973
1130
|
|
|
1131
|
+
##### Get a Ticket
|
|
1132
|
+
|
|
1133
|
+
Fetch one ticket by ID. Not supported by Basecamp, whose API only serves a to-do underneath its project.
|
|
1134
|
+
|
|
1135
|
+
```ruby
|
|
1136
|
+
result = unify.ticketing.ticket('PROJ-123')
|
|
1137
|
+
|
|
1138
|
+
puts "Ticket: #{result['data']}"
|
|
1139
|
+
```
|
|
1140
|
+
|
|
1141
|
+
**Response:**
|
|
1142
|
+
|
|
1143
|
+
```ruby
|
|
1144
|
+
{
|
|
1145
|
+
'data' => {
|
|
1146
|
+
'id' => 'PROJ-123',
|
|
1147
|
+
'url' => 'https://jira.example.com/browse/PROJ-123',
|
|
1148
|
+
'title' => 'Fix login bug',
|
|
1149
|
+
'status' => 'in_progress',
|
|
1150
|
+
'description' => 'Users are unable to log in',
|
|
1151
|
+
'created_at' => '2024-01-15T10:30:00Z',
|
|
1152
|
+
'updated_at' => '2024-01-20T14:22:00Z'
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
```
|
|
1156
|
+
|
|
1157
|
+
A single resource carries no pagination, so there is no `metadata` on this response.
|
|
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
|
+
|
|
974
1194
|
#### CRM API
|
|
975
1195
|
|
|
976
1196
|
The CRM API provides a unified interface for CRM platforms like Attio, HubSpot, PipeDrive, Salesforce and Zoho.
|
|
@@ -1071,6 +1291,261 @@ puts "Files: #{result['data']}"
|
|
|
1071
1291
|
}
|
|
1072
1292
|
```
|
|
1073
1293
|
|
|
1294
|
+
#### Calendar API
|
|
1295
|
+
|
|
1296
|
+
The Calendar API provides a unified interface for calendar and scheduling platforms like Google Calendar, Outlook, Calendly and Zoom.
|
|
1297
|
+
|
|
1298
|
+
##### List Events
|
|
1299
|
+
|
|
1300
|
+
`starts_after` and `starts_before` are required â the endpoint refuses an unbounded listing.
|
|
1301
|
+
|
|
1302
|
+
```ruby
|
|
1303
|
+
result = unify.calendar.events(
|
|
1304
|
+
starts_after: '2026-09-01T00:00:00Z',
|
|
1305
|
+
starts_before: '2026-09-08T00:00:00Z',
|
|
1306
|
+
limit: 100,
|
|
1307
|
+
after: nil,
|
|
1308
|
+
include_raw: false
|
|
1309
|
+
)
|
|
1310
|
+
|
|
1311
|
+
puts "Events: #{result['data']}"
|
|
1312
|
+
```
|
|
1313
|
+
|
|
1314
|
+
**Response:**
|
|
1315
|
+
|
|
1316
|
+
```ruby
|
|
1317
|
+
{
|
|
1318
|
+
'data' => [
|
|
1319
|
+
{
|
|
1320
|
+
'id' => 'evt_123',
|
|
1321
|
+
'title' => 'Design review',
|
|
1322
|
+
'description' => 'Walk through the new onboarding flow',
|
|
1323
|
+
'start_date' => '2026-09-01T15:00:00Z',
|
|
1324
|
+
'end_date' => '2026-09-01T16:00:00Z',
|
|
1325
|
+
'status' => 'confirmed',
|
|
1326
|
+
'url' => 'https://calendar.google.com/event?eid=...'
|
|
1327
|
+
}
|
|
1328
|
+
],
|
|
1329
|
+
'metadata' => {
|
|
1330
|
+
'next' => nil
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
```
|
|
1334
|
+
|
|
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.
|
|
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
|
+
|
|
1074
1549
|
## Error Handling
|
|
1075
1550
|
|
|
1076
1551
|
The SDK raises exceptions for errors. Always wrap SDK calls in rescue blocks for proper error handling.
|
|
@@ -1112,7 +1587,9 @@ lib/
|
|
|
1112
1587
|
âââ bundleup.rb # Main entry point
|
|
1113
1588
|
âââ bundleup/
|
|
1114
1589
|
â âââ client.rb # Main client class
|
|
1590
|
+
â âââ auth.rb # Auth API (authorization URL + code exchange)
|
|
1115
1591
|
â âââ proxy.rb # Proxy API implementation
|
|
1592
|
+
â âââ mcp.rb # MCP API (transport + managed sessions)
|
|
1116
1593
|
â âââ unify.rb # Unify API client wrapper
|
|
1117
1594
|
â âââ version.rb # Gem version
|
|
1118
1595
|
â âââ resources/
|
|
@@ -1126,7 +1603,8 @@ lib/
|
|
|
1126
1603
|
â âââ git.rb # Git Unify API
|
|
1127
1604
|
â âââ ticketing.rb # Ticketing Unify API
|
|
1128
1605
|
â âââ crm.rb # CRM Unify API
|
|
1129
|
-
â
|
|
1606
|
+
â âââ drive.rb # Drive Unify API
|
|
1607
|
+
â âââ calendar.rb # Calendar Unify API
|
|
1130
1608
|
sig/ # RBS type signatures (mirrors lib/)
|
|
1131
1609
|
spec/ # Test files
|
|
1132
1610
|
```
|
|
@@ -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
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BundleUp
|
|
4
|
+
module Unify
|
|
5
|
+
# Client for Calendar-related Unify endpoints.
|
|
6
|
+
class Calendar < Base
|
|
7
|
+
# Fetches events from the connected calendar provider.
|
|
8
|
+
#
|
|
9
|
+
# +params+ must carry +starts_after+ and +starts_before+ (ISO 8601) â
|
|
10
|
+
# the endpoint refuses an unbounded listing.
|
|
11
|
+
def events(params = {})
|
|
12
|
+
raise ArgumentError, 'starts_after and starts_before are required to fetch events.' if window_missing?(params)
|
|
13
|
+
|
|
14
|
+
response = connection.get('calendar/events') do |req|
|
|
15
|
+
req.params = params
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
raise "Failed to fetch calendar/events: #{response.status}" unless response.success?
|
|
19
|
+
|
|
20
|
+
response.body
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def window_missing?(params)
|
|
26
|
+
params ||= {}
|
|
27
|
+
|
|
28
|
+
%i[starts_after starts_before].any? do |key|
|
|
29
|
+
value = params[key] || params[key.to_s]
|
|
30
|
+
value.nil? || value.to_s.empty?
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/bundleup/unify/chat.rb
CHANGED
|
@@ -26,6 +26,26 @@ module BundleUp
|
|
|
26
26
|
response.body
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
# Fetches messages in a channel from the connected chat provider.
|
|
30
|
+
#
|
|
31
|
+
# Newest first. +author.name+ is nil on Slack, which returns only a user
|
|
32
|
+
# id on a message.
|
|
33
|
+
def messages(channel_id, params = {})
|
|
34
|
+
raise ArgumentError, 'channel_id is required to fetch messages.' if channel_id.nil?
|
|
35
|
+
|
|
36
|
+
encoded_channel_id = URI.encode_www_form_component(channel_id)
|
|
37
|
+
|
|
38
|
+
response = connection.get("chat/channels/#{encoded_channel_id}/messages") do |req|
|
|
39
|
+
req.params = params
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
unless response.success?
|
|
43
|
+
raise "Failed to fetch chat/channels/#{encoded_channel_id}/messages: #{response.status}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
response.body
|
|
47
|
+
end
|
|
48
|
+
|
|
29
49
|
# Sends a message to a channel on the connected chat provider.
|
|
30
50
|
def message(channel_id, text)
|
|
31
51
|
raise ArgumentError, 'channel_id is required to send a message.' if channel_id.nil?
|
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)
|
|
@@ -14,6 +14,41 @@ module BundleUp
|
|
|
14
14
|
|
|
15
15
|
response.body
|
|
16
16
|
end
|
|
17
|
+
|
|
18
|
+
# Fetches a single ticket by ID from the connected ticketing tool.
|
|
19
|
+
#
|
|
20
|
+
# Not supported by Basecamp, whose API only serves a to-do underneath its
|
|
21
|
+
# project â an id on its own cannot address one.
|
|
22
|
+
def ticket(ticket_id, params = {})
|
|
23
|
+
raise ArgumentError, 'ticket_id is required to fetch a ticket.' if blank?(ticket_id)
|
|
24
|
+
|
|
25
|
+
encoded_id = URI.encode_www_form_component(ticket_id)
|
|
26
|
+
|
|
27
|
+
response = connection.get("ticketing/tickets/#{encoded_id}") do |req|
|
|
28
|
+
req.params = params
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
raise "Failed to fetch ticketing/tickets/#{encoded_id}: #{response.status}" unless response.success?
|
|
32
|
+
|
|
33
|
+
response.body
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Fetches projects from the connected ticketing tool.
|
|
37
|
+
def projects(params = {})
|
|
38
|
+
response = connection.get('ticketing/projects') do |req|
|
|
39
|
+
req.params = params
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
raise "Failed to fetch ticketing/projects: #{response.status}" unless response.success?
|
|
43
|
+
|
|
44
|
+
response.body
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def blank?(value)
|
|
50
|
+
value.nil? || value.to_s.empty?
|
|
51
|
+
end
|
|
17
52
|
end
|
|
18
53
|
end
|
|
19
54
|
end
|
data/lib/bundleup/unify.rb
CHANGED
|
@@ -12,6 +12,7 @@ module BundleUp
|
|
|
12
12
|
@git = BundleUp::Unify::Git.new(api_key, connection_id)
|
|
13
13
|
@crm = BundleUp::Unify::CRM.new(api_key, connection_id)
|
|
14
14
|
@drive = BundleUp::Unify::Drive.new(api_key, connection_id)
|
|
15
|
+
@calendar = BundleUp::Unify::Calendar.new(api_key, connection_id)
|
|
15
16
|
@api_key = api_key
|
|
16
17
|
@connection_id = connection_id
|
|
17
18
|
end
|
|
@@ -41,6 +42,11 @@ module BundleUp
|
|
|
41
42
|
@drive ||= BundleUp::Unify::Drive.new(api_key, connection_id)
|
|
42
43
|
end
|
|
43
44
|
|
|
45
|
+
# Access the Calendar API for the connection.
|
|
46
|
+
def calendar
|
|
47
|
+
@calendar ||= BundleUp::Unify::Calendar.new(api_key, connection_id)
|
|
48
|
+
end
|
|
49
|
+
|
|
44
50
|
# Fetch the account this connection is authenticated as.
|
|
45
51
|
#
|
|
46
52
|
# `me` is the one unified method every provider implements, so it hangs off
|
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'
|
|
@@ -21,6 +22,7 @@ require_relative 'bundleup/unify/git'
|
|
|
21
22
|
require_relative 'bundleup/unify/ticketing'
|
|
22
23
|
require_relative 'bundleup/unify/crm'
|
|
23
24
|
require_relative 'bundleup/unify/drive'
|
|
25
|
+
require_relative 'bundleup/unify/calendar'
|
|
24
26
|
require_relative 'bundleup/unify/mcp'
|
|
25
27
|
require_relative 'bundleup/unify/me'
|
|
26
28
|
|
|
@@ -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
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module BundleUp
|
|
2
|
+
module Unify
|
|
3
|
+
class Calendar < Base
|
|
4
|
+
type calendar_event = {
|
|
5
|
+
id: String,
|
|
6
|
+
title: String?,
|
|
7
|
+
description: String?,
|
|
8
|
+
start_date: String?,
|
|
9
|
+
end_date: String?,
|
|
10
|
+
status: String?,
|
|
11
|
+
url: String?
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type metadata = { next: String? }
|
|
15
|
+
|
|
16
|
+
def events: (?Hash[Symbol, untyped] params) -> { data: Array[calendar_event], metadata: metadata }
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def window_missing?: (Hash[Symbol, untyped]?) -> bool
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/sig/bundleup/unify/chat.rbs
CHANGED
|
@@ -3,10 +3,21 @@ module BundleUp
|
|
|
3
3
|
class Chat < Base
|
|
4
4
|
type chat_user = { id: String, name: String }
|
|
5
5
|
type chat_channel = { id: String, name: String }
|
|
6
|
+
type chat_author = { id: String?, name: String? }
|
|
7
|
+
|
|
8
|
+
type chat_message = {
|
|
9
|
+
id: String,
|
|
10
|
+
text: String?,
|
|
11
|
+
author: chat_author?,
|
|
12
|
+
created_at: String?,
|
|
13
|
+
thread_id: String?
|
|
14
|
+
}
|
|
15
|
+
|
|
6
16
|
type metadata = { next: String? }
|
|
7
17
|
|
|
8
18
|
def users: (?Hash[Symbol, untyped] params) -> { data: Array[chat_user], metadata: metadata }
|
|
9
19
|
def channels: (?Hash[Symbol, untyped] params) -> { data: Array[chat_channel], metadata: metadata }
|
|
20
|
+
def messages: (String channel_id, ?Hash[Symbol, untyped] params) -> { data: Array[chat_message], metadata: metadata }
|
|
10
21
|
def message: (String channel_id, String text) -> { data: Hash[String, untyped] }
|
|
11
22
|
end
|
|
12
23
|
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
|
|
@@ -11,9 +11,37 @@ module BundleUp
|
|
|
11
11
|
updated_at: String
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
type ticket = {
|
|
15
|
+
id: String,
|
|
16
|
+
title: String,
|
|
17
|
+
status: String?,
|
|
18
|
+
url: String?,
|
|
19
|
+
description: String?,
|
|
20
|
+
created_at: String?,
|
|
21
|
+
updated_at: String?
|
|
22
|
+
}
|
|
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
|
+
|
|
14
34
|
type metadata = { next: String? }
|
|
15
35
|
|
|
16
|
-
def
|
|
36
|
+
def tickets: (?Hash[Symbol, untyped] params) -> { data: Array[ticketing_ticket], metadata: metadata }
|
|
37
|
+
|
|
38
|
+
def ticket: (String ticket_id, ?Hash[Symbol, untyped] params) -> { data: ticket }
|
|
39
|
+
|
|
40
|
+
def projects: (?Hash[Symbol, untyped] params) -> { data: Array[ticketing_project], metadata: metadata }
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def blank?: (untyped) -> bool
|
|
17
45
|
end
|
|
18
46
|
end
|
|
19
47
|
end
|
data/sig/bundleup/unify.rbs
CHANGED
|
@@ -8,9 +8,10 @@ module BundleUp
|
|
|
8
8
|
|
|
9
9
|
def chat: () -> Chat
|
|
10
10
|
def git: () -> Git
|
|
11
|
-
def
|
|
11
|
+
def ticketing: () -> Ticketing
|
|
12
12
|
def crm: () -> CRM
|
|
13
13
|
def drive: () -> Drive
|
|
14
|
+
def calendar: () -> Calendar
|
|
14
15
|
def me: (?Hash[Symbol, untyped] params) -> { data: Me::account }
|
|
15
16
|
end
|
|
16
17
|
end
|
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.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-
|
|
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
|
|
@@ -177,6 +177,7 @@ files:
|
|
|
177
177
|
- lib/bundleup/resources/webhook.rb
|
|
178
178
|
- lib/bundleup/unify.rb
|
|
179
179
|
- lib/bundleup/unify/base.rb
|
|
180
|
+
- lib/bundleup/unify/calendar.rb
|
|
180
181
|
- lib/bundleup/unify/chat.rb
|
|
181
182
|
- lib/bundleup/unify/crm.rb
|
|
182
183
|
- lib/bundleup/unify/drive.rb
|
|
@@ -185,8 +186,10 @@ files:
|
|
|
185
186
|
- lib/bundleup/unify/me.rb
|
|
186
187
|
- lib/bundleup/unify/ticketing.rb
|
|
187
188
|
- lib/bundleup/version.rb
|
|
189
|
+
- sig/bundleup/auth.rbs
|
|
188
190
|
- sig/bundleup/unify.rbs
|
|
189
191
|
- sig/bundleup/unify/base.rbs
|
|
192
|
+
- sig/bundleup/unify/calendar.rbs
|
|
190
193
|
- sig/bundleup/unify/chat.rbs
|
|
191
194
|
- sig/bundleup/unify/crm.rbs
|
|
192
195
|
- sig/bundleup/unify/drive.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
|