@lanes-sh/link 0.3.2 → 0.4.1

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.
Files changed (69) hide show
  1. package/README.md +11 -3
  2. package/instructions/agents/lanes-link-scout.md +2 -2
  3. package/instructions/skills/lanes-link/SKILL.md +135 -11
  4. package/package.json +3 -1
  5. package/src/cli/argv.ts +52 -0
  6. package/src/cli/commands/connect/authorise.ts +5 -0
  7. package/src/cli/commands/connect/custom/ask.ts +167 -0
  8. package/src/cli/commands/connect/custom/credential.ts +143 -0
  9. package/src/cli/commands/connect/custom/derive.ts +229 -0
  10. package/src/cli/commands/connect/custom/index.ts +285 -0
  11. package/src/cli/commands/connect/custom/prompts.ts +160 -0
  12. package/src/cli/commands/connect/custom/spec.ts +293 -0
  13. package/src/cli/commands/connect/custom/values.ts +53 -0
  14. package/src/cli/commands/connect/custom/write.ts +166 -0
  15. package/src/cli/commands/connect/grant.ts +27 -0
  16. package/src/cli/commands/connect/index.ts +24 -27
  17. package/src/cli/commands/connect/outcome.ts +3 -1
  18. package/src/cli/commands/connect/requirements.ts +11 -1
  19. package/src/cli/commands/connect/settle.ts +17 -0
  20. package/src/cli/commands/connect/setup.ts +9 -1
  21. package/src/cli/commands/connect/strategy.ts +87 -0
  22. package/src/cli/commands/connect/unknown.ts +41 -0
  23. package/src/cli/commands/mcp/list.ts +123 -29
  24. package/src/cli/identity.ts +29 -5
  25. package/src/cli/main.ts +42 -14
  26. package/src/cli/oauth.ts +89 -36
  27. package/src/cli/runtime/open.ts +13 -1
  28. package/src/cli/runtime/registry.ts +12 -0
  29. package/src/cli/selection.ts +12 -0
  30. package/src/cli/usage.ts +9 -1
  31. package/src/connectivity/auth/README.md +8 -1
  32. package/src/connectivity/auth/strategy/index.ts +128 -4
  33. package/src/connectivity/connector.ts +11 -0
  34. package/src/connectivity/index.ts +11 -1
  35. package/src/connectivity/manifest/auth.ts +19 -0
  36. package/src/connectivity/manifest/connector.ts +21 -0
  37. package/src/connectivity/manifest/primitives.ts +5 -1
  38. package/src/connectivity/manifest/provider.ts +30 -12
  39. package/src/connectivity/provider.ts +55 -0
  40. package/src/connectivity/transports/factory.ts +1 -0
  41. package/src/connectivity/transports/http/index.ts +73 -2
  42. package/src/dispatch/dispatch.ts +44 -5
  43. package/src/providers/bunq/hints.ts +45 -0
  44. package/src/providers/bunq/index.ts +87 -0
  45. package/src/providers/bunq/redact.ts +75 -0
  46. package/src/providers/bunq/specs/bunq.v1.json +883 -0
  47. package/src/providers/bunq/specs/vendor.ts +396 -0
  48. package/src/providers/bunq/strategy/handshake.ts +211 -0
  49. package/src/providers/bunq/strategy/index.ts +298 -0
  50. package/src/providers/bunq/strategy/keys.ts +72 -0
  51. package/src/providers/custom/index.ts +1 -6
  52. package/src/providers/custom/load.ts +56 -14
  53. package/src/providers/custom/template.ts +1 -1
  54. package/src/providers/discord/hints.ts +195 -0
  55. package/src/providers/discord/index.ts +121 -0
  56. package/src/providers/discord/redact.ts +99 -0
  57. package/src/providers/discord/specs/discord.v10.json +2333 -0
  58. package/src/providers/discord/specs/vendor.ts +164 -0
  59. package/src/providers/google/specs/vendor.ts +32 -317
  60. package/src/providers/index.ts +9 -0
  61. package/src/providers/reddit/index.ts +113 -0
  62. package/src/providers/reddit/oauth.ts +77 -0
  63. package/src/providers/reddit/redact.ts +33 -0
  64. package/src/providers/reddit/scopes.ts +27 -0
  65. package/src/providers/reddit/specs/reddit.v1.json +700 -0
  66. package/src/providers/scopes.ts +2 -0
  67. package/src/providers/shared/openapi.ts +155 -0
  68. package/src/providers/shared/vendor-operations.ts +179 -0
  69. package/src/providers/shared/vendor-spec.ts +309 -0
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Vendor a trimmed OpenAPI spec for Discord's v10 HTTP API.
3
+ *
4
+ * Discord publishes an OpenAPI document of its own, which is unusual and
5
+ * welcome, but ships it as a public preview: "subject to breaking changes
6
+ * without advance notice, and should not be used within production
7
+ * environments". Vendoring is what answers that. The committed copy never moves
8
+ * under us, so a breaking change upstream becomes a diff in a refresh commit
9
+ * rather than a provider that stops working — and the surface an operator's bot
10
+ * token can reach stays reviewable.
11
+ *
12
+ * bun run vendor:discord
13
+ *
14
+ * The pipeline is `src/providers/shared/vendor-spec.ts`. What stays here is what
15
+ * only Discord knows: which operations are worth exposing, and the three shapes
16
+ * in its document that the generator cannot take as written.
17
+ */
18
+
19
+ import { vendorSpec } from '../../shared/vendor-spec.ts';
20
+
21
+ const SOURCE = 'https://raw.githubusercontent.com/discord/discord-api-spec/main/specs/openapi.json';
22
+
23
+ /**
24
+ * The operations this provider exposes, out of 242 in the document.
25
+ *
26
+ * Curated hard, and the curation is load-bearing rather than tidy: `connect`
27
+ * writes one policy rule, `discord.*`, so an operation vendored here is an
28
+ * operation an agent may call. This list *is* the boundary. Discord's own API
29
+ * would otherwise hand over kick, ban, timeout, role assignment, channel
30
+ * deletion, and guild settings against a server the operator owns.
31
+ *
32
+ * What is deliberately absent, so a later refresh does not quietly add it back:
33
+ * `bulk_delete_messages` (a mass action no announcement workflow needs), every
34
+ * `deprecated_*` pin variant (superseded paths, kept by Discord for old
35
+ * clients), and everything under members, roles, bans, invites, and guild
36
+ * settings.
37
+ */
38
+ const READS = [
39
+ // Whose token this is. The first call worth making after connecting, and the
40
+ // one that proves the `Bot ` prefix survived being pasted.
41
+ 'get_my_user',
42
+ // Navigation: which servers the bot was added to, what is in them, and what
43
+ // one channel is. An agent cannot post to a channel it cannot name.
44
+ 'list_my_guilds',
45
+ 'get_guild',
46
+ 'list_guild_channels',
47
+ 'get_channel',
48
+ // The triage read. Discord exposes no message search to bots, so reading a
49
+ // channel means paging `list_messages` with `after` and `limit`.
50
+ 'list_messages',
51
+ 'get_message',
52
+ 'list_pins',
53
+ 'list_message_reactions_by_emoji',
54
+ 'get_active_guild_threads',
55
+ ] as const;
56
+
57
+ const WRITES = [
58
+ // Posting, as the app.
59
+ 'create_message',
60
+ // Editing and retracting one's own announcement. `update_message` is the
61
+ // typo fix; `delete_message` is the retraction, which is why it is here and
62
+ // `bulk_delete_messages` is not.
63
+ 'update_message',
64
+ 'delete_message',
65
+ // Publishing a message posted in an announcement channel out to the servers
66
+ // that follow it — the one operation that makes an announcement channel worth
67
+ // using rather than an ordinary one.
68
+ 'crosspost_message',
69
+ // Triage marking: react to it, pin it, or turn it into a thread. All three
70
+ // are how a human reads back what the agent decided.
71
+ 'add_my_message_reaction',
72
+ 'create_pin',
73
+ 'create_thread_from_message',
74
+ ] as const;
75
+
76
+ /**
77
+ * Posting under the operator's own name rather than the app's.
78
+ *
79
+ * Discord has no API for acting as a user account — automating one violates
80
+ * their terms — so an integration posts as an application, and an application's
81
+ * messages carry an `APP` badge that cannot be removed. What *can* be set is the
82
+ * name and avatar, and `execute_webhook` sets them per message. So an
83
+ * announcement can read as the operator while an ordinary reply reads as the
84
+ * integration, from one bot token.
85
+ *
86
+ * The cost is stated in `docs/detailed/security.md`: `create_webhook` and
87
+ * `list_channel_webhooks` return the webhook's token in their response, and a
88
+ * webhook token is a standalone credential for posting to that channel.
89
+ */
90
+ const WEBHOOKS = ['list_channel_webhooks', 'create_webhook', 'execute_webhook'] as const;
91
+
92
+ /**
93
+ * Discord's v2 message components, collapsed to open objects.
94
+ *
95
+ * `mcp-from-openapi` inlines `$ref`s, and this union is referenced by four
96
+ * request schemas and again from inside two of its own members — so inlining
97
+ * duplicates the whole tree six times over. There is no wrapper schema to
98
+ * collapse instead; the union is written out inline at each site.
99
+ *
100
+ * Measured, this is the difference between a provider that ships and one that
101
+ * cannot: `execute_webhook` generates a 148.9 KB input schema against a 64 KB
102
+ * per-tool budget, `create_message` 76.2 KB, `update_message` 72.6 KB, and the
103
+ * whole surface 305.4 KB against a 192 KB budget. Opaque brings the worst tool to
104
+ * 10.4 KB and the surface to 34.1 KB.
105
+ *
106
+ * `RichEmbed` is deliberately *not* here. It costs 3.1 KB — 1.6% of the surface
107
+ * budget — and it is how an announcement gets a title, a colour, and fields, so
108
+ * it is the one rich shape worth schematising. Collapsing it instead was
109
+ * measured and moves the worst tool from 148.9 KB to 144.2 KB, which is to say
110
+ * embeds were never the problem.
111
+ */
112
+ const OPAQUE = [
113
+ 'ActionRowComponentForMessageRequest',
114
+ 'ContainerComponentForMessageRequest',
115
+ 'FileComponentForMessageRequest',
116
+ 'MediaGalleryComponentForMessageRequest',
117
+ 'SectionComponentForMessageRequest',
118
+ 'SeparatorComponentForMessageRequest',
119
+ 'TextDisplayComponentForMessageRequest',
120
+ ];
121
+
122
+ await vendorSpec('discord', {
123
+ source: SOURCE,
124
+ outputDirectory: import.meta.dir,
125
+ out: 'discord.v10.json',
126
+ operations: [...READS, ...WRITES, ...WEBHOOKS],
127
+ opaque: OPAQUE,
128
+ opaqueNote:
129
+ 'Structure omitted: this union inlines to hundreds of kilobytes. ' +
130
+ 'Pass the object as documented at https://discord.com/developers/docs/components/reference.',
131
+ vendoredNote:
132
+ 'Trimmed by src/providers/discord/specs/vendor.ts. Committed deliberately: upstream is a public preview that may change without notice, and a spec decides which paths are called with the operator bot token — so it must be reviewable rather than fetched at connect time.',
133
+
134
+ // Discord declares path parameters on the path item, not the operation. That
135
+ // is legal, and `mcp-from-openapi`'s validator does not read it: without this
136
+ // the document fails outright with 29 × MISSING_PATH_PARAMETER and generates
137
+ // no tools at all.
138
+ hoistPathParameters: true,
139
+
140
+ // `create_message`, `update_message` and `execute_webhook` also offer
141
+ // `multipart/form-data`, whose file fields are named `files[0]`. That is not a
142
+ // legal tool property name, and one illegal name rejects the entire tools list
143
+ // for every provider on the endpoint — so the only thing standing between us
144
+ // and that is the generator preferring JSON of its own accord.
145
+ //
146
+ // The transport can now form-encode as well as JSON-encode (ADR-045), but not
147
+ // multipart, so a multipart branch is unsendable regardless of which one the
148
+ // generator picks. Attachments are unavailable as a result, which is the honest
149
+ // state: ADR-017's machinery is the route to them, not a branch nothing sends.
150
+ requestContentTypes: ['application/json'],
151
+
152
+ // `execute_webhook`'s body is a top-level `anyOf` of two complete payload
153
+ // schemas. A union has no `properties` to walk, so the generator emits one
154
+ // argument literally named `body` and the connector then sends
155
+ // `{"body":{"content":"…"}}` where Discord expects `{"content":"…"}`. Nothing
156
+ // in the test suite can see this: the name is legal, the size is fine, the
157
+ // base_url matches — only a live call fails.
158
+ //
159
+ // Naming the branch that is actually wanted flattens it back to 17 real
160
+ // arguments. Lossless: the other branch's properties are a strict subset.
161
+ rewriteRequestBody: {
162
+ execute_webhook: { $ref: '#/components/schemas/IncomingWebhookRequestPartial' },
163
+ },
164
+ });
@@ -15,23 +15,13 @@
15
15
  * command rather than a hand edit.
16
16
  *
17
17
  * bun run vendor:google
18
+ *
19
+ * The pipeline itself is `src/providers/shared/vendor-spec.ts`. What stays here
20
+ * is what only Google knows: which operations are worth exposing, and which of
21
+ * its system parameters have to go.
18
22
  */
19
23
 
20
- import { mkdir, writeFile } from 'node:fs/promises';
21
- import { join } from 'node:path';
22
- import { OpenAPIToolGenerator, type McpOpenAPITool } from 'mcp-from-openapi';
23
-
24
- /** Kept in step with `BUDGET_KB` in `src/cli/tools.test.ts`, which enforces it. */
25
- const BUDGET_KB = 64;
26
-
27
- interface Spec {
28
- openapi: string;
29
- info: Record<string, unknown>;
30
- servers?: Array<{ url: string }>;
31
- paths: Record<string, Record<string, { operationId?: string } & Record<string, unknown>>>;
32
- components?: { schemas?: Record<string, unknown> } & Record<string, unknown>;
33
- [key: string]: unknown;
34
- }
24
+ import { vendorSpec } from '../../shared/vendor-spec.ts';
35
25
 
36
26
  /**
37
27
  * The operations each provider exposes.
@@ -321,126 +311,6 @@ const SELECTION: Record<
321
311
  // field but `title` on it anyway.
322
312
  };
323
313
 
324
- /** Every `$ref` target reachable from a value, transitively. */
325
- function referenced(root: unknown, schemas: Record<string, unknown>): Set<string> {
326
- const found = new Set<string>();
327
- const queue: unknown[] = [root];
328
-
329
- while (queue.length > 0) {
330
- const node = queue.pop();
331
- if (node === null || typeof node !== 'object') continue;
332
-
333
- if (Array.isArray(node)) {
334
- queue.push(...node);
335
- continue;
336
- }
337
-
338
- for (const [key, value] of Object.entries(node)) {
339
- if (key === '$ref' && typeof value === 'string') {
340
- const name = value.replace('#/components/schemas/', '');
341
- if (!found.has(name) && name in schemas) {
342
- found.add(name);
343
- queue.push(schemas[name]);
344
- }
345
- continue;
346
- }
347
- queue.push(value);
348
- }
349
- }
350
-
351
- return found;
352
- }
353
-
354
- /**
355
- * Cut reference cycles, replacing the back-edge with an open object.
356
- *
357
- * Gmail's `MessagePart` contains `MessagePart[]` — a MIME tree, so the
358
- * recursion is honest — and the OpenAPI tool generator inlines `$ref`s, so it
359
- * recurses until the stack ends. That silently costs the two draft-writing
360
- * operations, which are the useful half of `gmail.compose`.
361
- *
362
- * Cutting the back-edge rather than dropping the operation keeps the tool: the
363
- * field that matters for creating a draft is `raw`, an RFC 2822 message, and
364
- * nothing below the cut is required to fill it in. A depth-first walk marks the
365
- * names currently on the path, and any `$ref` reaching back to one of them
366
- * becomes a plain object.
367
- */
368
- function cutCycles(schemas: Record<string, unknown>): number {
369
- let cuts = 0;
370
-
371
- const walk = (node: unknown, path: Set<string>): void => {
372
- if (node === null || typeof node !== 'object') return;
373
-
374
- if (Array.isArray(node)) {
375
- for (const item of node) walk(item, path);
376
- return;
377
- }
378
-
379
- const record = node as Record<string, unknown>;
380
- for (const [key, value] of Object.entries(record)) {
381
- if (key === '$ref' && typeof value === 'string') {
382
- const name = value.replace('#/components/schemas/', '');
383
- if (path.has(name)) {
384
- delete record['$ref'];
385
- record['type'] = 'object';
386
- record['additionalProperties'] = true;
387
- record['description'] = `A nested ${name}. Structure omitted: it recurses.`;
388
- cuts++;
389
- } else if (name in schemas) {
390
- walk(schemas[name], new Set([...path, name]));
391
- }
392
- continue;
393
- }
394
- walk(value, path);
395
- }
396
- };
397
-
398
- for (const [name, schema] of Object.entries(schemas)) walk(schema, new Set([name]));
399
- return cuts;
400
- }
401
-
402
- /**
403
- * Replace a named schema with an open object.
404
- *
405
- * Same device as `cutCycles` and a different disease. That one cuts recursion;
406
- * this one cuts *fan-out*. `mcp-from-openapi` inlines `$ref`s, so a schema's
407
- * cost to us is not its size in the document but its size once every reference
408
- * below it has been expanded — and Sheets' `Request` is a union of some eighty
409
- * variants, each with its own nested grid, filter, and chart schemas, sharing
410
- * sub-schemas that inlining duplicates per occurrence.
411
- *
412
- * Measured: `sheets.spreadsheets.batchUpdate` generates a 2,469KB input schema,
413
- * against 45KB for the whole of Drive. That is unusable — it would be sent on
414
- * every `tools/list` — and it is also the only operation that can add a tab,
415
- * freeze a header, or format a cell, so dropping it is no better.
416
- *
417
- * Opaque keeps the operation and costs 3KB. The agent fills in `requests` from
418
- * the API it already knows, which is the same bet `raw` makes for a Gmail draft:
419
- * a well-known wire format is cheaper described than schematised. The pointer to
420
- * Google's reference is in the description because that is the only thing lost.
421
- *
422
- * Applied before `referenced`, so the schemas that were reachable only through
423
- * the replaced one leave the document entirely rather than lingering unused.
424
- */
425
- function makeOpaque(schemas: Record<string, unknown>, names: readonly string[]): number {
426
- let replaced = 0;
427
-
428
- for (const name of names) {
429
- if (!(name in schemas)) continue;
430
- const original = schemas[name] as { description?: string };
431
- schemas[name] = {
432
- type: 'object',
433
- additionalProperties: true,
434
- description:
435
- `${original.description ?? `A ${name}.`} Structure omitted: it expands to megabytes ` +
436
- 'when inlined. Pass the object as documented at https://developers.google.com/workspace.',
437
- };
438
- replaced++;
439
- }
440
-
441
- return replaced;
442
- }
443
-
444
314
  /**
445
315
  * Google's system parameters, dropped from every operation.
446
316
  *
@@ -474,188 +344,33 @@ const SYSTEM_PARAMETERS = new Set([
474
344
  ]);
475
345
 
476
346
  /**
477
- * Strip system parameters from a path item or an operation.
478
- *
479
- * They arrive as `$ref`s into `components.parameters`, and the component *key*
480
- * is not the parameter name — `$.xgafv` is keyed `_.xgafv`, because a `$` is
481
- * awkward in a JSON pointer. Matching on the key would therefore miss exactly
482
- * the one that breaks everything, so the ref is resolved first.
347
+ * The note recorded in each spec's `info`, and the one `makeOpaque` leaves
348
+ * behind. Both are baked into the committed JSON, so they are passed verbatim
349
+ * rather than reworded.
483
350
  */
484
- function dropSystemParameters(
485
- holder: Record<string, unknown>,
486
- components: Record<string, unknown>,
487
- ): number {
488
- const parameters = holder['parameters'];
489
- if (!Array.isArray(parameters)) return 0;
490
-
491
- const nameOf = (parameter: unknown): string | undefined => {
492
- const record = parameter as { name?: string; $ref?: string };
493
- if (record.name) return record.name;
494
- if (!record.$ref) return undefined;
495
-
496
- const key = record.$ref.replace('#/components/parameters/', '');
497
- return (components[key] as { name?: string } | undefined)?.name;
498
- };
499
-
500
- const kept = parameters.filter((parameter) => {
501
- const name = nameOf(parameter);
502
- return !(name && SYSTEM_PARAMETERS.has(name));
351
+ const VENDORED_NOTE =
352
+ 'Trimmed by src/providers/google/specs/vendor.ts. Committed deliberately: a spec decides which paths are called with the operator token, so it must be reviewable rather than fetched at connect time.';
353
+
354
+ const OPAQUE_NOTE =
355
+ 'Structure omitted: it expands to megabytes when inlined. ' +
356
+ 'Pass the object as documented at https://developers.google.com/workspace.';
357
+
358
+ for (const [id, selection] of Object.entries(SELECTION)) {
359
+ await vendorSpec(id, {
360
+ source: selection.source,
361
+ // `import.meta.dir` *is* the specs directory — this script lives beside what
362
+ // it writes. It used to be `scripts/vendor-google-specs.ts` and reached
363
+ // across the tree; commit 3cd03ce moved the script and the specs together
364
+ // but not the path arithmetic, so for a while this resolved to
365
+ // `src/providers/google/providers/builtin/specs`, created it, reported
366
+ // success, and left the committed spec untouched — making the documented
367
+ // "re-run the script" step a silent no-op.
368
+ outputDirectory: import.meta.dir,
369
+ out: selection.out,
370
+ operations: selection.operations,
371
+ ...(selection.opaque ? { opaque: selection.opaque } : {}),
372
+ opaqueNote: OPAQUE_NOTE,
373
+ vendoredNote: VENDORED_NOTE,
374
+ systemParameters: SYSTEM_PARAMETERS,
503
375
  });
504
-
505
- holder['parameters'] = kept;
506
- return parameters.length - kept.length;
507
- }
508
-
509
- const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head'];
510
-
511
- async function vendor(id: string): Promise<void> {
512
- const { source, out, operations, opaque } = SELECTION[id]!;
513
- const wanted = new Set(operations);
514
-
515
- const response = await fetch(source);
516
- if (!response.ok) throw new Error(`${source}: HTTP ${response.status}`);
517
- const spec = (await response.json()) as Spec;
518
-
519
- const paths: Spec['paths'] = {};
520
- const seen = new Set<string>();
521
-
522
- for (const [path, item] of Object.entries(spec.paths)) {
523
- const kept: Record<string, unknown> = {};
524
- for (const [method, operation] of Object.entries(item)) {
525
- // Path-level keys are not operations and must survive: `parameters` here
526
- // is where Google puts the query parameters shared by every method on the
527
- // path, and `fields` is one of them — which `drive.about.get` *requires*.
528
- // Dropping it produced a tool with no arguments at all and a 400 on every
529
- // call.
530
- if (!METHODS.includes(method)) {
531
- kept[method] = operation;
532
- continue;
533
- }
534
- const operationId = operation.operationId;
535
- if (!operationId || !wanted.has(operationId)) continue;
536
- kept[method] = operation;
537
- seen.add(operationId);
538
- }
539
-
540
- // Only path-level keys survived, so no operation here was wanted.
541
- if (!Object.keys(kept).some((key) => METHODS.includes(key))) continue;
542
- if (Object.keys(kept).length > 0) paths[path] = kept as Spec['paths'][string];
543
- }
544
-
545
- const componentParameters = (spec.components?.['parameters'] ?? {}) as Record<string, unknown>;
546
-
547
- let dropped = 0;
548
- for (const item of Object.values(paths)) {
549
- dropped += dropSystemParameters(item as unknown as Record<string, unknown>, componentParameters);
550
- for (const [method, operation] of Object.entries(item)) {
551
- if (!METHODS.includes(method)) continue;
552
- dropped += dropSystemParameters(
553
- operation as unknown as Record<string, unknown>,
554
- componentParameters,
555
- );
556
- }
557
- }
558
-
559
- // Drop response schemas.
560
- //
561
- // Two reasons, and the second is the blocking one. Nothing reads them: the
562
- // connector hands the response body back as text, because an agent wants the
563
- // JSON, not a validated shape. And Gmail's response schemas are recursive —
564
- // `Message.payload` is a `MessagePart`, which contains `MessagePart[]` — which
565
- // sends the OpenAPI tool generator into infinite recursion and silently drops
566
- // eight of Gmail's twelve operations from the tool list.
567
- for (const item of Object.values(paths)) {
568
- for (const [method, operation] of Object.entries(item)) {
569
- if (!METHODS.includes(method)) continue;
570
- (operation as { responses?: unknown }).responses = {
571
- '200': { description: 'Success. The response body is returned verbatim.' },
572
- };
573
- }
574
- }
575
-
576
- const missing = operations.filter((operationId) => !seen.has(operationId));
577
- if (missing.length > 0) {
578
- // Loudly, rather than shipping a provider quietly missing capabilities: an
579
- // upstream rename should fail the refresh, not shrink the tool list.
580
- throw new Error(`${id}: these operations are not in the spec — ${missing.join(', ')}`);
581
- }
582
-
583
- const schemas = spec.components?.schemas ?? {};
584
- const opaqued = makeOpaque(schemas, opaque ?? []);
585
- const keep = referenced(paths, schemas);
586
- const trimmedSchemas = Object.fromEntries(
587
- Object.entries(schemas).filter(([name]) => keep.has(name)),
588
- );
589
- const cuts = cutCycles(trimmedSchemas);
590
-
591
- const trimmed: Spec = {
592
- openapi: spec.openapi,
593
- info: {
594
- ...spec.info,
595
- 'x-vendored-from': source,
596
- 'x-vendored-note':
597
- 'Trimmed by src/providers/google/specs/vendor.ts. Committed deliberately: a spec decides which paths are called with the operator token, so it must be reviewable rather than fetched at connect time.',
598
- },
599
- ...(spec.servers ? { servers: spec.servers } : {}),
600
- paths,
601
- components: { ...spec.components, schemas: trimmedSchemas },
602
- };
603
-
604
- // `import.meta.dir` *is* the specs directory — this script lives beside what it
605
- // writes. It used to be `scripts/vendor-google-specs.ts` and reached across the
606
- // tree; commit 3cd03ce moved the script and the specs together but not the path
607
- // arithmetic, so for a while this resolved to
608
- // `src/providers/google/providers/builtin/specs`, created it, reported success,
609
- // and left the committed spec untouched — making the documented "re-run the
610
- // script" step a silent no-op.
611
- const directory = import.meta.dir;
612
- await mkdir(directory, { recursive: true });
613
- await writeFile(join(directory, out), `${JSON.stringify(trimmed, null, 2)}\n`);
614
-
615
- const size = Math.round(JSON.stringify(trimmed).length / 1024);
616
- console.log(
617
- ` ${id.padEnd(6)} ${String(Object.keys(paths).length).padStart(2)} paths, ` +
618
- `${seen.size} operations, ${Object.keys(trimmedSchemas).length} schemas, ` +
619
- `${cuts} cycle${cuts === 1 ? '' : 's'} cut, ${opaqued} made opaque, ` +
620
- `${dropped} system params dropped, ${size}KB`,
621
- );
622
-
623
- await reportLargestTools(trimmed);
624
376
  }
625
-
626
- /**
627
- * The number the budget is actually about.
628
- *
629
- * Everything on the line above counts the *spec*; `cli/tools.test.ts` measures
630
- * the **generated input schema**, and the two differ by orders of magnitude
631
- * because `mcp-from-openapi` inlines `$ref`s — a schema shared by ten fields is
632
- * ten copies once generated. Reasoning about `spreadsheets.create` from a schema
633
- * count put it at 122 KB when the real figure was 1,133 KB, which is the whole
634
- * difference between "just over" and "seventeen times over".
635
- *
636
- * Printed here so the refresh that adds an operation shows its cost, rather than
637
- * leaving it to a test failure to say so after the fact.
638
- */
639
- async function reportLargestTools(trimmed: Spec): Promise<void> {
640
- try {
641
- const generator = await OpenAPIToolGenerator.fromJSON(trimmed);
642
- const measured = (await generator.generateTools())
643
- .map((tool: McpOpenAPITool) => ({
644
- name: tool.metadata.operationId ?? tool.name,
645
- kb: JSON.stringify(tool.inputSchema).length / 1024,
646
- }))
647
- .sort((a, b) => b.kb - a.kb);
648
-
649
- for (const { name, kb } of measured.slice(0, 3)) {
650
- const over = kb > BUDGET_KB ? ` ✗ over the ${BUDGET_KB}KB budget` : '';
651
- console.log(` ${name.padEnd(38)} ${kb.toFixed(1).padStart(8)}KB${over}`);
652
- }
653
- } catch (error) {
654
- // A generator failure is a real problem, but it is `tools.test.ts`'s to
655
- // report against every provider at once. Refusing to finish the vendoring
656
- // over it would leave the specs half-written.
657
- console.log(` (could not measure generated schemas: ${String(error)})`);
658
- }
659
- }
660
-
661
- for (const id of Object.keys(SELECTION)) await vendor(id);
@@ -1,5 +1,7 @@
1
1
  import type { ProviderDefinition, ProviderManifest } from '#connectivity';
2
+ import { bunq } from './bunq/index.ts';
2
3
  import { calendar } from './google/calendar/index.ts';
4
+ import { discord } from './discord/index.ts';
3
5
  import { contacts } from './google/contacts/index.ts';
4
6
  import { docs } from './google/docs/index.ts';
5
7
  import { github } from './github/index.ts';
@@ -16,6 +18,7 @@ import { icloudDrive } from './icloud/drive/index.ts';
16
18
  import { icloudMail } from './icloud/mail/index.ts';
17
19
  import { linear } from './linear/index.ts';
18
20
  import { notion } from './notion/index.ts';
21
+ import { reddit } from './reddit/index.ts';
19
22
  import { slack } from './slack/index.ts';
20
23
 
21
24
  /**
@@ -53,6 +56,8 @@ export const PROVIDERS: readonly (ProviderManifest | ProviderDefinition)[] = [
53
56
  linear,
54
57
  github,
55
58
  slack,
59
+ reddit,
60
+ discord,
56
61
  gmail,
57
62
  drive,
58
63
  sheets,
@@ -67,6 +72,7 @@ export const PROVIDERS: readonly (ProviderManifest | ProviderDefinition)[] = [
67
72
  icloudCalendar,
68
73
  icloudContacts,
69
74
  icloudDrive,
75
+ bunq,
70
76
  ];
71
77
 
72
78
  /** The manifest half of an entry, whichever shape it arrived in. */
@@ -95,8 +101,11 @@ export {
95
101
  tasks,
96
102
  } from './google/index.ts';
97
103
  export { icloudCalendar, icloudContacts, icloudDrive, icloudMail } from './icloud/index.ts';
104
+ export { bunq } from './bunq/index.ts';
105
+ export { discord } from './discord/index.ts';
98
106
  export { github } from './github/index.ts';
99
107
  export { linear } from './linear/index.ts';
100
108
  export { notion } from './notion/index.ts';
109
+ export { reddit } from './reddit/index.ts';
101
110
  export { slack } from './slack/index.ts';
102
111
  export { SCOPE_MEANINGS, type ScopeMeaning } from './scopes.ts';