@haddowg/json-api-mcp 0.0.0-stage → 1.2.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.
- package/LICENSE +21 -0
- package/README.md +51 -2
- package/dist/cli.d.mts +11 -0
- package/dist/cli.d.mts.map +1 -0
- package/dist/cli.mjs +71 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/generate-CiLuqQJ0.mjs +1474 -0
- package/dist/generate-CiLuqQJ0.mjs.map +1 -0
- package/dist/generate-CuWnYJUB.d.mts +337 -0
- package/dist/generate-CuWnYJUB.d.mts.map +1 -0
- package/dist/generate.d.mts +2 -0
- package/dist/generate.mjs +2 -0
- package/dist/index.cjs +808 -0
- package/dist/index.d.cts +534 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +534 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +788 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +77 -3
|
@@ -0,0 +1,1474 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { isPolymorphic, resolvePaginator } from "@haddowg/json-api-client";
|
|
4
|
+
//#region src/openapi.ts
|
|
5
|
+
const HTTP_METHODS = [
|
|
6
|
+
"get",
|
|
7
|
+
"post",
|
|
8
|
+
"patch",
|
|
9
|
+
"put",
|
|
10
|
+
"delete"
|
|
11
|
+
];
|
|
12
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13
|
+
/** Follow a local `#/...` reference. `undefined` for anything else or a dangling one. */
|
|
14
|
+
function resolvePointer(doc, ref) {
|
|
15
|
+
if (!ref.startsWith("#/")) return;
|
|
16
|
+
let node = doc;
|
|
17
|
+
for (const raw of ref.slice(2).split("/")) {
|
|
18
|
+
const segment = decodeURIComponent(raw).replace(/~1/g, "/").replace(/~0/g, "~");
|
|
19
|
+
if (!isObject(node) || !Object.hasOwn(node, segment)) return;
|
|
20
|
+
node = node[segment];
|
|
21
|
+
}
|
|
22
|
+
return node;
|
|
23
|
+
}
|
|
24
|
+
/** Follow `$ref`s until a node that isn't one. A cycle or dangling ref gives `undefined`. */
|
|
25
|
+
function deref(doc, value) {
|
|
26
|
+
let node = value;
|
|
27
|
+
const seen = /* @__PURE__ */ new Set();
|
|
28
|
+
while (isObject(node) && typeof node["$ref"] === "string") {
|
|
29
|
+
const ref = node["$ref"];
|
|
30
|
+
if (seen.has(ref)) return;
|
|
31
|
+
seen.add(ref);
|
|
32
|
+
node = resolvePointer(doc, ref);
|
|
33
|
+
}
|
|
34
|
+
return node;
|
|
35
|
+
}
|
|
36
|
+
/** An operation's parameters, path-level ones included, with refs followed. */
|
|
37
|
+
function operationParameters(doc, item, op) {
|
|
38
|
+
const out = /* @__PURE__ */ new Map();
|
|
39
|
+
for (const raw of [...item.parameters ?? [], ...op.parameters ?? []]) {
|
|
40
|
+
const param = deref(doc, raw);
|
|
41
|
+
if (param !== void 0 && typeof param.name === "string") out.set(`${param.in}:${param.name}`, param);
|
|
42
|
+
}
|
|
43
|
+
return [...out.values()];
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/outputs.ts
|
|
47
|
+
/**
|
|
48
|
+
* Each tool's output schema, generated from the descriptor and the document's resource schemas.
|
|
49
|
+
* Output schemas aren't held to the portable subset inputs are: the model only reads them.
|
|
50
|
+
*/
|
|
51
|
+
const DROPPED = /* @__PURE__ */ new Set([
|
|
52
|
+
"example",
|
|
53
|
+
"examples",
|
|
54
|
+
"x-enum-varnames"
|
|
55
|
+
]);
|
|
56
|
+
/** A document schema with refs inlined, examples dropped and any cycle cut to `{}`. */
|
|
57
|
+
function outputSchema(doc, value, stack = []) {
|
|
58
|
+
if (!isObject(value)) return value === false ? { not: {} } : {};
|
|
59
|
+
if (typeof value["$ref"] === "string") {
|
|
60
|
+
const ref = value["$ref"];
|
|
61
|
+
if (stack.includes(ref)) return {};
|
|
62
|
+
const { $ref: _ref, ...siblings } = value;
|
|
63
|
+
return {
|
|
64
|
+
...outputSchema(doc, resolvePointer(doc, ref), [...stack, ref]),
|
|
65
|
+
...outputSchema(doc, siblings, stack)
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const out = {};
|
|
69
|
+
for (const [key, member] of Object.entries(value)) {
|
|
70
|
+
if (DROPPED.has(key)) continue;
|
|
71
|
+
if (key === "properties" && isObject(member)) {
|
|
72
|
+
const props = {};
|
|
73
|
+
for (const [name, prop] of Object.entries(member)) props[name] = outputSchema(doc, prop, stack);
|
|
74
|
+
out[key] = props;
|
|
75
|
+
} else if ((key === "items" || key === "additionalProperties" || key === "not") && isObject(member)) out[key] = outputSchema(doc, member, stack);
|
|
76
|
+
else if ((key === "allOf" || key === "anyOf" || key === "oneOf") && Array.isArray(member)) out[key] = member.map((m) => outputSchema(doc, m, stack));
|
|
77
|
+
else out[key] = member;
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
/** Index the document's resource object schemas by their type, preferring `<Type>Resource`. */
|
|
82
|
+
function resourceSchemas(doc) {
|
|
83
|
+
const out = /* @__PURE__ */ new Map();
|
|
84
|
+
const entries = Object.entries(doc.components?.schemas ?? {});
|
|
85
|
+
const ordered = [...entries.filter(([name]) => name.endsWith("Resource")), ...entries.filter(([name]) => !name.endsWith("Resource"))];
|
|
86
|
+
for (const [, schema] of ordered) {
|
|
87
|
+
const props = isObject(schema["properties"]) ? schema["properties"] : void 0;
|
|
88
|
+
const typeConst = isObject(props?.["type"]) ? props["type"]["const"] : void 0;
|
|
89
|
+
const describesFields = props?.["attributes"] !== void 0 || props?.["relationships"] !== void 0;
|
|
90
|
+
if (typeof typeConst === "string" && describesFields && !out.has(typeConst)) out.set(typeConst, schema);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
/** Every type an `include` path can reach from the primary types, the primary types included. */
|
|
95
|
+
function reachableTypes(descriptor, primary, paths) {
|
|
96
|
+
const out = new Set(primary);
|
|
97
|
+
for (const path of paths) {
|
|
98
|
+
let current = [...primary];
|
|
99
|
+
for (const segment of path.split(".")) {
|
|
100
|
+
const next = [];
|
|
101
|
+
for (const type of current) for (const related of descriptor[type]?.relations[segment]?.types ?? []) {
|
|
102
|
+
out.add(related);
|
|
103
|
+
next.push(related);
|
|
104
|
+
}
|
|
105
|
+
current = next;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
/** The include paths no more than `depth` relationships deep; all of them when `depth` is null. */
|
|
111
|
+
function capIncludePaths(paths, depth) {
|
|
112
|
+
return depth === null ? [...paths] : paths.filter((path) => path.split(".").length <= depth);
|
|
113
|
+
}
|
|
114
|
+
/** The include paths an operation's `include` parameter lists. */
|
|
115
|
+
function includePaths(doc, op) {
|
|
116
|
+
for (const raw of op.parameters ?? []) {
|
|
117
|
+
const param = deref(doc, raw);
|
|
118
|
+
if (param?.name === "include") {
|
|
119
|
+
const items = deref(doc, param.schema?.["items"]);
|
|
120
|
+
return Array.isArray(items?.["enum"]) ? items["enum"] : [];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
const ref = (type) => ({ $ref: `#/$defs/${type}` });
|
|
126
|
+
/** One type's flattened resource schema: id, type, attributes and relations, all optional but id and type. */
|
|
127
|
+
function flatResource(doc, descriptor, schemas, type, expanded) {
|
|
128
|
+
const resource = schemas.get(type);
|
|
129
|
+
const attributes = outputSchema(doc, (isObject(resource?.["properties"]) ? resource["properties"] : {})["attributes"]);
|
|
130
|
+
const properties = {
|
|
131
|
+
id: { type: "string" },
|
|
132
|
+
type: {
|
|
133
|
+
type: "string",
|
|
134
|
+
const: type
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
if (isObject(attributes["properties"])) Object.assign(properties, attributes["properties"]);
|
|
138
|
+
for (const [name, relation] of Object.entries(descriptor[type]?.relations ?? {})) {
|
|
139
|
+
if (relation.writeOnly === true) continue;
|
|
140
|
+
const polymorphic = isPolymorphic(relation);
|
|
141
|
+
const identifier = polymorphic ? {
|
|
142
|
+
type: "object",
|
|
143
|
+
properties: {
|
|
144
|
+
type: {
|
|
145
|
+
type: "string",
|
|
146
|
+
enum: [...relation.types]
|
|
147
|
+
},
|
|
148
|
+
id: { type: "string" }
|
|
149
|
+
},
|
|
150
|
+
required: ["type", "id"]
|
|
151
|
+
} : { type: "string" };
|
|
152
|
+
const full = relation.types.filter((t) => expanded.has(t)).map(ref);
|
|
153
|
+
const member = full.length === 0 ? identifier : { anyOf: [identifier, ...full] };
|
|
154
|
+
const target = relation.types.join(" or ");
|
|
155
|
+
properties[name] = relation.cardinality === "many" ? {
|
|
156
|
+
type: "array",
|
|
157
|
+
items: member,
|
|
158
|
+
description: `The related ${target} resources: ${polymorphic ? "type and id" : "ids"}, or the resources themselves when included.`
|
|
159
|
+
} : {
|
|
160
|
+
anyOf: [...full.length === 0 ? [identifier] : [identifier, ...full], { type: "null" }],
|
|
161
|
+
description: `The related ${target}: ${polymorphic ? "its type and id" : "its id"}, or the resource itself when included.`
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const schema = {
|
|
165
|
+
type: "object",
|
|
166
|
+
properties,
|
|
167
|
+
required: ["id", "type"]
|
|
168
|
+
};
|
|
169
|
+
if (typeof resource?.["description"] === "string") schema["description"] = resource["description"];
|
|
170
|
+
return schema;
|
|
171
|
+
}
|
|
172
|
+
function defsFor(doc, descriptor, schemas, types) {
|
|
173
|
+
const defs = {};
|
|
174
|
+
for (const type of types) defs[type] = flatResource(doc, descriptor, schemas, type, types);
|
|
175
|
+
return defs;
|
|
176
|
+
}
|
|
177
|
+
/** The `meta` schema a success response declares, else a free-form object. */
|
|
178
|
+
function responseMeta(ctx) {
|
|
179
|
+
for (const [status, raw] of Object.entries(ctx.operation.responses ?? {})) {
|
|
180
|
+
if (!status.startsWith("2")) continue;
|
|
181
|
+
const response = deref(ctx.doc, raw);
|
|
182
|
+
const schema = deref(ctx.doc, response?.content?.["application/vnd.api+json"]?.schema);
|
|
183
|
+
const meta = isObject(schema?.["properties"]) ? schema["properties"]["meta"] : void 0;
|
|
184
|
+
if (meta !== void 0) return outputSchema(ctx.doc, meta);
|
|
185
|
+
}
|
|
186
|
+
return { type: "object" };
|
|
187
|
+
}
|
|
188
|
+
const LINKS = {
|
|
189
|
+
type: "object",
|
|
190
|
+
description: "Pagination links. Pass one to this tool as `pageLink` to fetch that page; a missing link means there is no such page.",
|
|
191
|
+
properties: {
|
|
192
|
+
first: { type: "string" },
|
|
193
|
+
prev: { type: "string" },
|
|
194
|
+
next: { type: "string" },
|
|
195
|
+
last: { type: "string" }
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
/** A collection: `{ items, links, meta }`. */
|
|
199
|
+
function collectionOutput(ctx, types) {
|
|
200
|
+
const reachable = reachableTypes(ctx.descriptor, types, ctx.includes);
|
|
201
|
+
return {
|
|
202
|
+
type: "object",
|
|
203
|
+
properties: {
|
|
204
|
+
items: {
|
|
205
|
+
type: "array",
|
|
206
|
+
items: types.length === 1 ? ref(types[0]) : { anyOf: types.map(ref) }
|
|
207
|
+
},
|
|
208
|
+
links: LINKS,
|
|
209
|
+
meta: {
|
|
210
|
+
...responseMeta(ctx),
|
|
211
|
+
description: "The document meta, such as page totals."
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
required: ["items", "links"],
|
|
215
|
+
$defs: defsFor(ctx.doc, ctx.descriptor, ctx.schemas, reachable)
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/** One resource, flattened, at the root. */
|
|
219
|
+
function resourceOutput(ctx, type) {
|
|
220
|
+
const reachable = reachableTypes(ctx.descriptor, [type], ctx.includes);
|
|
221
|
+
const defs = defsFor(ctx.doc, ctx.descriptor, ctx.schemas, reachable);
|
|
222
|
+
return {
|
|
223
|
+
...defs[type],
|
|
224
|
+
$defs: defs
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
/** A single read: `{ item }`, and for a to-one related read null when nothing is related. */
|
|
228
|
+
function itemOutput(ctx, types, nullable) {
|
|
229
|
+
const reachable = reachableTypes(ctx.descriptor, types, ctx.includes);
|
|
230
|
+
return {
|
|
231
|
+
type: "object",
|
|
232
|
+
properties: { item: nullable ? {
|
|
233
|
+
anyOf: [...types.map(ref), { type: "null" }],
|
|
234
|
+
description: "The related resource, or null when there is none."
|
|
235
|
+
} : types.length === 1 ? {
|
|
236
|
+
...ref(types[0]),
|
|
237
|
+
description: "The resource."
|
|
238
|
+
} : {
|
|
239
|
+
anyOf: types.map(ref),
|
|
240
|
+
description: "The resource."
|
|
241
|
+
} },
|
|
242
|
+
required: ["item"],
|
|
243
|
+
$defs: defsFor(ctx.doc, ctx.descriptor, ctx.schemas, reachable)
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/** A meta-only result: the document's `meta`. */
|
|
247
|
+
function metaOutput(ctx) {
|
|
248
|
+
return {
|
|
249
|
+
...responseMeta(ctx),
|
|
250
|
+
type: "object"
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/** A JSON:API result whose primary type the descriptor doesn't name. */
|
|
254
|
+
const LOOSE_OUTPUT = { type: "object" };
|
|
255
|
+
/** The URL an action answered with (generator contract 17): `{ url, type, expiresAt? }`. */
|
|
256
|
+
function locationOutput(mediaType) {
|
|
257
|
+
return {
|
|
258
|
+
type: "object",
|
|
259
|
+
description: "The URL the action produced, such as a short-lived download link. The tool does not fetch it.",
|
|
260
|
+
properties: {
|
|
261
|
+
url: {
|
|
262
|
+
type: "string",
|
|
263
|
+
format: "uri-reference",
|
|
264
|
+
description: "The URL."
|
|
265
|
+
},
|
|
266
|
+
type: {
|
|
267
|
+
type: "string",
|
|
268
|
+
description: `The media type the URL serves. The action declares \`${mediaType}\`.`
|
|
269
|
+
},
|
|
270
|
+
expiresAt: {
|
|
271
|
+
type: "string",
|
|
272
|
+
format: "date-time",
|
|
273
|
+
description: "When the URL stops working. Absent when it does not expire."
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
required: ["url", "type"],
|
|
277
|
+
additionalProperties: false
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
/** An action's usual output or the URL it may answer with instead, sharing the root `$defs`. */
|
|
281
|
+
function orLocation(schema, mediaType) {
|
|
282
|
+
const { $defs, ...rest } = schema;
|
|
283
|
+
return {
|
|
284
|
+
type: "object",
|
|
285
|
+
anyOf: [rest, locationOutput(mediaType)],
|
|
286
|
+
...$defs === void 0 ? {} : { $defs }
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
//#endregion
|
|
290
|
+
//#region src/portable.ts
|
|
291
|
+
/**
|
|
292
|
+
* Convert document schemas into tool input schemas every major model API accepts: OpenAI's strict
|
|
293
|
+
* function calling and Anthropic's strict tool use. That means no `oneOf`, `allOf`, `if`, `then`
|
|
294
|
+
* or `$ref`, every object closed with `additionalProperties: false` and listing all its
|
|
295
|
+
* properties as required, an optional property made nullable instead, and no numeric, length or
|
|
296
|
+
* pattern keywords. Constraints that can't stay as keywords move into the description, and a
|
|
297
|
+
* value with no portable shape, such as a free-form object, is taken as JSON text.
|
|
298
|
+
*
|
|
299
|
+
* Each schema comes with a {@link Decoder} that turns the arguments a model sends back into the
|
|
300
|
+
* value the API expects: a `null` the conversion added is dropped, and JSON text is parsed.
|
|
301
|
+
*/
|
|
302
|
+
/** The string formats both APIs accept. */
|
|
303
|
+
const PORTABLE_FORMATS = /* @__PURE__ */ new Set([
|
|
304
|
+
"date-time",
|
|
305
|
+
"time",
|
|
306
|
+
"date",
|
|
307
|
+
"duration",
|
|
308
|
+
"email",
|
|
309
|
+
"hostname",
|
|
310
|
+
"ipv4",
|
|
311
|
+
"ipv6",
|
|
312
|
+
"uuid"
|
|
313
|
+
]);
|
|
314
|
+
/** Objects may nest this deep before a value is taken as JSON text instead. */
|
|
315
|
+
const MAX_DEPTH = 6;
|
|
316
|
+
const VALUE = { kind: "value" };
|
|
317
|
+
/** Add `null` to a portable schema's allowed values. */
|
|
318
|
+
function nullable(schema) {
|
|
319
|
+
const out = { ...schema };
|
|
320
|
+
if (Array.isArray(out["anyOf"])) {
|
|
321
|
+
const branches = out["anyOf"];
|
|
322
|
+
if (!branches.some((b) => b["type"] === "null")) out["anyOf"] = [...branches, { type: "null" }];
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
const type = out["type"];
|
|
326
|
+
if (typeof type === "string" && type !== "null") out["type"] = [type, "null"];
|
|
327
|
+
else if (Array.isArray(type) && !type.includes("null")) out["type"] = [...type, "null"];
|
|
328
|
+
if (Array.isArray(out["enum"]) && !out["enum"].includes(null)) out["enum"] = [...out["enum"], null];
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
/** Whether a portable schema already allows `null`. */
|
|
332
|
+
function allowsNull(schema) {
|
|
333
|
+
const type = schema["type"];
|
|
334
|
+
if (type === "null" || Array.isArray(type) && type.includes("null")) return true;
|
|
335
|
+
return Array.isArray(schema["anyOf"]) && schema["anyOf"].some(allowsNull);
|
|
336
|
+
}
|
|
337
|
+
/** A closed object schema listing every property as required, as strict mode wants. */
|
|
338
|
+
function closedObject(properties, description) {
|
|
339
|
+
const schema = {
|
|
340
|
+
type: "object",
|
|
341
|
+
properties,
|
|
342
|
+
required: Object.keys(properties),
|
|
343
|
+
additionalProperties: false
|
|
344
|
+
};
|
|
345
|
+
if (description !== void 0 && description !== "") schema["description"] = description;
|
|
346
|
+
return schema;
|
|
347
|
+
}
|
|
348
|
+
const jsonText = (description) => ({
|
|
349
|
+
schema: {
|
|
350
|
+
type: "string",
|
|
351
|
+
description: joinText(description, "Send the value as JSON text.")
|
|
352
|
+
},
|
|
353
|
+
decoder: { kind: "json" }
|
|
354
|
+
});
|
|
355
|
+
function joinText(...parts) {
|
|
356
|
+
return parts.filter((part) => part !== void 0 && part.trim() !== "").map((part) => part.trim()).join(" ");
|
|
357
|
+
}
|
|
358
|
+
/** The non-null `type` keys a schema names. */
|
|
359
|
+
function typeKeys(schema) {
|
|
360
|
+
const raw = schema["type"];
|
|
361
|
+
const list = typeof raw === "string" ? [raw] : Array.isArray(raw) ? raw : [];
|
|
362
|
+
return {
|
|
363
|
+
types: list.filter((t) => t !== "null"),
|
|
364
|
+
nullable: list.includes("null")
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
function inferType(values) {
|
|
368
|
+
const types = new Set(values.filter((v) => v !== null).map((v) => typeof v === "number" ? Number.isInteger(v) ? "integer" : "number" : typeof v));
|
|
369
|
+
if (types.size === 1) return [...types][0];
|
|
370
|
+
if (types.size === 2 && types.has("integer") && types.has("number")) return "number";
|
|
371
|
+
}
|
|
372
|
+
/** A range as a sentence, such as `Between 1 and 200 characters.` */
|
|
373
|
+
function bounds(min, max, unit) {
|
|
374
|
+
const suffix = unit === "" ? "" : ` ${unit}`;
|
|
375
|
+
if (min !== void 0 && max !== void 0) return `Between ${min} and ${max}${suffix}.`;
|
|
376
|
+
if (min !== void 0) return `At least ${min}${suffix}.`;
|
|
377
|
+
if (max !== void 0) return `At most ${max}${suffix}.`;
|
|
378
|
+
}
|
|
379
|
+
/** The constraints a schema states that can't stay as keywords, as sentences. */
|
|
380
|
+
function constraintNote(schema) {
|
|
381
|
+
const num = (key) => typeof schema[key] === "number" ? schema[key] : void 0;
|
|
382
|
+
const notes = [
|
|
383
|
+
bounds(num("minLength"), num("maxLength"), "characters"),
|
|
384
|
+
bounds(num("minimum"), num("maximum"), ""),
|
|
385
|
+
num("exclusiveMinimum") === void 0 ? void 0 : `Greater than ${num("exclusiveMinimum")}.`,
|
|
386
|
+
num("exclusiveMaximum") === void 0 ? void 0 : `Less than ${num("exclusiveMaximum")}.`,
|
|
387
|
+
num("multipleOf") === void 0 ? void 0 : `A multiple of ${num("multipleOf")}.`,
|
|
388
|
+
bounds(num("minItems"), num("maxItems"), "items"),
|
|
389
|
+
typeof schema["pattern"] === "string" ? `Matches the pattern \`${schema["pattern"]}\`.` : void 0,
|
|
390
|
+
typeof schema["format"] === "string" && !PORTABLE_FORMATS.has(schema["format"]) ? `Format: ${schema["format"]}.` : void 0,
|
|
391
|
+
schema["default"] === void 0 ? void 0 : `Defaults to ${JSON.stringify(schema["default"])}.`
|
|
392
|
+
].filter((note) => note !== void 0);
|
|
393
|
+
return notes.length === 0 ? void 0 : notes.join(" ");
|
|
394
|
+
}
|
|
395
|
+
/** Merge an `allOf` into one schema: the members' keywords, then the outer node's own. */
|
|
396
|
+
function mergeAllOf(doc, node, stack) {
|
|
397
|
+
const members = node["allOf"];
|
|
398
|
+
const merged = {};
|
|
399
|
+
const properties = {};
|
|
400
|
+
const required = /* @__PURE__ */ new Set();
|
|
401
|
+
for (const member of members) {
|
|
402
|
+
const resolved = follow(doc, member, stack);
|
|
403
|
+
if (resolved === void 0) return;
|
|
404
|
+
const inner = Array.isArray(resolved.node["allOf"]) ? mergeAllOf(doc, resolved.node, resolved.stack) : resolved.node;
|
|
405
|
+
if (inner === void 0) return;
|
|
406
|
+
Object.assign(properties, isObject(inner["properties"]) ? inner["properties"] : {});
|
|
407
|
+
for (const key of Array.isArray(inner["required"]) ? inner["required"] : []) required.add(key);
|
|
408
|
+
Object.assign(merged, inner);
|
|
409
|
+
}
|
|
410
|
+
const { allOf: _allOf, ...own } = node;
|
|
411
|
+
Object.assign(merged, own);
|
|
412
|
+
if (Object.keys(properties).length > 0) merged["properties"] = {
|
|
413
|
+
...properties,
|
|
414
|
+
...isObject(own["properties"]) ? own["properties"] : {}
|
|
415
|
+
};
|
|
416
|
+
if (required.size > 0 || Array.isArray(own["required"])) merged["required"] = [...required, ...Array.isArray(own["required"]) ? own["required"] : []];
|
|
417
|
+
return merged;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Follow a node's `$ref`s, keeping the outermost description. `undefined` on a cycle, which the
|
|
421
|
+
* caller then takes as JSON text.
|
|
422
|
+
*/
|
|
423
|
+
function follow(doc, value, stack) {
|
|
424
|
+
let node = value;
|
|
425
|
+
let description;
|
|
426
|
+
let path = stack;
|
|
427
|
+
while (isObject(node) && typeof node["$ref"] === "string") {
|
|
428
|
+
const ref = node["$ref"];
|
|
429
|
+
if (description === void 0 && typeof node["description"] === "string") description = node["description"];
|
|
430
|
+
if (path.includes(ref)) return;
|
|
431
|
+
path = [...path, ref];
|
|
432
|
+
node = resolvePointer(doc, ref);
|
|
433
|
+
}
|
|
434
|
+
if (node === true) return {
|
|
435
|
+
node: {},
|
|
436
|
+
stack: path
|
|
437
|
+
};
|
|
438
|
+
if (!isObject(node)) return;
|
|
439
|
+
return {
|
|
440
|
+
node: description === void 0 ? node : {
|
|
441
|
+
...node,
|
|
442
|
+
description
|
|
443
|
+
},
|
|
444
|
+
stack: path
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
const isNull = (b) => isObject(b) && b["type"] === "null";
|
|
448
|
+
/** Convert one document schema into a portable input schema and its decoder. */
|
|
449
|
+
function toPortable(doc, raw, stack = [], depth = 0) {
|
|
450
|
+
const outerDescription = isObject(raw) && typeof raw["description"] === "string" ? raw["description"] : void 0;
|
|
451
|
+
const followed = follow(doc, raw, stack);
|
|
452
|
+
if (followed === void 0) return jsonText(outerDescription);
|
|
453
|
+
let node = followed.node;
|
|
454
|
+
const path = followed.stack;
|
|
455
|
+
if (Array.isArray(node["allOf"])) {
|
|
456
|
+
const merged = mergeAllOf(doc, node, path);
|
|
457
|
+
if (merged === void 0) return jsonText(outerDescription);
|
|
458
|
+
node = merged;
|
|
459
|
+
}
|
|
460
|
+
const description = typeof node["description"] === "string" ? node["description"] : void 0;
|
|
461
|
+
const branches = node["oneOf"] ?? node["anyOf"];
|
|
462
|
+
if (Array.isArray(branches)) {
|
|
463
|
+
const { oneOf: _oneOf, anyOf: _anyOf, ...rest } = node;
|
|
464
|
+
const others = branches.filter((b) => !isNull(b));
|
|
465
|
+
const withNull = others.length < branches.length;
|
|
466
|
+
if (others.length === 1) {
|
|
467
|
+
const resolved = follow(doc, others[0], path);
|
|
468
|
+
if (resolved === void 0) return jsonText(description);
|
|
469
|
+
const single = toPortable(doc, {
|
|
470
|
+
...resolved.node,
|
|
471
|
+
...rest
|
|
472
|
+
}, resolved.stack, depth);
|
|
473
|
+
return withNull ? {
|
|
474
|
+
...single,
|
|
475
|
+
schema: nullable(single.schema)
|
|
476
|
+
} : single;
|
|
477
|
+
}
|
|
478
|
+
if (depth >= MAX_DEPTH) return jsonText(description);
|
|
479
|
+
const converted = others.map((b) => toPortable(doc, b, path, depth + 1).schema);
|
|
480
|
+
const schema = { anyOf: withNull ? [...converted, { type: "null" }] : converted };
|
|
481
|
+
const text = joinText(description, constraintNote(node));
|
|
482
|
+
if (text !== "") schema["description"] = text;
|
|
483
|
+
return {
|
|
484
|
+
schema,
|
|
485
|
+
decoder: VALUE
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
let { types, nullable: isNullable } = typeKeys(node);
|
|
489
|
+
const enumValues = Array.isArray(node["enum"]) ? node["enum"] : node["const"] !== void 0 ? [node["const"]] : void 0;
|
|
490
|
+
if (enumValues !== void 0 && enumValues.includes(null)) isNullable = true;
|
|
491
|
+
if (types.length === 0) {
|
|
492
|
+
const inferred = enumValues !== void 0 ? inferType(enumValues) : isObject(node["properties"]) ? "object" : node["items"] !== void 0 ? "array" : void 0;
|
|
493
|
+
if (inferred === void 0) return jsonText(description);
|
|
494
|
+
types = [inferred];
|
|
495
|
+
}
|
|
496
|
+
if (types.length > 1) {
|
|
497
|
+
const schema = { anyOf: [...types.map((type) => ({ type })), ...isNullable ? [{ type: "null" }] : []] };
|
|
498
|
+
if (description !== void 0) schema["description"] = description;
|
|
499
|
+
return {
|
|
500
|
+
schema,
|
|
501
|
+
decoder: VALUE
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
const type = types[0];
|
|
505
|
+
const text = joinText(description, constraintNote(node));
|
|
506
|
+
let result;
|
|
507
|
+
if (type === "object") {
|
|
508
|
+
const props = isObject(node["properties"]) ? node["properties"] : {};
|
|
509
|
+
const writable = Object.entries(props).filter(([, prop]) => !(isObject(prop) && prop["readOnly"] === true));
|
|
510
|
+
if (writable.length === 0 || depth >= MAX_DEPTH) return jsonText(text);
|
|
511
|
+
const required = new Set(Array.isArray(node["required"]) ? node["required"] : []);
|
|
512
|
+
const properties = {};
|
|
513
|
+
const decoders = {};
|
|
514
|
+
for (const [key, prop] of writable) {
|
|
515
|
+
const converted = toPortable(doc, prop, path, depth + 1);
|
|
516
|
+
const addNull = !required.has(key) && !allowsNull(converted.schema);
|
|
517
|
+
properties[key] = addNull ? nullable(converted.schema) : converted.schema;
|
|
518
|
+
decoders[key] = {
|
|
519
|
+
decoder: converted.decoder,
|
|
520
|
+
nullMeansOmit: addNull
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
result = {
|
|
524
|
+
schema: closedObject(properties, text),
|
|
525
|
+
decoder: {
|
|
526
|
+
kind: "object",
|
|
527
|
+
props: decoders
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
} else if (type === "array") {
|
|
531
|
+
if (node["items"] === void 0 || node["items"] === true || depth >= MAX_DEPTH) return jsonText(text);
|
|
532
|
+
const items = toPortable(doc, node["items"], path, depth + 1);
|
|
533
|
+
result = {
|
|
534
|
+
schema: {
|
|
535
|
+
type: "array",
|
|
536
|
+
items: items.schema
|
|
537
|
+
},
|
|
538
|
+
decoder: {
|
|
539
|
+
kind: "array",
|
|
540
|
+
items: items.decoder
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
if (text !== "") result.schema["description"] = text;
|
|
544
|
+
} else {
|
|
545
|
+
const schema = { type };
|
|
546
|
+
if (text !== "") schema["description"] = text;
|
|
547
|
+
if (enumValues !== void 0) schema["enum"] = enumValues.filter((v) => v !== null);
|
|
548
|
+
if (type === "string" && typeof node["format"] === "string" && PORTABLE_FORMATS.has(node["format"])) schema["format"] = node["format"];
|
|
549
|
+
result = {
|
|
550
|
+
schema,
|
|
551
|
+
decoder: VALUE
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
return isNullable ? {
|
|
555
|
+
...result,
|
|
556
|
+
schema: nullable(result.schema)
|
|
557
|
+
} : result;
|
|
558
|
+
}
|
|
559
|
+
//#endregion
|
|
560
|
+
//#region src/inputs.ts
|
|
561
|
+
/**
|
|
562
|
+
* Each tool's input schema, and the plan the call handler follows to turn a model's arguments
|
|
563
|
+
* back into a client call. Every schema is portable (see ./portable.ts): a parameter the API
|
|
564
|
+
* doesn't require is nullable, and `null` at the top level means "not given".
|
|
565
|
+
*/
|
|
566
|
+
/** The include description, for a tool capped at `depth` relationships or uncapped. */
|
|
567
|
+
function includeDescription(depth) {
|
|
568
|
+
return `Relationship paths to include in the result (JSON:API compound documents). An included relation comes back as the related resource in place of its id.${depth === null ? "" : depth === 1 ? " Only direct relationships can be included, so relations of an included resource come back as ids." : ` Paths reach at most ${depth} relationships deep, and relations below that come back as ids.`} Leave null to include nothing.`;
|
|
569
|
+
}
|
|
570
|
+
const FIELDS_DESCRIPTION = "Sparse fieldsets (JSON:API): per resource type, the attribute and relationship names to return. A relationship only has a value in the result when it is also included. Leave a type null for its default fields.";
|
|
571
|
+
const WITH_COUNT_DESCRIPTION = "Set true to have the total number of results counted and returned in meta. Counting can be slow on a large collection, so leave it null unless the total is needed.";
|
|
572
|
+
const PAGE_LINK_DESCRIPTION = "A pagination link (first, prev, next or last) copied from the links of an earlier result of this same tool, to fetch that page. When given, every other argument is ignored.";
|
|
573
|
+
const nullableString = (description) => nullable({
|
|
574
|
+
type: "string",
|
|
575
|
+
description
|
|
576
|
+
});
|
|
577
|
+
const idSchema = (description) => ({
|
|
578
|
+
type: "string",
|
|
579
|
+
description
|
|
580
|
+
});
|
|
581
|
+
/** The `filter[...]` parameters as one closed object, each member nullable. */
|
|
582
|
+
function filterInput(doc, params) {
|
|
583
|
+
const properties = {};
|
|
584
|
+
const props = {};
|
|
585
|
+
for (const param of params) {
|
|
586
|
+
const match = /^filter\[(.+)\]$/.exec(param.name);
|
|
587
|
+
if (param.in !== "query" || match === null) continue;
|
|
588
|
+
const converted = toPortable(doc, param.schema ?? {}, [], 1);
|
|
589
|
+
const schema = { ...converted.schema };
|
|
590
|
+
if (typeof param.description === "string" && schema["description"] === void 0) schema["description"] = param.description;
|
|
591
|
+
else if (typeof param.description === "string") schema["description"] = `${param.description} ${String(schema["description"])}`;
|
|
592
|
+
const addNull = !allowsNull(schema);
|
|
593
|
+
properties[match[1]] = addNull ? nullable(schema) : schema;
|
|
594
|
+
props[match[1]] = {
|
|
595
|
+
decoder: converted.decoder,
|
|
596
|
+
nullMeansOmit: true
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
if (Object.keys(properties).length === 0) return;
|
|
600
|
+
return {
|
|
601
|
+
schema: nullable(closedObject(properties, "Filters to apply. Leave a filter null to not apply it, or the whole object null for none.")),
|
|
602
|
+
decoder: {
|
|
603
|
+
kind: "object",
|
|
604
|
+
props
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
/** The page parameter's size bound and default for the strategy that answers by default. */
|
|
609
|
+
function pageBounds(doc, params, kind, sizeKey) {
|
|
610
|
+
let schema = deref(doc, params.find((p) => p.in === "query" && p.name === "page")?.schema);
|
|
611
|
+
const branches = schema?.["oneOf"] ?? schema?.["anyOf"];
|
|
612
|
+
if (Array.isArray(branches)) schema = branches.map((b) => deref(doc, b)).find((b) => isObject(b?.["x-paginator"]) && b["x-paginator"]["strategy"] === kind);
|
|
613
|
+
const size = isObject(schema?.["properties"]) ? schema["properties"][sizeKey] : void 0;
|
|
614
|
+
if (!isObject(size)) return {};
|
|
615
|
+
const out = {};
|
|
616
|
+
if (typeof size["maximum"] === "number") out.max = size["maximum"];
|
|
617
|
+
if (typeof size["default"] === "number") out.default = size["default"];
|
|
618
|
+
return out;
|
|
619
|
+
}
|
|
620
|
+
/** A read tool's query arguments, read from the operation's own parameters. */
|
|
621
|
+
function queryInput(ctx, paginator, collection) {
|
|
622
|
+
const { doc, params, defaultPageSize, includeDepth } = ctx;
|
|
623
|
+
const properties = {};
|
|
624
|
+
const plan = {
|
|
625
|
+
sort: false,
|
|
626
|
+
include: false,
|
|
627
|
+
fields: [],
|
|
628
|
+
paginated: false
|
|
629
|
+
};
|
|
630
|
+
if (collection) {
|
|
631
|
+
const filter = filterInput(doc, params);
|
|
632
|
+
if (filter !== void 0) {
|
|
633
|
+
properties["filter"] = filter.schema;
|
|
634
|
+
plan.filter = filter.decoder;
|
|
635
|
+
}
|
|
636
|
+
const sortItems = deref(doc, params.find((p) => p.in === "query" && p.name === "sort")?.schema?.["items"]);
|
|
637
|
+
const tokens = Array.isArray(sortItems?.["enum"]) ? sortItems["enum"] : [];
|
|
638
|
+
if (tokens.length > 0) {
|
|
639
|
+
properties["sort"] = nullable({
|
|
640
|
+
type: "array",
|
|
641
|
+
items: {
|
|
642
|
+
type: "string",
|
|
643
|
+
enum: tokens
|
|
644
|
+
},
|
|
645
|
+
description: "Fields to sort by, in order. A leading `-` sorts that field descending. Leave null for the default order."
|
|
646
|
+
});
|
|
647
|
+
plan.sort = true;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
const paths = params.some((p) => p.in === "query" && p.name === "include") ? capIncludePaths(includePaths(doc, ctx.operation), includeDepth) : [];
|
|
651
|
+
if (paths.length > 0) {
|
|
652
|
+
properties["include"] = nullable({
|
|
653
|
+
type: "array",
|
|
654
|
+
items: {
|
|
655
|
+
type: "string",
|
|
656
|
+
enum: paths
|
|
657
|
+
},
|
|
658
|
+
description: includeDescription(includeDepth)
|
|
659
|
+
});
|
|
660
|
+
plan.include = true;
|
|
661
|
+
plan.includePaths = paths;
|
|
662
|
+
}
|
|
663
|
+
const fieldTypes = params.map((p) => p.in === "query" ? /^fields\[(.+)\]$/.exec(p.name)?.[1] : void 0).filter((t) => t !== void 0);
|
|
664
|
+
if (fieldTypes.length > 0) {
|
|
665
|
+
const members = {};
|
|
666
|
+
for (const type of fieldTypes) members[type] = nullable({
|
|
667
|
+
type: "array",
|
|
668
|
+
items: { type: "string" }
|
|
669
|
+
});
|
|
670
|
+
properties["fields"] = nullable(closedObject(members, FIELDS_DESCRIPTION));
|
|
671
|
+
plan.fields = fieldTypes;
|
|
672
|
+
}
|
|
673
|
+
if (collection && paginator !== void 0) {
|
|
674
|
+
const resolved = resolvePaginator(paginator);
|
|
675
|
+
if (resolved.kind !== "none") {
|
|
676
|
+
plan.paginated = true;
|
|
677
|
+
const sizeKey = resolved.keys.size ?? resolved.keys.limit;
|
|
678
|
+
if (sizeKey !== void 0) {
|
|
679
|
+
const bounds = pageBounds(doc, params, resolved.kind, sizeKey);
|
|
680
|
+
const fallback = Math.min(defaultPageSize, bounds.max ?? defaultPageSize);
|
|
681
|
+
plan.sizeKey = sizeKey;
|
|
682
|
+
plan.defaultPageSize = fallback;
|
|
683
|
+
if (bounds.max !== void 0) plan.maxPageSize = bounds.max;
|
|
684
|
+
properties["pageSize"] = nullable({
|
|
685
|
+
type: "integer",
|
|
686
|
+
description: `How many results a page holds, ${bounds.max === void 0 ? "at least 1" : `1 to ${bounds.max}`}. Leave null for ${fallback}.`
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
properties["pageLink"] = nullableString(PAGE_LINK_DESCRIPTION);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return {
|
|
693
|
+
properties,
|
|
694
|
+
plan
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
/** The `data` member's schema of a write request body. */
|
|
698
|
+
function requestData(doc, schema) {
|
|
699
|
+
const body = deref(doc, schema);
|
|
700
|
+
return deref(doc, isObject(body?.["properties"]) ? body["properties"]["data"] : void 0);
|
|
701
|
+
}
|
|
702
|
+
/** A relation's write argument: an id, ids, or `{ type, id }` objects for a polymorphic one. */
|
|
703
|
+
function relationArgument(name, relation, allowNull) {
|
|
704
|
+
const polymorphic = isPolymorphic(relation);
|
|
705
|
+
const one = polymorphic ? closedObject({
|
|
706
|
+
type: {
|
|
707
|
+
type: "string",
|
|
708
|
+
enum: [...relation.types]
|
|
709
|
+
},
|
|
710
|
+
id: { type: "string" }
|
|
711
|
+
}) : { type: "string" };
|
|
712
|
+
const target = polymorphic ? relation.types.join(" or ") : relation.types[0];
|
|
713
|
+
if (relation.cardinality === "many") return {
|
|
714
|
+
type: "array",
|
|
715
|
+
items: one,
|
|
716
|
+
description: polymorphic ? `The ${name} relationship: every related resource, as its type (${target}) and id.` : `The ${name} relationship: the ids of the related \`${target}\` resources.`
|
|
717
|
+
};
|
|
718
|
+
const schema = {
|
|
719
|
+
...one,
|
|
720
|
+
description: polymorphic ? `The ${name} relationship: the related resource's type (${target}) and id.` : `The ${name} relationship: the id of the related \`${target}\` resource.`
|
|
721
|
+
};
|
|
722
|
+
return allowNull ? nullable(schema) : schema;
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* A flat write input from a JSON:API write request body: the attributes and relationships as
|
|
726
|
+
* one object, the API's required members required, the rest nullable.
|
|
727
|
+
*/
|
|
728
|
+
function writeInput(doc, descriptor, type, bodySchema, mode) {
|
|
729
|
+
const properties = {};
|
|
730
|
+
const required = [];
|
|
731
|
+
const fields = {};
|
|
732
|
+
const clearable = [];
|
|
733
|
+
const data = requestData(doc, bodySchema);
|
|
734
|
+
const dataProps = isObject(data?.["properties"]) ? data["properties"] : {};
|
|
735
|
+
const attributes = deref(doc, dataProps["attributes"]);
|
|
736
|
+
const converted = attributes === void 0 ? void 0 : toPortable(doc, attributes, [], 0);
|
|
737
|
+
if (converted !== void 0 && converted.decoder.kind === "object") {
|
|
738
|
+
const attrRequired = new Set(Array.isArray(attributes["required"]) ? attributes["required"] : []);
|
|
739
|
+
const attrProps = converted.schema["properties"];
|
|
740
|
+
for (const [name, prop] of Object.entries(converted.decoder.props)) {
|
|
741
|
+
const schema = attrProps[name];
|
|
742
|
+
properties[name] = schema;
|
|
743
|
+
const isRequired = mode !== "update" && attrRequired.has(name);
|
|
744
|
+
fields[name] = { property: {
|
|
745
|
+
decoder: prop.decoder,
|
|
746
|
+
nullMeansOmit: !isRequired
|
|
747
|
+
} };
|
|
748
|
+
if (isRequired) required.push(name);
|
|
749
|
+
if (!prop.nullMeansOmit && allowsNull(schema)) clearable.push(name);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
const relationships = deref(doc, dataProps["relationships"]);
|
|
753
|
+
const relProps = isObject(relationships?.["properties"]) ? relationships["properties"] : {};
|
|
754
|
+
const relRequired = new Set(Array.isArray(relationships?.["required"]) ? relationships["required"] : []);
|
|
755
|
+
for (const [name, raw] of Object.entries(relProps)) {
|
|
756
|
+
const relation = descriptor[type]?.relations[name];
|
|
757
|
+
if (relation === void 0 || name in properties) continue;
|
|
758
|
+
const relSchema = deref(doc, raw);
|
|
759
|
+
const linkage = isObject(relSchema?.["properties"]) ? relSchema["properties"]["data"] : void 0;
|
|
760
|
+
const linkageAllowsNull = relation.cardinality === "one" && JSON.stringify(deref(doc, linkage) ?? {}).includes("\"null\"");
|
|
761
|
+
const isRequired = mode !== "update" && relRequired.has(name);
|
|
762
|
+
const schema = relationArgument(name, relation, linkageAllowsNull);
|
|
763
|
+
properties[name] = isRequired || allowsNull(schema) ? schema : nullable(schema);
|
|
764
|
+
fields[name] = {
|
|
765
|
+
relation: isPolymorphic(relation) ? {
|
|
766
|
+
cardinality: relation.cardinality,
|
|
767
|
+
types: relation.types,
|
|
768
|
+
polymorphic: true
|
|
769
|
+
} : {
|
|
770
|
+
cardinality: relation.cardinality,
|
|
771
|
+
types: relation.types
|
|
772
|
+
},
|
|
773
|
+
property: {
|
|
774
|
+
decoder: { kind: "value" },
|
|
775
|
+
nullMeansOmit: !isRequired
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
if (isRequired) required.push(name);
|
|
779
|
+
if (linkageAllowsNull) clearable.push(name);
|
|
780
|
+
}
|
|
781
|
+
let clientId = "none";
|
|
782
|
+
if (mode === "create") {
|
|
783
|
+
const idProp = dataProps["id"];
|
|
784
|
+
const dataRequired = Array.isArray(data?.["required"]) ? data["required"] : [];
|
|
785
|
+
if (idProp !== void 0 && idProp !== false) clientId = dataRequired.includes("id") ? "required" : "optional";
|
|
786
|
+
}
|
|
787
|
+
return {
|
|
788
|
+
properties,
|
|
789
|
+
required,
|
|
790
|
+
plan: {
|
|
791
|
+
fields,
|
|
792
|
+
clientId,
|
|
793
|
+
clearable: mode === "update" ? clearable : []
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
/** Close a root object: every property required, the optional ones already nullable. */
|
|
798
|
+
function root(properties, required) {
|
|
799
|
+
const out = {};
|
|
800
|
+
for (const [name, schema] of Object.entries(properties)) out[name] = required.includes(name) || allowsNull(schema) ? schema : nullable(schema);
|
|
801
|
+
return closedObject(out);
|
|
802
|
+
}
|
|
803
|
+
/** A list or to-many related read. */
|
|
804
|
+
function collectionInput(ctx, paginator, parent, countable) {
|
|
805
|
+
const { properties, plan } = queryInput(ctx, paginator, true);
|
|
806
|
+
if (countable !== void 0 && countable.tokens.includes("_self_")) {
|
|
807
|
+
properties["withCount"] = nullable({
|
|
808
|
+
type: "boolean",
|
|
809
|
+
description: WITH_COUNT_DESCRIPTION
|
|
810
|
+
});
|
|
811
|
+
plan.countProfile = countable.profile;
|
|
812
|
+
}
|
|
813
|
+
const props = {};
|
|
814
|
+
const required = [];
|
|
815
|
+
if (parent !== void 0) {
|
|
816
|
+
props["id"] = idSchema(`The id of the \`${parent.type}\` resource whose ${parent.relation} to list.`);
|
|
817
|
+
required.push("id");
|
|
818
|
+
}
|
|
819
|
+
return {
|
|
820
|
+
schema: root({
|
|
821
|
+
...props,
|
|
822
|
+
...properties
|
|
823
|
+
}, required),
|
|
824
|
+
query: plan
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
/** A get or to-one related read. */
|
|
828
|
+
function singleInput(ctx, description) {
|
|
829
|
+
const { properties, plan } = queryInput(ctx, void 0, false);
|
|
830
|
+
return {
|
|
831
|
+
schema: root({
|
|
832
|
+
id: idSchema(description),
|
|
833
|
+
...properties
|
|
834
|
+
}, ["id"]),
|
|
835
|
+
query: plan
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
/** A create, from the type's create request body. */
|
|
839
|
+
function createInput(ctx, type) {
|
|
840
|
+
const schema = deref(ctx.doc, ctx.operation.requestBody)?.content?.["application/vnd.api+json"]?.schema;
|
|
841
|
+
const write = writeInput(ctx.doc, ctx.descriptor, type, schema, "create");
|
|
842
|
+
const properties = {};
|
|
843
|
+
const required = [...write.required];
|
|
844
|
+
if (write.plan.clientId !== "none") {
|
|
845
|
+
properties["id"] = idSchema(`The id to give the new \`${type}\` resource.`);
|
|
846
|
+
if (write.plan.clientId === "required") required.push("id");
|
|
847
|
+
}
|
|
848
|
+
return {
|
|
849
|
+
schema: root({
|
|
850
|
+
...properties,
|
|
851
|
+
...write.properties
|
|
852
|
+
}, required),
|
|
853
|
+
write: write.plan
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
/** An update, from the type's update request body. */
|
|
857
|
+
function updateInput(ctx, type) {
|
|
858
|
+
const schema = deref(ctx.doc, ctx.operation.requestBody)?.content?.["application/vnd.api+json"]?.schema;
|
|
859
|
+
const write = writeInput(ctx.doc, ctx.descriptor, type, schema, "update");
|
|
860
|
+
const properties = { id: idSchema(`The id of the \`${type}\` resource to update.`) };
|
|
861
|
+
const clearable = "clear" in write.properties ? [] : write.plan.clearable;
|
|
862
|
+
const extra = {};
|
|
863
|
+
if (clearable.length > 0) extra["clear"] = nullable({
|
|
864
|
+
type: "array",
|
|
865
|
+
items: {
|
|
866
|
+
type: "string",
|
|
867
|
+
enum: [...clearable]
|
|
868
|
+
},
|
|
869
|
+
description: "Fields to set to null. Every other argument left null keeps its current value."
|
|
870
|
+
});
|
|
871
|
+
return {
|
|
872
|
+
schema: root({
|
|
873
|
+
...properties,
|
|
874
|
+
...write.properties,
|
|
875
|
+
...extra
|
|
876
|
+
}, ["id"]),
|
|
877
|
+
write: {
|
|
878
|
+
...write.plan,
|
|
879
|
+
clearable
|
|
880
|
+
}
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
/** A delete. */
|
|
884
|
+
function deleteInput(type) {
|
|
885
|
+
return { schema: root({ id: idSchema(`The id of the \`${type}\` resource to delete.`) }, ["id"]) };
|
|
886
|
+
}
|
|
887
|
+
/** The request media type a model can send to an action, in order of preference. */
|
|
888
|
+
function pickBody(ctx) {
|
|
889
|
+
const body = deref(ctx.doc, ctx.operation.requestBody);
|
|
890
|
+
const content = Object.entries(body?.content ?? {});
|
|
891
|
+
if (content.length === 0) return "none";
|
|
892
|
+
const find = (test) => content.find(([mediaType]) => test(mediaType.toLowerCase()));
|
|
893
|
+
const document = find((m) => m === "application/vnd.api+json");
|
|
894
|
+
if (document !== void 0) return {
|
|
895
|
+
mediaType: document[0],
|
|
896
|
+
schema: document[1].schema,
|
|
897
|
+
kind: "document"
|
|
898
|
+
};
|
|
899
|
+
const json = find((m) => m === "application/json" || m.endsWith("+json"));
|
|
900
|
+
if (json !== void 0) return {
|
|
901
|
+
mediaType: json[0],
|
|
902
|
+
schema: json[1].schema,
|
|
903
|
+
kind: "json"
|
|
904
|
+
};
|
|
905
|
+
const text = find((m) => m.startsWith("text/"));
|
|
906
|
+
if (text !== void 0) return {
|
|
907
|
+
mediaType: text[0],
|
|
908
|
+
schema: { type: "string" },
|
|
909
|
+
kind: "text"
|
|
910
|
+
};
|
|
911
|
+
const form = content.find(([mediaType, media]) => {
|
|
912
|
+
const m = mediaType.toLowerCase();
|
|
913
|
+
const schema = deref(ctx.doc, media.schema);
|
|
914
|
+
return (m === "application/x-www-form-urlencoded" || m.startsWith("multipart/")) && isObject(schema?.["properties"]) && Object.keys(schema["properties"]).length > 0;
|
|
915
|
+
});
|
|
916
|
+
if (form !== void 0) return {
|
|
917
|
+
mediaType: form[0],
|
|
918
|
+
schema: form[1].schema,
|
|
919
|
+
kind: "form"
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* A custom action's input: the resource id at resource scope, then the body. A JSON:API body
|
|
924
|
+
* whose type the descriptor knows is flat, like a create; any other body is one `body` argument.
|
|
925
|
+
* `undefined` when every media type the action takes is one a model can't send, such as binary.
|
|
926
|
+
*/
|
|
927
|
+
function actionInput(ctx, type, name, action) {
|
|
928
|
+
const properties = {};
|
|
929
|
+
const required = [];
|
|
930
|
+
if (action.scope === "resource") {
|
|
931
|
+
properties["id"] = idSchema(`The id of the \`${type}\` resource to run ${name} on.`);
|
|
932
|
+
required.push("id");
|
|
933
|
+
}
|
|
934
|
+
const picked = pickBody(ctx);
|
|
935
|
+
if (picked === void 0) return;
|
|
936
|
+
if (picked === "none") return {
|
|
937
|
+
schema: root(properties, required),
|
|
938
|
+
action: { kind: "none" }
|
|
939
|
+
};
|
|
940
|
+
const body = deref(ctx.doc, ctx.operation.requestBody);
|
|
941
|
+
const bodyRequired = body?.required === true;
|
|
942
|
+
if (picked.kind === "document" && action.inputType !== void 0) {
|
|
943
|
+
const write = writeInput(ctx.doc, ctx.descriptor, action.inputType, picked.schema, "action");
|
|
944
|
+
for (const [key, schema] of Object.entries(write.properties)) if (!(key in properties)) properties[key] = schema;
|
|
945
|
+
return {
|
|
946
|
+
schema: root(properties, [...required, ...write.required.filter((r) => r !== "id")]),
|
|
947
|
+
action: {
|
|
948
|
+
kind: "flat",
|
|
949
|
+
write: write.plan
|
|
950
|
+
}
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
const converted = toPortable(ctx.doc, picked.schema ?? {}, [], 1);
|
|
954
|
+
const description = typeof body?.description === "string" ? body.description : `The request body, as ${picked.mediaType}.`;
|
|
955
|
+
properties["body"] = {
|
|
956
|
+
...converted.schema,
|
|
957
|
+
description: converted.schema["description"] === void 0 ? description : `${description} ${String(converted.schema["description"])}`
|
|
958
|
+
};
|
|
959
|
+
if (bodyRequired) required.push("body");
|
|
960
|
+
return {
|
|
961
|
+
schema: root(properties, required),
|
|
962
|
+
action: {
|
|
963
|
+
kind: "body",
|
|
964
|
+
decoder: converted.decoder,
|
|
965
|
+
required: bodyRequired,
|
|
966
|
+
mediaType: picked.mediaType,
|
|
967
|
+
mediaKind: picked.kind
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
//#endregion
|
|
972
|
+
//#region src/names.ts
|
|
973
|
+
/** The name pattern OpenAI's and Anthropic's tool names share. */
|
|
974
|
+
const TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
975
|
+
const CRUD = {
|
|
976
|
+
fetchCollection: "list",
|
|
977
|
+
fetchOne: "get",
|
|
978
|
+
create: "create",
|
|
979
|
+
update: "update",
|
|
980
|
+
delete: "delete"
|
|
981
|
+
};
|
|
982
|
+
/**
|
|
983
|
+
* Parse an `operationId` into what a tool needs. `undefined` for an operation that isn't a tool:
|
|
984
|
+
* relationship endpoints, Atomic Operations, or an id this package doesn't recognise.
|
|
985
|
+
*/
|
|
986
|
+
function parseOperationId(operationId) {
|
|
987
|
+
const parts = operationId.split(".");
|
|
988
|
+
const [verb, type] = parts;
|
|
989
|
+
if (verb === void 0 || type === void 0 || type === "") return;
|
|
990
|
+
const crud = CRUD[verb];
|
|
991
|
+
if (crud !== void 0) return parts.length === 2 ? {
|
|
992
|
+
kind: crud,
|
|
993
|
+
type
|
|
994
|
+
} : void 0;
|
|
995
|
+
if (verb === "fetchRelated" && parts.length === 3 && parts[2] !== "") return {
|
|
996
|
+
kind: "related",
|
|
997
|
+
type,
|
|
998
|
+
relation: parts[2]
|
|
999
|
+
};
|
|
1000
|
+
if (verb === "action" && parts[2] !== void 0 && parts[2] !== "") {
|
|
1001
|
+
if (parts.length === 3) return {
|
|
1002
|
+
kind: "action",
|
|
1003
|
+
type,
|
|
1004
|
+
action: parts[2]
|
|
1005
|
+
};
|
|
1006
|
+
const method = parts[3];
|
|
1007
|
+
if (parts.length === 4 && HTTP_METHODS.includes(method)) return {
|
|
1008
|
+
kind: "action",
|
|
1009
|
+
type,
|
|
1010
|
+
action: parts[2],
|
|
1011
|
+
method
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
const snake = (value) => value.replace(/-/g, "_");
|
|
1016
|
+
/**
|
|
1017
|
+
* The tool name for a parsed `operationId`: noun first, the type as the document spells it with
|
|
1018
|
+
* kebab-case turned into snake_case. A related read ends in `list` or `get` by the relation's
|
|
1019
|
+
* cardinality; an action is named after its path, plus the method for a multi-method action.
|
|
1020
|
+
*/
|
|
1021
|
+
function toolName(parsed, descriptor) {
|
|
1022
|
+
const type = snake(parsed.type);
|
|
1023
|
+
switch (parsed.kind) {
|
|
1024
|
+
case "related": {
|
|
1025
|
+
const verb = (descriptor[parsed.type]?.relations[parsed.relation])?.cardinality === "one" ? "get" : "list";
|
|
1026
|
+
return `${type}_${snake(parsed.relation)}_${verb}`;
|
|
1027
|
+
}
|
|
1028
|
+
case "action": return parsed.method === void 0 ? `${type}_${snake(parsed.action)}` : `${type}_${snake(parsed.action)}_${parsed.method}`;
|
|
1029
|
+
default: return `${type}_${parsed.kind}`;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
/** Thrown when the tools can't be named: a name too long, with a stray character, or a clash. */
|
|
1033
|
+
var ToolNameError = class extends Error {
|
|
1034
|
+
problems;
|
|
1035
|
+
constructor(problems) {
|
|
1036
|
+
super(`Cannot build MCP tools from this document:\n- ${problems.join("\n- ")}`);
|
|
1037
|
+
this.name = "ToolNameError";
|
|
1038
|
+
this.problems = problems;
|
|
1039
|
+
}
|
|
1040
|
+
};
|
|
1041
|
+
/** Collect every naming problem across the tools, so one build reports them all. */
|
|
1042
|
+
function checkToolNames(tools) {
|
|
1043
|
+
const problems = [];
|
|
1044
|
+
const byName = /* @__PURE__ */ new Map();
|
|
1045
|
+
for (const { name, operationId } of tools) {
|
|
1046
|
+
if (name.length > 64) problems.push(`"${name}" (${operationId}) is ${name.length} characters; tool names allow 64`);
|
|
1047
|
+
else if (!TOOL_NAME_PATTERN.test(name)) problems.push(`"${name}" (${operationId}) has characters outside [a-zA-Z0-9_-]`);
|
|
1048
|
+
byName.set(name, [...byName.get(name) ?? [], operationId]);
|
|
1049
|
+
}
|
|
1050
|
+
for (const [name, ids] of byName) if (ids.length > 1) problems.push(`"${name}" is the tool name of ${ids.join(" and ")}`);
|
|
1051
|
+
if (problems.length > 0) throw new ToolNameError(problems);
|
|
1052
|
+
}
|
|
1053
|
+
//#endregion
|
|
1054
|
+
//#region src/safety.ts
|
|
1055
|
+
/** The vendor extension an action's operation declares its safety in. */
|
|
1056
|
+
const SAFETY_EXTENSION = "x-safety";
|
|
1057
|
+
/**
|
|
1058
|
+
* Read an action's declared safety from its operation. An operation without the extension, or
|
|
1059
|
+
* with one this package can't read, declares nothing.
|
|
1060
|
+
*/
|
|
1061
|
+
function readActionSafety(operation) {
|
|
1062
|
+
const raw = operation[SAFETY_EXTENSION];
|
|
1063
|
+
const safety = {};
|
|
1064
|
+
if (!isObject(raw)) return safety;
|
|
1065
|
+
for (const key of [
|
|
1066
|
+
"readOnly",
|
|
1067
|
+
"destructive",
|
|
1068
|
+
"idempotent"
|
|
1069
|
+
]) if (typeof raw[key] === "boolean") safety[key] = raw[key];
|
|
1070
|
+
return safety;
|
|
1071
|
+
}
|
|
1072
|
+
//#endregion
|
|
1073
|
+
//#region src/scopes.ts
|
|
1074
|
+
/** The descriptor's key for each kind's operation in a type's `scopes`. */
|
|
1075
|
+
const SCOPE_KEYS = {
|
|
1076
|
+
list: "fetchCollection",
|
|
1077
|
+
get: "fetchOne",
|
|
1078
|
+
create: "create",
|
|
1079
|
+
update: "update",
|
|
1080
|
+
delete: "delete"
|
|
1081
|
+
};
|
|
1082
|
+
/** The scopes the descriptor says an operation needs, or `undefined` when it names none. */
|
|
1083
|
+
function descriptorScopes(op) {
|
|
1084
|
+
let scopes;
|
|
1085
|
+
if (op.kind === "action") scopes = op.actionDescriptor?.scopes;
|
|
1086
|
+
else if (op.kind === "related") scopes = op.resource.scopes?.[`fetchRelated.${op.relation ?? ""}`];
|
|
1087
|
+
else scopes = op.resource.scopes?.[SCOPE_KEYS[op.kind]];
|
|
1088
|
+
return scopes === void 0 || scopes.length === 0 ? void 0 : scopes;
|
|
1089
|
+
}
|
|
1090
|
+
//#endregion
|
|
1091
|
+
//#region src/catalogue.ts
|
|
1092
|
+
function annotations(op) {
|
|
1093
|
+
switch (op.kind) {
|
|
1094
|
+
case "list":
|
|
1095
|
+
case "get":
|
|
1096
|
+
case "related": return { readOnlyHint: true };
|
|
1097
|
+
case "create":
|
|
1098
|
+
case "update": return {
|
|
1099
|
+
readOnlyHint: false,
|
|
1100
|
+
destructiveHint: false,
|
|
1101
|
+
idempotentHint: false
|
|
1102
|
+
};
|
|
1103
|
+
case "delete": return {
|
|
1104
|
+
readOnlyHint: false,
|
|
1105
|
+
destructiveHint: true,
|
|
1106
|
+
idempotentHint: true
|
|
1107
|
+
};
|
|
1108
|
+
case "action": {
|
|
1109
|
+
const safety = actionSafety(op);
|
|
1110
|
+
const out = {};
|
|
1111
|
+
if (safety.readOnly !== void 0) out.readOnlyHint = safety.readOnly;
|
|
1112
|
+
if (safety.destructive !== void 0) out.destructiveHint = safety.destructive;
|
|
1113
|
+
if (safety.idempotent !== void 0) out.idempotentHint = safety.idempotent;
|
|
1114
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* An action's declared safety: the flags its descriptor entry carries, with the operation's own
|
|
1120
|
+
* `x-safety` winning where both say. A codegen descriptor mirrors `x-safety`, so they agree.
|
|
1121
|
+
*/
|
|
1122
|
+
function actionSafety(op) {
|
|
1123
|
+
const safety = {};
|
|
1124
|
+
const declared = op.actionDescriptor;
|
|
1125
|
+
for (const key of [
|
|
1126
|
+
"readOnly",
|
|
1127
|
+
"destructive",
|
|
1128
|
+
"idempotent"
|
|
1129
|
+
]) {
|
|
1130
|
+
const value = declared?.[key];
|
|
1131
|
+
if (typeof value === "boolean") safety[key] = value;
|
|
1132
|
+
}
|
|
1133
|
+
return {
|
|
1134
|
+
...safety,
|
|
1135
|
+
...readActionSafety(op.operation)
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
/** The descriptor entry an action operation calls: the one at its path and method. */
|
|
1139
|
+
function findAction(op) {
|
|
1140
|
+
return Object.entries(op.resource.actions ?? {}).find(([, action]) => action.path === op.path && (action.method ?? "POST") === op.method);
|
|
1141
|
+
}
|
|
1142
|
+
function actionResult(action) {
|
|
1143
|
+
if (action.output === "location") return "location";
|
|
1144
|
+
if (action.output === "none") return action.rawOutput !== void 0 && action.rawOutput.length > 0 ? "raw" : "none";
|
|
1145
|
+
if (action.output === "meta") return "meta";
|
|
1146
|
+
if (action.outputType === void 0) return "loose";
|
|
1147
|
+
return action.outputCardinality === "many" ? "collection" : "resource";
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* An action's output schema. One that may answer with a URL instead of its usual result takes
|
|
1151
|
+
* either; one that only answers with a URL takes the URL.
|
|
1152
|
+
*/
|
|
1153
|
+
function actionOutput(ctx, action) {
|
|
1154
|
+
const usual = documentOutput(ctx, action);
|
|
1155
|
+
if (action.location === void 0) return usual;
|
|
1156
|
+
return usual === void 0 ? locationOutput(action.location.mediaType) : orLocation(usual, action.location.mediaType);
|
|
1157
|
+
}
|
|
1158
|
+
function documentOutput(ctx, action) {
|
|
1159
|
+
switch (actionResult(action)) {
|
|
1160
|
+
case "collection": return collectionOutput(ctx, [action.outputType]);
|
|
1161
|
+
case "resource": return resourceOutput(ctx, action.outputType);
|
|
1162
|
+
case "meta": return metaOutput(ctx);
|
|
1163
|
+
case "loose": return LOOSE_OUTPUT;
|
|
1164
|
+
default: return;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
/** Build every tool the document and descriptor describe. Throws on a naming problem. */
|
|
1168
|
+
function buildCatalogue(doc, descriptor, defaultPageSize, includeDepth = null) {
|
|
1169
|
+
const entries = [];
|
|
1170
|
+
const skipped = [];
|
|
1171
|
+
const schemas = resourceSchemas(doc);
|
|
1172
|
+
for (const [path, item] of Object.entries(doc.paths ?? {})) for (const method of HTTP_METHODS) {
|
|
1173
|
+
const operation = item[method];
|
|
1174
|
+
const operationId = operation?.operationId;
|
|
1175
|
+
if (operation === void 0 || typeof operationId !== "string") continue;
|
|
1176
|
+
const parsed = parseOperationId(operationId);
|
|
1177
|
+
if (parsed === void 0) continue;
|
|
1178
|
+
const resource = descriptor[parsed.type];
|
|
1179
|
+
if (resource === void 0) {
|
|
1180
|
+
skipped.push({
|
|
1181
|
+
operationId,
|
|
1182
|
+
reason: `the descriptor has no type "${parsed.type}"`
|
|
1183
|
+
});
|
|
1184
|
+
continue;
|
|
1185
|
+
}
|
|
1186
|
+
const built = buildEntry({
|
|
1187
|
+
operationId,
|
|
1188
|
+
toolName: toolName(parsed, descriptor),
|
|
1189
|
+
kind: parsed.kind,
|
|
1190
|
+
type: parsed.type,
|
|
1191
|
+
method: method.toUpperCase(),
|
|
1192
|
+
path,
|
|
1193
|
+
resource,
|
|
1194
|
+
operation,
|
|
1195
|
+
security: operation.security ?? doc.security ?? []
|
|
1196
|
+
}, parsed, {
|
|
1197
|
+
doc,
|
|
1198
|
+
descriptor,
|
|
1199
|
+
params: operationParameters(doc, item, operation),
|
|
1200
|
+
operation,
|
|
1201
|
+
defaultPageSize,
|
|
1202
|
+
includeDepth
|
|
1203
|
+
}, {
|
|
1204
|
+
doc,
|
|
1205
|
+
descriptor,
|
|
1206
|
+
schemas,
|
|
1207
|
+
operation,
|
|
1208
|
+
includes: capIncludePaths(includePaths(doc, operation), includeDepth)
|
|
1209
|
+
}, descriptor);
|
|
1210
|
+
if (typeof built === "string") {
|
|
1211
|
+
skipped.push({
|
|
1212
|
+
operationId,
|
|
1213
|
+
reason: built
|
|
1214
|
+
});
|
|
1215
|
+
continue;
|
|
1216
|
+
}
|
|
1217
|
+
entries.push(built);
|
|
1218
|
+
}
|
|
1219
|
+
checkToolNames(entries.map((e) => ({
|
|
1220
|
+
name: e.tool.name,
|
|
1221
|
+
operationId: e.op.operationId
|
|
1222
|
+
})));
|
|
1223
|
+
entries.sort((a, b) => a.tool.name.localeCompare(b.tool.name));
|
|
1224
|
+
return {
|
|
1225
|
+
entries,
|
|
1226
|
+
skipped
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
function buildEntry(op, parsed, inputCtx, outputCtx, descriptor) {
|
|
1230
|
+
let input;
|
|
1231
|
+
let output;
|
|
1232
|
+
let result;
|
|
1233
|
+
let key;
|
|
1234
|
+
switch (parsed.kind) {
|
|
1235
|
+
case "list":
|
|
1236
|
+
input = collectionInput(inputCtx, op.resource.paginator, void 0, op.resource.countable);
|
|
1237
|
+
output = collectionOutput(outputCtx, [op.type]);
|
|
1238
|
+
result = "collection";
|
|
1239
|
+
break;
|
|
1240
|
+
case "get":
|
|
1241
|
+
input = singleInput(inputCtx, `The id of the \`${op.type}\` resource to get.`);
|
|
1242
|
+
output = itemOutput(outputCtx, [op.type], false);
|
|
1243
|
+
result = "item";
|
|
1244
|
+
break;
|
|
1245
|
+
case "create":
|
|
1246
|
+
input = createInput(inputCtx, op.type);
|
|
1247
|
+
output = resourceOutput(outputCtx, op.type);
|
|
1248
|
+
result = "resource";
|
|
1249
|
+
break;
|
|
1250
|
+
case "update":
|
|
1251
|
+
input = updateInput(inputCtx, op.type);
|
|
1252
|
+
output = resourceOutput(outputCtx, op.type);
|
|
1253
|
+
result = "resource";
|
|
1254
|
+
break;
|
|
1255
|
+
case "delete":
|
|
1256
|
+
input = deleteInput(op.type);
|
|
1257
|
+
result = "deleted";
|
|
1258
|
+
break;
|
|
1259
|
+
case "related": {
|
|
1260
|
+
const relation = op.resource.relations[parsed.relation];
|
|
1261
|
+
if (relation === void 0) return `the descriptor has no relation "${parsed.relation}" on "${op.type}"`;
|
|
1262
|
+
op.relation = parsed.relation;
|
|
1263
|
+
op.relationDescriptor = relation;
|
|
1264
|
+
if (relation.cardinality === "many") {
|
|
1265
|
+
input = collectionInput(inputCtx, relation.paginator ?? descriptor[relation.types[0] ?? ""]?.paginator, {
|
|
1266
|
+
type: op.type,
|
|
1267
|
+
relation: parsed.relation
|
|
1268
|
+
}, relation.countable);
|
|
1269
|
+
output = collectionOutput(outputCtx, relation.types);
|
|
1270
|
+
result = "collection";
|
|
1271
|
+
} else {
|
|
1272
|
+
input = singleInput(inputCtx, `The id of the \`${op.type}\` resource whose ${parsed.relation} to get.`);
|
|
1273
|
+
output = itemOutput(outputCtx, relation.types, true);
|
|
1274
|
+
result = "item";
|
|
1275
|
+
}
|
|
1276
|
+
break;
|
|
1277
|
+
}
|
|
1278
|
+
case "action": {
|
|
1279
|
+
const found = findAction(op);
|
|
1280
|
+
if (found === void 0) return `the descriptor has no ${op.method} action at "${op.path}" on "${op.type}"`;
|
|
1281
|
+
const [actionKey, action] = found;
|
|
1282
|
+
op.action = parsed.action;
|
|
1283
|
+
op.actionDescriptor = action;
|
|
1284
|
+
key = actionKey;
|
|
1285
|
+
input = actionInput(inputCtx, op.type, parsed.action, action);
|
|
1286
|
+
if (input === void 0) return "every request media type it accepts is one a model cannot send, such as a binary upload";
|
|
1287
|
+
output = actionOutput(outputCtx, action);
|
|
1288
|
+
result = actionResult(action);
|
|
1289
|
+
break;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
const scopes = descriptorScopes(op);
|
|
1293
|
+
if (scopes !== void 0) op.scopes = scopes;
|
|
1294
|
+
const tool = {
|
|
1295
|
+
name: op.toolName,
|
|
1296
|
+
inputSchema: input.schema
|
|
1297
|
+
};
|
|
1298
|
+
const operation = op.operation;
|
|
1299
|
+
if (typeof operation.summary === "string") tool.title = operation.summary;
|
|
1300
|
+
const description = operation.description ?? operation.summary;
|
|
1301
|
+
if (typeof description === "string") tool.description = description;
|
|
1302
|
+
if (output !== void 0) tool.outputSchema = output;
|
|
1303
|
+
const hints = annotations(op);
|
|
1304
|
+
if (hints !== void 0) tool.annotations = hints;
|
|
1305
|
+
tool._meta = { operationId: op.operationId };
|
|
1306
|
+
return key === void 0 ? {
|
|
1307
|
+
op,
|
|
1308
|
+
tool,
|
|
1309
|
+
input,
|
|
1310
|
+
result
|
|
1311
|
+
} : {
|
|
1312
|
+
op,
|
|
1313
|
+
tool,
|
|
1314
|
+
input,
|
|
1315
|
+
result,
|
|
1316
|
+
actionKey: key
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
/**
|
|
1320
|
+
* Throw unless the document's `info.x-generator.contract` is in the range this package reads. A
|
|
1321
|
+
* document outside it would yield tools that miss or misread what the server offers, so there is
|
|
1322
|
+
* no warning mode.
|
|
1323
|
+
*/
|
|
1324
|
+
function assertContract(doc) {
|
|
1325
|
+
const contract = doc.info?.["x-generator"]?.contract;
|
|
1326
|
+
if (typeof contract !== "number") throw new Error(`The OpenAPI document declares no generator contract (info.x-generator.contract). @haddowg/json-api-mcp reads contracts 13 to 18; regenerate the document from a newer haddowg/json-api server.`);
|
|
1327
|
+
if (contract < 13) throw new Error(`The OpenAPI document declares generator contract ${contract}, older than this package reads (13 to 18). Regenerate it from a newer haddowg/json-api server.`);
|
|
1328
|
+
if (contract > 18) throw new Error(`The OpenAPI document declares generator contract ${contract}, newer than this package reads (13 to 18). Upgrade @haddowg/json-api-mcp.`);
|
|
1329
|
+
return contract;
|
|
1330
|
+
}
|
|
1331
|
+
//#endregion
|
|
1332
|
+
//#region src/model.ts
|
|
1333
|
+
/** The package that writes the artefact. */
|
|
1334
|
+
const MODEL_GENERATOR = "@haddowg/json-api-mcp";
|
|
1335
|
+
//#endregion
|
|
1336
|
+
//#region src/build.ts
|
|
1337
|
+
const OMITTED = /* @__PURE__ */ new Set([
|
|
1338
|
+
"parameters",
|
|
1339
|
+
"requestBody",
|
|
1340
|
+
"responses",
|
|
1341
|
+
"callbacks",
|
|
1342
|
+
"security"
|
|
1343
|
+
]);
|
|
1344
|
+
function lightOperation(operation) {
|
|
1345
|
+
const out = {};
|
|
1346
|
+
for (const [key, value] of Object.entries(operation)) if (!OMITTED.has(key)) out[key] = value;
|
|
1347
|
+
return out;
|
|
1348
|
+
}
|
|
1349
|
+
/** The names of the document's `oauth2` security schemes, the ones an MCP caller presents. */
|
|
1350
|
+
function oauthSchemes(document) {
|
|
1351
|
+
const schemes = document.components?.["securitySchemes"];
|
|
1352
|
+
if (!isObject(schemes)) return [];
|
|
1353
|
+
return Object.keys(schemes).filter((name) => {
|
|
1354
|
+
const scheme = deref(document, schemes[name]);
|
|
1355
|
+
return isObject(scheme) && scheme["type"] === "oauth2";
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
/**
|
|
1359
|
+
* Build the model from a document and its descriptor. Throws when the document's generator
|
|
1360
|
+
* contract is out of range or a tool name breaks the rules. Needs no codegen: pass the
|
|
1361
|
+
* descriptor a generated client exports, or use `generateMcpModel` to build it too.
|
|
1362
|
+
*/
|
|
1363
|
+
function buildMcpModel(document, descriptor, options) {
|
|
1364
|
+
const contract = assertContract(document);
|
|
1365
|
+
const includeDepth = options.includeDepth === void 0 ? 1 : options.includeDepth;
|
|
1366
|
+
if (includeDepth !== null && !(Number.isInteger(includeDepth) && includeDepth >= 0)) throw new Error(`includeDepth must be a whole number of 0 or more, or null; got ${includeDepth}`);
|
|
1367
|
+
const catalogue = buildCatalogue(document, descriptor, options.defaultPageSize ?? 10, includeDepth);
|
|
1368
|
+
const info = document.info;
|
|
1369
|
+
const model = {
|
|
1370
|
+
format: 3,
|
|
1371
|
+
generator: MODEL_GENERATOR,
|
|
1372
|
+
contract,
|
|
1373
|
+
source: {
|
|
1374
|
+
title: `${String(info?.title ?? "unknown")} ${String(info?.version ?? "unknown")}`,
|
|
1375
|
+
hash: options.hash
|
|
1376
|
+
},
|
|
1377
|
+
credentialSchemes: oauthSchemes(document),
|
|
1378
|
+
includeDepth,
|
|
1379
|
+
descriptor,
|
|
1380
|
+
tools: catalogue.entries.map((entry) => {
|
|
1381
|
+
const { op } = entry;
|
|
1382
|
+
const operation = {
|
|
1383
|
+
operationId: op.operationId,
|
|
1384
|
+
toolName: op.toolName,
|
|
1385
|
+
kind: op.kind,
|
|
1386
|
+
type: op.type,
|
|
1387
|
+
method: op.method,
|
|
1388
|
+
path: op.path,
|
|
1389
|
+
operation: lightOperation(op.operation)
|
|
1390
|
+
};
|
|
1391
|
+
if (op.operation.security !== void 0) operation.security = op.operation.security;
|
|
1392
|
+
if (op.relation !== void 0) operation.relation = op.relation;
|
|
1393
|
+
if (op.action !== void 0) operation.action = op.action;
|
|
1394
|
+
if (entry.actionKey !== void 0) operation.actionKey = entry.actionKey;
|
|
1395
|
+
const { schema: _schema, ...plan } = entry.input;
|
|
1396
|
+
return {
|
|
1397
|
+
operation,
|
|
1398
|
+
tool: entry.tool,
|
|
1399
|
+
plan,
|
|
1400
|
+
result: entry.result
|
|
1401
|
+
};
|
|
1402
|
+
}),
|
|
1403
|
+
skipped: catalogue.skipped
|
|
1404
|
+
};
|
|
1405
|
+
if (document.security !== void 0) model.security = document.security;
|
|
1406
|
+
const server = document.servers?.[0]?.url;
|
|
1407
|
+
if (server !== void 0) model.baseUrl = server;
|
|
1408
|
+
return model;
|
|
1409
|
+
}
|
|
1410
|
+
//#endregion
|
|
1411
|
+
//#region src/generate.ts
|
|
1412
|
+
/**
|
|
1413
|
+
* @haddowg/json-api-mcp/generate: the build-time half. It reads an OpenAPI document, builds the
|
|
1414
|
+
* descriptor with `@haddowg/json-api-codegen` and writes the model the runtime serves. Run it
|
|
1415
|
+
* against the committed spec and commit its output, as with the generated client. The runtime
|
|
1416
|
+
* entry point never loads this module or the codegen.
|
|
1417
|
+
*/
|
|
1418
|
+
async function codegen() {
|
|
1419
|
+
try {
|
|
1420
|
+
return await import("@haddowg/json-api-codegen");
|
|
1421
|
+
} catch (error) {
|
|
1422
|
+
throw new Error("Generating an MCP model needs @haddowg/json-api-codegen; add it to your dev dependencies.", { cause: error });
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
/** Build the model from a parsed document, building its descriptor with the codegen. */
|
|
1426
|
+
async function generateMcpModel(document, options = {}) {
|
|
1427
|
+
const { buildDescriptor, hashJson } = await codegen();
|
|
1428
|
+
return buildMcpModel(document, buildDescriptor(document), {
|
|
1429
|
+
...options,
|
|
1430
|
+
hash: hashJson(document)
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
/** The model as the generator writes it: two-space JSON with a trailing newline. */
|
|
1434
|
+
function serialiseMcpModel(model) {
|
|
1435
|
+
return `${JSON.stringify(model, null, 2)}\n`;
|
|
1436
|
+
}
|
|
1437
|
+
/**
|
|
1438
|
+
* Read the document, build the model and write it, or with `check` compare it with the committed
|
|
1439
|
+
* file. The comparison is of the parsed JSON, so a formatter rewriting the file's whitespace
|
|
1440
|
+
* doesn't count as a change.
|
|
1441
|
+
*/
|
|
1442
|
+
async function writeMcpModel(options) {
|
|
1443
|
+
const { readDocument } = await codegen();
|
|
1444
|
+
const document = await readDocument(options.input);
|
|
1445
|
+
const generateOptions = {};
|
|
1446
|
+
if (options.defaultPageSize !== void 0) generateOptions.defaultPageSize = options.defaultPageSize;
|
|
1447
|
+
if (options.includeDepth !== void 0) generateOptions.includeDepth = options.includeDepth;
|
|
1448
|
+
const content = serialiseMcpModel(await generateMcpModel(document, generateOptions));
|
|
1449
|
+
if (options.check === true) {
|
|
1450
|
+
let committed;
|
|
1451
|
+
try {
|
|
1452
|
+
committed = JSON.parse(await readFile(options.output, "utf8"));
|
|
1453
|
+
} catch {
|
|
1454
|
+
return {
|
|
1455
|
+
path: options.output,
|
|
1456
|
+
upToDate: false
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
return {
|
|
1460
|
+
path: options.output,
|
|
1461
|
+
upToDate: JSON.stringify(committed) === JSON.stringify(JSON.parse(content))
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
await mkdir(dirname(options.output), { recursive: true });
|
|
1465
|
+
await writeFile(options.output, content, "utf8");
|
|
1466
|
+
return {
|
|
1467
|
+
path: options.output,
|
|
1468
|
+
upToDate: true
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
//#endregion
|
|
1472
|
+
export { buildMcpModel as i, serialiseMcpModel as n, writeMcpModel as r, generateMcpModel as t };
|
|
1473
|
+
|
|
1474
|
+
//# sourceMappingURL=generate-CiLuqQJ0.mjs.map
|