@haddowg/json-api-mcp 0.0.0-stage → 1.2.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.
@@ -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