@lanes-sh/link 0.3.2 → 0.4.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.
- package/README.md +11 -3
- package/instructions/agents/lanes-link-scout.md +2 -2
- package/instructions/skills/lanes-link/SKILL.md +135 -11
- package/package.json +3 -1
- package/src/cli/argv.ts +52 -0
- package/src/cli/commands/connect/authorise.ts +5 -0
- package/src/cli/commands/connect/custom/ask.ts +167 -0
- package/src/cli/commands/connect/custom/credential.ts +143 -0
- package/src/cli/commands/connect/custom/derive.ts +229 -0
- package/src/cli/commands/connect/custom/index.ts +285 -0
- package/src/cli/commands/connect/custom/prompts.ts +160 -0
- package/src/cli/commands/connect/custom/spec.ts +293 -0
- package/src/cli/commands/connect/custom/values.ts +53 -0
- package/src/cli/commands/connect/custom/write.ts +166 -0
- package/src/cli/commands/connect/grant.ts +27 -0
- package/src/cli/commands/connect/index.ts +24 -27
- package/src/cli/commands/connect/outcome.ts +3 -1
- package/src/cli/commands/connect/requirements.ts +11 -1
- package/src/cli/commands/connect/settle.ts +17 -0
- package/src/cli/commands/connect/setup.ts +9 -1
- package/src/cli/commands/connect/strategy.ts +87 -0
- package/src/cli/commands/connect/unknown.ts +41 -0
- package/src/cli/identity.ts +29 -5
- package/src/cli/main.ts +37 -13
- package/src/cli/oauth.ts +89 -36
- package/src/cli/runtime/open.ts +13 -1
- package/src/cli/runtime/registry.ts +12 -0
- package/src/cli/selection.ts +12 -0
- package/src/cli/usage.ts +9 -1
- package/src/connectivity/auth/README.md +8 -1
- package/src/connectivity/auth/strategy/index.ts +128 -4
- package/src/connectivity/connector.ts +11 -0
- package/src/connectivity/index.ts +11 -1
- package/src/connectivity/manifest/auth.ts +19 -0
- package/src/connectivity/manifest/connector.ts +21 -0
- package/src/connectivity/manifest/primitives.ts +5 -1
- package/src/connectivity/manifest/provider.ts +30 -12
- package/src/connectivity/provider.ts +55 -0
- package/src/connectivity/transports/factory.ts +1 -0
- package/src/connectivity/transports/http/index.ts +73 -2
- package/src/dispatch/dispatch.ts +44 -5
- package/src/providers/bunq/hints.ts +43 -0
- package/src/providers/bunq/index.ts +87 -0
- package/src/providers/bunq/redact.ts +64 -0
- package/src/providers/bunq/specs/bunq.v1.json +864 -0
- package/src/providers/bunq/specs/vendor.ts +338 -0
- package/src/providers/bunq/strategy/handshake.ts +211 -0
- package/src/providers/bunq/strategy/index.ts +298 -0
- package/src/providers/bunq/strategy/keys.ts +72 -0
- package/src/providers/custom/index.ts +1 -6
- package/src/providers/custom/load.ts +56 -14
- package/src/providers/custom/template.ts +1 -1
- package/src/providers/discord/hints.ts +195 -0
- package/src/providers/discord/index.ts +121 -0
- package/src/providers/discord/redact.ts +99 -0
- package/src/providers/discord/specs/discord.v10.json +2333 -0
- package/src/providers/discord/specs/vendor.ts +164 -0
- package/src/providers/google/specs/vendor.ts +32 -317
- package/src/providers/index.ts +9 -0
- package/src/providers/reddit/index.ts +113 -0
- package/src/providers/reddit/oauth.ts +77 -0
- package/src/providers/reddit/redact.ts +33 -0
- package/src/providers/reddit/scopes.ts +27 -0
- package/src/providers/reddit/specs/reddit.v1.json +700 -0
- package/src/providers/scopes.ts +2 -0
- package/src/providers/shared/openapi.ts +155 -0
- package/src/providers/shared/vendor-operations.ts +98 -0
- package/src/providers/shared/vendor-spec.ts +309 -0
package/src/providers/scopes.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { GOOGLE_SCOPE_MEANINGS } from './google/shared/scopes.ts';
|
|
2
2
|
import { LINEAR_SCOPE_MEANINGS } from './linear/scopes.ts';
|
|
3
|
+
import { REDDIT_SCOPE_MEANINGS } from './reddit/scopes.ts';
|
|
3
4
|
import { SLACK_SCOPE_MEANINGS } from './slack/scopes.ts';
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -24,5 +25,6 @@ export interface ScopeMeaning {
|
|
|
24
25
|
export const SCOPE_MEANINGS: Record<string, ScopeMeaning> = {
|
|
25
26
|
...GOOGLE_SCOPE_MEANINGS,
|
|
26
27
|
...LINEAR_SCOPE_MEANINGS,
|
|
28
|
+
...REDDIT_SCOPE_MEANINGS,
|
|
27
29
|
...SLACK_SCOPE_MEANINGS,
|
|
28
30
|
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec surgery that is not about any one vendor.
|
|
3
|
+
*
|
|
4
|
+
* Two operations, both forced on us by the same property of
|
|
5
|
+
* `mcp-from-openapi`: it **inlines** `$ref`s. That makes a generated input
|
|
6
|
+
* schema cost what the reference graph below it costs, not what the document
|
|
7
|
+
* says — so a cycle is unbounded recursion rather than a self-reference, and a
|
|
8
|
+
* shared schema is duplicated at every use site.
|
|
9
|
+
*
|
|
10
|
+
* These lived inside `google/specs/vendor.ts` while Google was the only
|
|
11
|
+
* vendored spec. bunq is the second, and it needs `cutCycles` more urgently
|
|
12
|
+
* than Google does: bunq's published document fails to generate **55**
|
|
13
|
+
* operations, every payment endpoint among them, because `Payment` recurses
|
|
14
|
+
* through `RequestInquiry` and `RequestResponse`. Copying seventy lines into a
|
|
15
|
+
* second script is how the two come to disagree about what a cycle is.
|
|
16
|
+
*
|
|
17
|
+
* `makeOpaque` was held back at first for a good reason: it writes a sentence
|
|
18
|
+
* into the schema it replaces, and that sentence points at the vendor's own
|
|
19
|
+
* reference documentation, which made it a Google function that happened to look
|
|
20
|
+
* generic. Taking the sentence as a parameter is what actually settles that —
|
|
21
|
+
* the surgery is generic, only the pointer was not — so it lives here now, with
|
|
22
|
+
* each caller passing its own note. Discord is the third vendor to need it.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The shape both vendoring scripts read and write. Generic OpenAPI 3.x. */
|
|
26
|
+
export interface Spec {
|
|
27
|
+
openapi: string;
|
|
28
|
+
info: Record<string, unknown>;
|
|
29
|
+
servers?: Array<{ url: string }>;
|
|
30
|
+
paths: Record<string, Record<string, { operationId?: string } & Record<string, unknown>>>;
|
|
31
|
+
components?: { schemas?: Record<string, unknown> } & Record<string, unknown>;
|
|
32
|
+
[key: string]: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Every `$ref` target reachable from a value, transitively. */
|
|
36
|
+
export function referenced(root: unknown, schemas: Record<string, unknown>): Set<string> {
|
|
37
|
+
const found = new Set<string>();
|
|
38
|
+
const queue: unknown[] = [root];
|
|
39
|
+
|
|
40
|
+
while (queue.length > 0) {
|
|
41
|
+
const node = queue.pop();
|
|
42
|
+
if (node === null || typeof node !== 'object') continue;
|
|
43
|
+
|
|
44
|
+
if (Array.isArray(node)) {
|
|
45
|
+
queue.push(...node);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const [key, value] of Object.entries(node)) {
|
|
50
|
+
if (key === '$ref' && typeof value === 'string') {
|
|
51
|
+
const name = value.replace('#/components/schemas/', '');
|
|
52
|
+
if (!found.has(name) && name in schemas) {
|
|
53
|
+
found.add(name);
|
|
54
|
+
queue.push(schemas[name]);
|
|
55
|
+
}
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
queue.push(value);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return found;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Cut reference cycles, replacing the back-edge with an open object.
|
|
67
|
+
*
|
|
68
|
+
* Gmail's `MessagePart` contains `MessagePart[]` — a MIME tree, so the
|
|
69
|
+
* recursion is honest — and the OpenAPI tool generator inlines `$ref`s, so it
|
|
70
|
+
* recurses until the stack ends. That silently costs the two draft-writing
|
|
71
|
+
* operations, which are the useful half of `gmail.compose`.
|
|
72
|
+
*
|
|
73
|
+
* Cutting the back-edge rather than dropping the operation keeps the tool: the
|
|
74
|
+
* field that matters for creating a draft is `raw`, an RFC 2822 message, and
|
|
75
|
+
* nothing below the cut is required to fill it in. A depth-first walk marks the
|
|
76
|
+
* names currently on the path, and any `$ref` reaching back to one of them
|
|
77
|
+
* becomes a plain object.
|
|
78
|
+
*/
|
|
79
|
+
export function cutCycles(schemas: Record<string, unknown>): number {
|
|
80
|
+
let cuts = 0;
|
|
81
|
+
|
|
82
|
+
const walk = (node: unknown, path: Set<string>): void => {
|
|
83
|
+
if (node === null || typeof node !== 'object') return;
|
|
84
|
+
|
|
85
|
+
if (Array.isArray(node)) {
|
|
86
|
+
for (const item of node) walk(item, path);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const record = node as Record<string, unknown>;
|
|
91
|
+
for (const [key, value] of Object.entries(record)) {
|
|
92
|
+
if (key === '$ref' && typeof value === 'string') {
|
|
93
|
+
const name = value.replace('#/components/schemas/', '');
|
|
94
|
+
if (path.has(name)) {
|
|
95
|
+
delete record['$ref'];
|
|
96
|
+
record['type'] = 'object';
|
|
97
|
+
record['additionalProperties'] = true;
|
|
98
|
+
record['description'] = `A nested ${name}. Structure omitted: it recurses.`;
|
|
99
|
+
cuts++;
|
|
100
|
+
} else if (name in schemas) {
|
|
101
|
+
walk(schemas[name], new Set([...path, name]));
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
walk(value, path);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
for (const [name, schema] of Object.entries(schemas)) walk(schema, new Set([name]));
|
|
110
|
+
return cuts;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Replace a named schema with an open object.
|
|
115
|
+
*
|
|
116
|
+
* Same device as `cutCycles` and a different disease. That one cuts recursion;
|
|
117
|
+
* this one cuts *fan-out*. Because `$ref`s are inlined, a union of eighty
|
|
118
|
+
* variants — each with its own nested grid, filter, and chart schemas, sharing
|
|
119
|
+
* sub-schemas that inlining duplicates per occurrence — costs orders of
|
|
120
|
+
* magnitude more generated than it does on disk.
|
|
121
|
+
*
|
|
122
|
+
* Measured on the worst case in the repository: one spreadsheet operation
|
|
123
|
+
* generated a 2,469KB input schema against 45KB for a whole other API. That is
|
|
124
|
+
* unusable — it would be sent on every `tools/list` — and it was also the only
|
|
125
|
+
* operation that could add a tab, freeze a header, or format a cell, so
|
|
126
|
+
* dropping it was no better.
|
|
127
|
+
*
|
|
128
|
+
* Opaque keeps the operation for a few KB. The agent fills the field in from the
|
|
129
|
+
* API it already knows, which is the same bet `raw` makes for a mail draft: a
|
|
130
|
+
* well-known wire format is cheaper described than schematised. `note` carries
|
|
131
|
+
* the pointer to the vendor's reference, because that is the only thing lost.
|
|
132
|
+
*
|
|
133
|
+
* Apply before `referenced`, so the schemas that were reachable only through the
|
|
134
|
+
* replaced one leave the document entirely rather than lingering unused.
|
|
135
|
+
*/
|
|
136
|
+
export function makeOpaque(
|
|
137
|
+
schemas: Record<string, unknown>,
|
|
138
|
+
names: readonly string[],
|
|
139
|
+
note: string,
|
|
140
|
+
): number {
|
|
141
|
+
let replaced = 0;
|
|
142
|
+
|
|
143
|
+
for (const name of names) {
|
|
144
|
+
if (!(name in schemas)) continue;
|
|
145
|
+
const original = schemas[name] as { description?: string };
|
|
146
|
+
schemas[name] = {
|
|
147
|
+
type: 'object',
|
|
148
|
+
additionalProperties: true,
|
|
149
|
+
description: `${original.description ?? `A ${name}.`} ${note}`,
|
|
150
|
+
};
|
|
151
|
+
replaced++;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return replaced;
|
|
155
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document surgery on path items and operations.
|
|
3
|
+
*
|
|
4
|
+
* The other axis from `vendor-schemas.ts`: that file rewrites the schemas map,
|
|
5
|
+
* this one rewrites what an operation declares it takes — its parameters and the
|
|
6
|
+
* content types its request body offers. Both exist because the generated tool,
|
|
7
|
+
* not the document, is what has to be correct and small.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head'];
|
|
11
|
+
|
|
12
|
+
/** The name a parameter goes by, resolving a `$ref` into `components.parameters`. */
|
|
13
|
+
export function parameterName(
|
|
14
|
+
parameter: unknown,
|
|
15
|
+
components: Record<string, unknown>,
|
|
16
|
+
): string | undefined {
|
|
17
|
+
const record = parameter as { name?: string; $ref?: string };
|
|
18
|
+
if (record.name) return record.name;
|
|
19
|
+
if (!record.$ref) return undefined;
|
|
20
|
+
|
|
21
|
+
// The component *key* is not the parameter name — `$.xgafv` is keyed
|
|
22
|
+
// `_.xgafv`, because a `$` is awkward in a JSON pointer. Matching on the key
|
|
23
|
+
// would therefore miss exactly the one that breaks everything.
|
|
24
|
+
const key = record.$ref.replace('#/components/parameters/', '');
|
|
25
|
+
return (components[key] as { name?: string } | undefined)?.name;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Strip vendor system parameters from a path item or an operation. */
|
|
29
|
+
export function dropSystemParameters(
|
|
30
|
+
holder: Record<string, unknown>,
|
|
31
|
+
components: Record<string, unknown>,
|
|
32
|
+
system: ReadonlySet<string>,
|
|
33
|
+
): number {
|
|
34
|
+
const parameters = holder['parameters'];
|
|
35
|
+
if (!Array.isArray(parameters)) return 0;
|
|
36
|
+
|
|
37
|
+
const kept = parameters.filter((parameter) => {
|
|
38
|
+
const name = parameterName(parameter, components);
|
|
39
|
+
return !(name && system.has(name));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
holder['parameters'] = kept;
|
|
43
|
+
return parameters.length - kept.length;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Move a path item's shared `parameters` onto each of its operations. */
|
|
47
|
+
export function hoistParameters(
|
|
48
|
+
item: Record<string, unknown>,
|
|
49
|
+
components: Record<string, unknown>,
|
|
50
|
+
): number {
|
|
51
|
+
const shared = item['parameters'];
|
|
52
|
+
if (!Array.isArray(shared) || shared.length === 0) return 0;
|
|
53
|
+
|
|
54
|
+
let moved = 0;
|
|
55
|
+
for (const [method, operation] of Object.entries(item)) {
|
|
56
|
+
if (!METHODS.includes(method)) continue;
|
|
57
|
+
|
|
58
|
+
const holder = operation as Record<string, unknown>;
|
|
59
|
+
const own = Array.isArray(holder['parameters']) ? (holder['parameters'] as unknown[]) : [];
|
|
60
|
+
const taken = new Set(
|
|
61
|
+
own.map((parameter) => parameterName(parameter, components)).filter(Boolean),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// An operation that already declares the parameter keeps its own. Adding
|
|
65
|
+
// both is what produces a renamed duplicate.
|
|
66
|
+
const inherited = shared.filter(
|
|
67
|
+
(parameter) => !taken.has(parameterName(parameter, components)),
|
|
68
|
+
);
|
|
69
|
+
holder['parameters'] = [...inherited, ...own];
|
|
70
|
+
moved += inherited.length;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
delete item['parameters'];
|
|
74
|
+
return moved;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Keep only the listed request body content types, refusing to empty a body. */
|
|
78
|
+
export function narrowRequestBody(
|
|
79
|
+
operation: Record<string, unknown>,
|
|
80
|
+
operationId: string,
|
|
81
|
+
allowed: readonly string[],
|
|
82
|
+
): number {
|
|
83
|
+
const body = operation['requestBody'] as { content?: Record<string, unknown> } | undefined;
|
|
84
|
+
if (!body?.content) return 0;
|
|
85
|
+
|
|
86
|
+
const before = Object.keys(body.content);
|
|
87
|
+
const kept = before.filter((type) => allowed.includes(type));
|
|
88
|
+
if (kept.length === 0) {
|
|
89
|
+
// Silently leaving the body alone would reintroduce whatever the filter was
|
|
90
|
+
// added to remove, so this is a refusal rather than a fallback.
|
|
91
|
+
throw new Error(
|
|
92
|
+
`${operationId}: request body offers ${before.join(', ')}, none of which is kept by requestContentTypes (${allowed.join(', ')}).`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
body.content = Object.fromEntries(kept.map((type) => [type, body.content![type]]));
|
|
97
|
+
return before.length - kept.length;
|
|
98
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trim an upstream OpenAPI document down to a reviewable, committed spec.
|
|
3
|
+
*
|
|
4
|
+
* The output is **committed**, and that is the point. A spec decides which paths
|
|
5
|
+
* get called with the operator's token, and `connect` grants everything a
|
|
6
|
+
* provider discovers — so a spec fetched at connect time from a third party
|
|
7
|
+
* could introduce, say, a DELETE operation that lands on the vendor's own host
|
|
8
|
+
* holding a real credential. Vendoring makes the surface reviewable and the
|
|
9
|
+
* build reproducible; a per-provider script exists so refreshing it is one
|
|
10
|
+
* command rather than a hand edit.
|
|
11
|
+
*
|
|
12
|
+
* Every caller passes its own `outputDirectory`, its own note strings, and opts
|
|
13
|
+
* in to the repairs its upstream document happens to need. Nothing here knows
|
|
14
|
+
* which vendor it is trimming: a document that declares path parameters one
|
|
15
|
+
* level up is a shape, not a brand, and the flag that fixes it is set by the
|
|
16
|
+
* provider that has that shape.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { OpenAPIToolGenerator, type McpOpenAPITool } from 'mcp-from-openapi';
|
|
22
|
+
import { cutCycles, makeOpaque, referenced, type Spec } from './openapi.ts';
|
|
23
|
+
import {
|
|
24
|
+
METHODS,
|
|
25
|
+
dropSystemParameters,
|
|
26
|
+
hoistParameters,
|
|
27
|
+
narrowRequestBody,
|
|
28
|
+
} from './vendor-operations.ts';
|
|
29
|
+
|
|
30
|
+
/** Kept in step with `BUDGET_KB` in `src/cli/tools.test.ts`, which enforces it. */
|
|
31
|
+
export const BUDGET_KB = 64;
|
|
32
|
+
|
|
33
|
+
export interface VendorSpecOptions {
|
|
34
|
+
/** The upstream document. */
|
|
35
|
+
readonly source: string;
|
|
36
|
+
/**
|
|
37
|
+
* Where to write, which is the caller's own `import.meta.dir`.
|
|
38
|
+
*
|
|
39
|
+
* A parameter rather than this module's own directory, and not a stylistic
|
|
40
|
+
* choice: resolving it here would write every provider's spec into
|
|
41
|
+
* `providers/shared/`, report success, and leave the committed file untouched.
|
|
42
|
+
* That exact no-op shipped once before — see the note in the Google script.
|
|
43
|
+
*/
|
|
44
|
+
readonly outputDirectory: string;
|
|
45
|
+
readonly out: string;
|
|
46
|
+
/** The operations to keep, by `operationId`. Everything else is dropped. */
|
|
47
|
+
readonly operations: readonly string[];
|
|
48
|
+
/** Schemas to replace with an open object, before reachability is computed. */
|
|
49
|
+
readonly opaque?: readonly string[];
|
|
50
|
+
/** The sentence `makeOpaque` appends, naming where the real shape is documented. */
|
|
51
|
+
readonly opaqueNote?: string;
|
|
52
|
+
/** What `info['x-vendored-note']` records about why this file is committed. */
|
|
53
|
+
readonly vendoredNote: string;
|
|
54
|
+
/** Vendor-specific query parameters to strip from every operation. */
|
|
55
|
+
readonly systemParameters?: ReadonlySet<string>;
|
|
56
|
+
/**
|
|
57
|
+
* Move path-item `parameters` onto each operation, and delete the shared copy.
|
|
58
|
+
*
|
|
59
|
+
* Off by default, because a document that already declares its parameters per
|
|
60
|
+
* operation must not be rewritten — and a path-item `parameters` array is also
|
|
61
|
+
* where some vendors put the query parameters shared by every method on the
|
|
62
|
+
* path, which the operations genuinely need to inherit rather than own.
|
|
63
|
+
*
|
|
64
|
+
* On, it repairs a document whose path parameters live only at the path level.
|
|
65
|
+
* `mcp-from-openapi`'s validator reads `operation.parameters` and nothing else,
|
|
66
|
+
* so such a document fails validation outright with one
|
|
67
|
+
* `MISSING_PATH_PARAMETER` per parameter and generates zero tools. Deleting the
|
|
68
|
+
* shared copy is half the fix: leaving both makes the generator see the same
|
|
69
|
+
* parameter twice and rename one of them.
|
|
70
|
+
*/
|
|
71
|
+
readonly hoistPathParameters?: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Request body content types to keep. Omit to keep all of them.
|
|
74
|
+
*
|
|
75
|
+
* The HTTP connector encodes a body as JSON or form-urlencoded, whichever the
|
|
76
|
+
* document declares (ADR-045). It cannot do `multipart/form-data` at all — a
|
|
77
|
+
* multipart branch would be JSON-stringified under a multipart content type —
|
|
78
|
+
* so that branch describes a request it cannot make, and carries the cost of
|
|
79
|
+
* one anyway.
|
|
80
|
+
*
|
|
81
|
+
* Worse than dead weight: multipart file fields are named `files[0]`, which is
|
|
82
|
+
* not a legal tool property name, and one illegal name rejects the *entire*
|
|
83
|
+
* tools list for every provider on the endpoint. Nothing but the generator's
|
|
84
|
+
* own content-type preference order keeps it out of the generated schema, and
|
|
85
|
+
* that is a third party's choice to change.
|
|
86
|
+
*/
|
|
87
|
+
readonly requestContentTypes?: readonly string[];
|
|
88
|
+
/**
|
|
89
|
+
* Replace an operation's whole request body schema, by `operationId`.
|
|
90
|
+
*
|
|
91
|
+
* For a body the generator cannot flatten. A top-level `anyOf` has no
|
|
92
|
+
* `properties` to walk, so it emits a single argument literally named `body`
|
|
93
|
+
* and the connector then sends `{"body":{…}}` where the vendor expects
|
|
94
|
+
* `{…}` — every test in the repository passes and only a live call fails.
|
|
95
|
+
* Naming the branch that is actually wanted flattens it back out.
|
|
96
|
+
*
|
|
97
|
+
* Applied before reachability, so a branch that is no longer referenced leaves
|
|
98
|
+
* the document rather than lingering unused.
|
|
99
|
+
*/
|
|
100
|
+
readonly rewriteRequestBody?: Readonly<Record<string, unknown>>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function vendorSpec(id: string, options: VendorSpecOptions): Promise<void> {
|
|
104
|
+
const { source, operations } = options;
|
|
105
|
+
const wanted = new Set(operations);
|
|
106
|
+
|
|
107
|
+
const response = await fetch(source);
|
|
108
|
+
if (!response.ok) throw new Error(`${source}: HTTP ${response.status}`);
|
|
109
|
+
const spec = (await response.json()) as Spec;
|
|
110
|
+
|
|
111
|
+
const paths: Spec['paths'] = {};
|
|
112
|
+
const seen = new Set<string>();
|
|
113
|
+
|
|
114
|
+
for (const [path, item] of Object.entries(spec.paths)) {
|
|
115
|
+
const kept: Record<string, unknown> = {};
|
|
116
|
+
for (const [method, operation] of Object.entries(item)) {
|
|
117
|
+
// Path-level keys are not operations and must survive: `parameters` here
|
|
118
|
+
// is where some vendors put the query parameters shared by every method on
|
|
119
|
+
// the path, and one of those may be *required* by an operation. Dropping
|
|
120
|
+
// it produced a tool with no arguments at all and a 400 on every call.
|
|
121
|
+
if (!METHODS.includes(method)) {
|
|
122
|
+
kept[method] = operation;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const operationId = operation.operationId;
|
|
126
|
+
if (!operationId || !wanted.has(operationId)) continue;
|
|
127
|
+
kept[method] = operation;
|
|
128
|
+
seen.add(operationId);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Only path-level keys survived, so no operation here was wanted.
|
|
132
|
+
if (!Object.keys(kept).some((key) => METHODS.includes(key))) continue;
|
|
133
|
+
if (Object.keys(kept).length > 0) paths[path] = kept as Spec['paths'][string];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const componentParameters = (spec.components?.['parameters'] ?? {}) as Record<string, unknown>;
|
|
137
|
+
const system = options.systemParameters ?? new Set<string>();
|
|
138
|
+
|
|
139
|
+
let hoisted = 0;
|
|
140
|
+
if (options.hoistPathParameters) {
|
|
141
|
+
for (const item of Object.values(paths)) {
|
|
142
|
+
hoisted += hoistParameters(item as unknown as Record<string, unknown>, componentParameters);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let dropped = 0;
|
|
147
|
+
for (const item of Object.values(paths)) {
|
|
148
|
+
dropped += dropSystemParameters(
|
|
149
|
+
item as unknown as Record<string, unknown>,
|
|
150
|
+
componentParameters,
|
|
151
|
+
system,
|
|
152
|
+
);
|
|
153
|
+
for (const [method, operation] of Object.entries(item)) {
|
|
154
|
+
if (!METHODS.includes(method)) continue;
|
|
155
|
+
dropped += dropSystemParameters(
|
|
156
|
+
operation as unknown as Record<string, unknown>,
|
|
157
|
+
componentParameters,
|
|
158
|
+
system,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let narrowed = 0;
|
|
164
|
+
for (const item of Object.values(paths)) {
|
|
165
|
+
for (const [method, operation] of Object.entries(item)) {
|
|
166
|
+
if (!METHODS.includes(method)) continue;
|
|
167
|
+
const holder = operation as unknown as Record<string, unknown>;
|
|
168
|
+
|
|
169
|
+
if (options.requestContentTypes) {
|
|
170
|
+
narrowed += narrowRequestBody(
|
|
171
|
+
holder,
|
|
172
|
+
operation.operationId ?? `${method} path`,
|
|
173
|
+
options.requestContentTypes,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const replacement = operation.operationId
|
|
178
|
+
? options.rewriteRequestBody?.[operation.operationId]
|
|
179
|
+
: undefined;
|
|
180
|
+
if (replacement) {
|
|
181
|
+
const body = holder['requestBody'] as { content?: Record<string, unknown> } | undefined;
|
|
182
|
+
for (const type of Object.keys(body?.content ?? {})) {
|
|
183
|
+
(body!.content![type] as Record<string, unknown>)['schema'] = replacement;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Drop response schemas.
|
|
190
|
+
//
|
|
191
|
+
// Two reasons, and the second is the blocking one. Nothing reads them: the
|
|
192
|
+
// connector hands the response body back as text, because an agent wants the
|
|
193
|
+
// JSON, not a validated shape. And a recursive response schema — a message
|
|
194
|
+
// whose payload is a part, which contains parts — sends the OpenAPI tool
|
|
195
|
+
// generator into infinite recursion and silently drops operations from the
|
|
196
|
+
// tool list.
|
|
197
|
+
for (const item of Object.values(paths)) {
|
|
198
|
+
for (const [method, operation] of Object.entries(item)) {
|
|
199
|
+
if (!METHODS.includes(method)) continue;
|
|
200
|
+
(operation as { responses?: unknown }).responses = {
|
|
201
|
+
'200': { description: 'Success. The response body is returned verbatim.' },
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const missing = operations.filter((operationId) => !seen.has(operationId));
|
|
207
|
+
if (missing.length > 0) {
|
|
208
|
+
// Loudly, rather than shipping a provider quietly missing capabilities: an
|
|
209
|
+
// upstream rename should fail the refresh, not shrink the tool list.
|
|
210
|
+
throw new Error(`${id}: these operations are not in the spec — ${missing.join(', ')}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const schemas = spec.components?.schemas ?? {};
|
|
214
|
+
const opaqued = makeOpaque(schemas, options.opaque ?? [], options.opaqueNote ?? '');
|
|
215
|
+
const keep = referenced(paths, schemas);
|
|
216
|
+
const trimmedSchemas = Object.fromEntries(
|
|
217
|
+
Object.entries(schemas).filter(([name]) => keep.has(name)),
|
|
218
|
+
);
|
|
219
|
+
const cuts = cutCycles(trimmedSchemas);
|
|
220
|
+
|
|
221
|
+
const trimmed: Spec = {
|
|
222
|
+
openapi: spec.openapi,
|
|
223
|
+
info: {
|
|
224
|
+
...spec.info,
|
|
225
|
+
'x-vendored-from': source,
|
|
226
|
+
'x-vendored-note': options.vendoredNote,
|
|
227
|
+
},
|
|
228
|
+
...(spec.servers ? { servers: spec.servers } : {}),
|
|
229
|
+
paths,
|
|
230
|
+
components: { ...spec.components, schemas: trimmedSchemas },
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
await mkdir(options.outputDirectory, { recursive: true });
|
|
234
|
+
await writeFile(
|
|
235
|
+
join(options.outputDirectory, options.out),
|
|
236
|
+
`${JSON.stringify(trimmed, null, 2)}\n`,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const size = Math.round(JSON.stringify(trimmed).length / 1024);
|
|
240
|
+
const extra = [
|
|
241
|
+
options.hoistPathParameters ? `${hoisted} params hoisted` : '',
|
|
242
|
+
narrowed > 0 ? `${narrowed} body types dropped` : '',
|
|
243
|
+
].filter(Boolean);
|
|
244
|
+
console.log(
|
|
245
|
+
` ${id.padEnd(6)} ${String(Object.keys(paths).length).padStart(2)} paths, ` +
|
|
246
|
+
`${seen.size} operations, ${Object.keys(trimmedSchemas).length} schemas, ` +
|
|
247
|
+
`${cuts} cycle${cuts === 1 ? '' : 's'} cut, ${opaqued} made opaque, ` +
|
|
248
|
+
`${dropped} system params dropped, ${size}KB` +
|
|
249
|
+
(extra.length > 0 ? `, ${extra.join(', ')}` : ''),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
await reportLargestTools(id, trimmed, seen.size);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The number the budget is actually about, and a count that proves nothing fell out.
|
|
257
|
+
*
|
|
258
|
+
* Everything on the line above counts the *spec*; `cli/tools.test.ts` measures
|
|
259
|
+
* the **generated input schema**, and the two differ by orders of magnitude
|
|
260
|
+
* because `mcp-from-openapi` inlines `$ref`s — a schema shared by ten fields is
|
|
261
|
+
* ten copies once generated. Reasoning about one operation from a schema count
|
|
262
|
+
* put it at 122 KB when the real figure was 1,133 KB, which is the whole
|
|
263
|
+
* difference between "just over" and "seventeen times over".
|
|
264
|
+
*
|
|
265
|
+
* Printed here so the refresh that adds an operation shows its cost, rather than
|
|
266
|
+
* leaving it to a test failure to say so after the fact.
|
|
267
|
+
*
|
|
268
|
+
* The count is the other half. The generator answers an unresolvable reference by
|
|
269
|
+
* logging to the console and omitting that one tool, so a document can trim
|
|
270
|
+
* cleanly, write successfully, and quietly advertise less than it selected.
|
|
271
|
+
* `tools.test.ts` only asserts the surface is non-empty, so nothing downstream
|
|
272
|
+
* would notice.
|
|
273
|
+
*/
|
|
274
|
+
async function reportLargestTools(id: string, trimmed: Spec, expected: number): Promise<void> {
|
|
275
|
+
let measured: Array<{ name: string; kb: number }>;
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
const generator = await OpenAPIToolGenerator.fromJSON(trimmed);
|
|
279
|
+
const tools = await generator.generateTools();
|
|
280
|
+
|
|
281
|
+
if (tools.length !== expected) {
|
|
282
|
+
throw new Error(
|
|
283
|
+
`${id}: the spec holds ${expected} operations but only ${tools.length} generated. ` +
|
|
284
|
+
'The generator omits a tool it cannot resolve — check for a dangling $ref, ' +
|
|
285
|
+
'including into components this script trimmed.',
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
measured = tools
|
|
290
|
+
.map((tool: McpOpenAPITool) => ({
|
|
291
|
+
name: tool.metadata.operationId ?? tool.name,
|
|
292
|
+
kb: JSON.stringify(tool.inputSchema).length / 1024,
|
|
293
|
+
}))
|
|
294
|
+
.sort((a, b) => b.kb - a.kb);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
// A count mismatch is this script's to report — it is the one failure
|
|
297
|
+
// `tools.test.ts` cannot see. A generator failure is not: that shows up
|
|
298
|
+
// against every provider at once, and refusing to finish over it would
|
|
299
|
+
// leave the specs half-written.
|
|
300
|
+
if (error instanceof Error && error.message.startsWith(`${id}: the spec holds`)) throw error;
|
|
301
|
+
console.log(` (could not measure generated schemas: ${String(error)})`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
for (const { name, kb } of measured.slice(0, 3)) {
|
|
306
|
+
const over = kb > BUDGET_KB ? ` ✗ over the ${BUDGET_KB}KB budget` : '';
|
|
307
|
+
console.log(` ${name.padEnd(38)} ${kb.toFixed(1).padStart(8)}KB${over}`);
|
|
308
|
+
}
|
|
309
|
+
}
|