@haddowg/json-api-mcp 1.2.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -3,7 +3,7 @@ import { JsonApiError, createClient, execute, fetchTransport, isActionLocation,
3
3
  /** The oldest generator contract this package reads: the baseline current servers stamp. */
4
4
  const MINIMUM_CONTRACT = 13;
5
5
  /** The newest generator contract this package reads. */
6
- const MAXIMUM_CONTRACT = 18;
6
+ const MAXIMUM_CONTRACT = 19;
7
7
  /**
8
8
  * Throw unless the document's `info.x-generator.contract` is in the range this package reads. A
9
9
  * document outside it would yield tools that miss or misread what the server offers, so there is
@@ -11,9 +11,9 @@ const MAXIMUM_CONTRACT = 18;
11
11
  */
12
12
  function assertContract(doc) {
13
13
  const contract = doc.info?.["x-generator"]?.contract;
14
- 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.`);
15
- 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.`);
16
- 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.`);
14
+ 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 19; regenerate the document from a newer haddowg/json-api server.`);
15
+ if (contract < 13) throw new Error(`The OpenAPI document declares generator contract ${contract}, older than this package reads (13 to 19). Regenerate it from a newer haddowg/json-api server.`);
16
+ if (contract > 19) throw new Error(`The OpenAPI document declares generator contract ${contract}, newer than this package reads (13 to 19). Upgrade @haddowg/json-api-mcp.`);
17
17
  return contract;
18
18
  }
19
19
  //#endregion
@@ -91,9 +91,11 @@ const SELF_COUNT = "_self_";
91
91
  //#region src/flatten.ts
92
92
  /**
93
93
  * Flatten the client's materialised results into plain tool output. A resource becomes
94
- * `{ id, type, ...attributes, relation }`, where a relation is an id (ids for a to-many, or type
95
- * and id for a polymorphic relation), or the flattened related resource when it was included.
96
- * Links and meta on resources are dropped; a collection keeps only its pagination links.
94
+ * `{ id, type, ...attributes, relation, links? }`, where a relation is an id (ids for a to-many,
95
+ * or type and id for a polymorphic relation), or the flattened related resource when it was
96
+ * included, and `links` holds only the links the type names. Generic links such as `self` and
97
+ * resource meta are dropped. Results go back in a JSON:API-like envelope of `data`, `links` and
98
+ * `meta`, where `links` only carries a collection's pagination.
97
99
  */
98
100
  /** A materialised related value carries `$raw` when it was included, not when it's only linkage. */
99
101
  const isIncluded = (value) => "$raw" in value;
@@ -103,14 +105,36 @@ function identifier(value, polymorphic) {
103
105
  id: value["id"]
104
106
  } : value["id"];
105
107
  }
108
+ /** A link as a flattened resource carries it: `{ href, type?, title?, meta? }`. */
109
+ function flatLink(link) {
110
+ if (typeof link === "string") return { href: link };
111
+ if (!isObject(link) || typeof link["href"] !== "string") return;
112
+ const out = { href: link["href"] };
113
+ if (typeof link["type"] === "string") out["type"] = link["type"];
114
+ if (typeof link["title"] === "string") out["title"] = link["title"];
115
+ if (isObject(link["meta"])) out["meta"] = link["meta"];
116
+ return out;
117
+ }
118
+ /** The links of a resource its type names, or undefined when it carries none of them. */
119
+ function namedLinks(value, names) {
120
+ const links = value.$links;
121
+ if (names === void 0 || !isObject(links)) return;
122
+ const out = {};
123
+ for (const name of names) {
124
+ const link = flatLink(links[name]);
125
+ if (link !== void 0) out[name] = link;
126
+ }
127
+ return Object.keys(out).length > 0 ? out : void 0;
128
+ }
106
129
  /**
107
130
  * Flatten one materialised resource. A resource already on the path, or one more than
108
- * `maxDepth` relationships from the root, is cut back to its id.
131
+ * `includeDepth` relationships from the root, is cut back to its id.
109
132
  */
110
- function flattenResource(value, descriptor, path = [], maxDepth = null) {
133
+ function flattenResource(value, ctx, path = []) {
111
134
  const type = String(value["type"]);
112
135
  const key = `${type}:${String(value["id"])}`;
113
- const relations = descriptor[type]?.relations ?? {};
136
+ const relations = ctx.descriptor[type]?.relations ?? {};
137
+ const maxDepth = ctx.includeDepth;
114
138
  const out = {
115
139
  id: value["id"],
116
140
  type
@@ -126,10 +150,12 @@ function flattenResource(value, descriptor, path = [], maxDepth = null) {
126
150
  const one = (item) => {
127
151
  if (!isObject(item)) return item;
128
152
  const itemKey = `${String(item["type"])}:${String(item["id"])}`;
129
- return isIncluded(item) && itemKey !== key && !path.includes(itemKey) && (maxDepth === null || path.length < maxDepth) ? flattenResource(item, descriptor, [...path, key], maxDepth) : identifier(item, polymorphic);
153
+ return isIncluded(item) && itemKey !== key && !path.includes(itemKey) && (maxDepth === null || path.length < maxDepth) ? flattenResource(item, ctx, [...path, key]) : identifier(item, polymorphic);
130
154
  };
131
155
  out[name] = Array.isArray(member) ? member.map(one) : one(member);
132
156
  }
157
+ const links = namedLinks(value, ctx.resourceLinks[type]);
158
+ if (links !== void 0) out["links"] = links;
133
159
  return out;
134
160
  }
135
161
  const PAGE_LINKS = [
@@ -142,15 +168,30 @@ function linkHref(link) {
142
168
  if (typeof link === "string") return link;
143
169
  if (isObject(link) && typeof link["href"] === "string") return link["href"];
144
170
  }
145
- /** Flatten a materialised collection into `{ items, links, meta? }`. */
146
- function flattenCollection(value, descriptor, maxDepth = null) {
171
+ /** The document `meta` a materialised resource came with. */
172
+ function resourceDocumentMeta(value) {
173
+ const document = isObject(value) ? value.$document : void 0;
174
+ return isObject(document?.meta) ? document.meta : void 0;
175
+ }
176
+ /**
177
+ * One resource in its envelope: `{ data, meta? }`, with `data` null when there is none.
178
+ * `fallbackMeta` is the document meta when the value can't carry it, such as a null.
179
+ */
180
+ function resourceEnvelope(value, ctx, fallbackMeta) {
181
+ const out = { data: isObject(value) ? flattenResource(value, ctx) : null };
182
+ const meta = resourceDocumentMeta(value) ?? fallbackMeta;
183
+ if (meta !== void 0) out["meta"] = meta;
184
+ return out;
185
+ }
186
+ /** A materialised collection in its envelope: `{ data, links, meta? }`. */
187
+ function collectionEnvelope(value, ctx) {
147
188
  const links = {};
148
189
  for (const name of PAGE_LINKS) {
149
190
  const href = linkHref(value.$links?.[name]);
150
191
  if (href !== void 0) links[name] = href;
151
192
  }
152
193
  const out = {
153
- items: value.map((item) => isObject(item) ? flattenResource(item, descriptor, [], maxDepth) : item),
194
+ data: value.map((item) => isObject(item) ? flattenResource(item, ctx) : item),
154
195
  links
155
196
  };
156
197
  if (value.$meta !== void 0) out["meta"] = value.$meta;
@@ -175,6 +216,47 @@ function structured(value) {
175
216
  structuredContent: value
176
217
  };
177
218
  }
219
+ /** The `meta` of a JSON:API response body, when it has one. */
220
+ function responseMeta(response) {
221
+ if (response === void 0 || response.body === "") return;
222
+ try {
223
+ const body = JSON.parse(response.body);
224
+ return isObject(body) && isObject(body["meta"]) ? body["meta"] : void 0;
225
+ } catch {
226
+ return;
227
+ }
228
+ }
229
+ /**
230
+ * A result in the shape a model before format 4 describes: `{ item }` for a single read,
231
+ * `{ items, links, meta }` for a collection, the bare resource, meta object or URL otherwise,
232
+ * and a delete as text.
233
+ */
234
+ function legacyResult(entry, envelope, location, id) {
235
+ const data = envelope["data"];
236
+ if (location || entry.result === "resource" || entry.result === "loose") {
237
+ if (Array.isArray(data)) {
238
+ const { data: items, ...rest } = envelope;
239
+ return structured({
240
+ items,
241
+ ...rest
242
+ });
243
+ }
244
+ return isObject(data) ? structured(data) : { content: [text("The action returned no resource.")] };
245
+ }
246
+ switch (entry.result) {
247
+ case "item": return structured({ item: data ?? null });
248
+ case "collection": {
249
+ const { data: items, ...rest } = envelope;
250
+ return structured({
251
+ items,
252
+ ...rest
253
+ });
254
+ }
255
+ case "deleted": return { content: [text(`Deleted ${entry.op.type} ${String(id)}.`)] };
256
+ case "meta": return structured(isObject(envelope["meta"]) ? envelope["meta"] : {});
257
+ default: return structured(envelope);
258
+ }
259
+ }
178
260
  function requireId(args) {
179
261
  const id = args["id"];
180
262
  if (typeof id === "string" && id !== "") return id;
@@ -355,29 +437,62 @@ async function runCall(entry, rawArgs, env) {
355
437
  if (rawArgs !== void 0 && rawArgs !== null && !isObject(rawArgs)) throw new ArgumentError("the arguments must be an object");
356
438
  const args = rawArgs ?? {};
357
439
  if (env.baseUrl === void 0) throw new Error("No base URL: pass baseUrl, return one from requestInit, or give the document a server");
440
+ let last;
441
+ const inner = env.transport ?? fetchTransport;
442
+ const transport = async (request) => {
443
+ last = await inner(request);
444
+ return last;
445
+ };
446
+ const outcome = await callApi(entry, args, {
447
+ ...env,
448
+ baseUrl: env.baseUrl,
449
+ transport
450
+ });
451
+ const id = entry.op.kind === "delete" ? requireId(args) : void 0;
452
+ if (outcome.kind === "content") return outcome.result;
453
+ const location = outcome.kind === "envelope" && outcome.location === true;
454
+ const envelope = outcome.kind === "envelope" ? outcome.envelope : withFallbackMeta(outcome.envelope, responseMeta(last));
455
+ return env.legacyResults ? legacyResult(entry, envelope, location, id) : structured(envelope);
456
+ }
457
+ function withFallbackMeta(envelope, meta) {
458
+ return meta === void 0 || envelope["meta"] !== void 0 ? envelope : {
459
+ ...envelope,
460
+ meta
461
+ };
462
+ }
463
+ const envelope = (value) => ({
464
+ kind: "envelope",
465
+ envelope: value
466
+ });
467
+ async function callApi(entry, args, env) {
358
468
  const { op } = entry;
359
469
  const descriptor = op.kind === "action" ? withAction(env.descriptor, entry) : env.descriptor;
360
470
  const headers = env.headers;
361
471
  const accessor = createClient(descriptor, {
362
472
  baseUrl: env.baseUrl,
363
- ...env.transport !== void 0 ? { transport: env.transport } : {},
473
+ transport: env.transport,
364
474
  ...headers !== void 0 ? { headers: () => headers } : {}
365
475
  })[op.type];
366
476
  const query = entry.input.query === void 0 ? void 0 : readQuery(args, entry.input.query);
367
477
  if (query !== void 0 && env.signal !== void 0) query.signal = env.signal;
478
+ const flat = {
479
+ descriptor: env.descriptor,
480
+ includeDepth: env.includeDepth,
481
+ resourceLinks: env.resourceLinks
482
+ };
368
483
  switch (op.kind) {
369
484
  case "list":
370
485
  case "related": {
371
- if (entry.result === "item") {
372
- const related = await accessor.id(requireId(args)).rel(op.relation).related(query);
373
- return structured({ item: isObject(related) ? flattenResource(related, env.descriptor, [], env.includeDepth) : null });
374
- }
486
+ if (entry.result === "item") return {
487
+ kind: "bare",
488
+ envelope: resourceEnvelope(await accessor.id(requireId(args)).rel(op.relation).related(query), flat)
489
+ };
375
490
  let collection;
376
491
  if (args["pageLink"] !== void 0 && args["pageLink"] !== null && entry.input.query?.paginated) {
377
492
  const url = checkPageLink(args["pageLink"], env.baseUrl, op.path);
378
493
  const doc = await execute({
379
494
  baseUrl: env.baseUrl,
380
- transport: env.transport ?? fetchTransport,
495
+ transport: env.transport,
381
496
  ...headers !== void 0 ? { headers: () => headers } : {}
382
497
  }, {
383
498
  method: "GET",
@@ -392,37 +507,44 @@ async function runCall(entry, rawArgs, env) {
392
507
  });
393
508
  } else if (op.kind === "list") collection = await accessor.list(query);
394
509
  else collection = await accessor.id(requireId(args)).rel(op.relation).related(query);
395
- return structured(flattenCollection(Array.isArray(collection) ? collection : [], env.descriptor, env.includeDepth));
396
- }
397
- case "get": {
398
- const resource = await accessor.get(requireId(args), query);
399
- return structured({ item: isObject(resource) ? flattenResource(resource, env.descriptor, [], env.includeDepth) : null });
510
+ return envelope(collectionEnvelope(Array.isArray(collection) ? collection : [], flat));
400
511
  }
512
+ case "get": return {
513
+ kind: "bare",
514
+ envelope: resourceEnvelope(await accessor.get(requireId(args), query), flat)
515
+ };
401
516
  case "create": {
402
517
  const input = writeInput(args, entry.input.write);
403
518
  const created = await accessor.create(input);
404
- return structured(isObject(created) ? flattenResource(created, env.descriptor, [], env.includeDepth) : {
405
- id: String(input["id"] ?? ""),
406
- type: op.type
407
- });
519
+ return {
520
+ kind: "bare",
521
+ envelope: resourceEnvelope(isObject(created) ? created : {
522
+ id: String(input["id"] ?? ""),
523
+ type: op.type
524
+ }, flat)
525
+ };
408
526
  }
409
527
  case "update": {
410
528
  const id = requireId(args);
411
529
  const updated = await accessor.id(id).update(writeInput(args, entry.input.write));
412
- return structured(isObject(updated) ? flattenResource(updated, env.descriptor, [], env.includeDepth) : {
413
- id,
414
- type: op.type
415
- });
416
- }
417
- case "delete": {
418
- const id = requireId(args);
419
- await accessor.id(id).delete();
420
- return { content: [text(`Deleted ${op.type} ${id}.`)] };
530
+ return {
531
+ kind: "bare",
532
+ envelope: resourceEnvelope(isObject(updated) ? updated : {
533
+ id,
534
+ type: op.type
535
+ }, flat)
536
+ };
421
537
  }
422
- case "action": return runAction(entry, args, accessor, env);
538
+ case "delete":
539
+ await accessor.id(requireId(args)).delete();
540
+ return {
541
+ kind: "bare",
542
+ envelope: { data: null }
543
+ };
544
+ case "action": return runAction(entry, args, accessor, env, flat);
423
545
  }
424
546
  }
425
- async function runAction(entry, args, accessor, env) {
547
+ async function runAction(entry, args, accessor, env, flat) {
426
548
  const { op } = entry;
427
549
  const action = op.actionDescriptor;
428
550
  const call = (action.scope === "resource" ? accessor.id(requireId(args)).actions : accessor.actions)[entry.actionKey];
@@ -435,22 +557,36 @@ async function runAction(entry, args, accessor, env) {
435
557
  if (plan.kind === "body" && plan.required && body === void 0) throw new ArgumentError("body is required");
436
558
  result = await call(body, options);
437
559
  }
438
- if (action.location !== void 0 && isActionLocation(result)) return structured({ ...result });
560
+ if (action.location !== void 0 && isActionLocation(result)) return {
561
+ kind: "envelope",
562
+ envelope: { data: { ...result } },
563
+ location: true
564
+ };
565
+ const content = (value) => ({
566
+ kind: "content",
567
+ result: { content: [text(value)] }
568
+ });
439
569
  switch (entry.result) {
440
- case "collection": return structured(flattenCollection(Array.isArray(result) ? result : [], env.descriptor, env.includeDepth));
570
+ case "collection": return envelope(collectionEnvelope(Array.isArray(result) ? result : [], flat));
441
571
  case "resource":
442
572
  case "loose":
443
- if (Array.isArray(result)) return structured(flattenCollection(result, env.descriptor, env.includeDepth));
444
- return isObject(result) ? structured(flattenResource(result, env.descriptor, [], env.includeDepth)) : { content: [text("The action returned no resource.")] };
445
- case "meta": return structured(isObject(result) ? result : {});
573
+ if (Array.isArray(result)) return envelope(collectionEnvelope(result, flat));
574
+ return {
575
+ kind: "bare",
576
+ envelope: resourceEnvelope(isObject(result) ? result : null, flat)
577
+ };
578
+ case "meta": return envelope({ meta: isObject(result) ? result : {} });
446
579
  case "raw": {
447
580
  const mimeType = action.rawOutput?.[0]?.mediaType ?? "application/octet-stream";
448
- if (result === void 0) return { content: [text("Done.")] };
449
- if (typeof result === "string") return { content: [text(result)] };
450
- if (result instanceof ArrayBuffer || typeof Blob !== "undefined" && result instanceof Blob) return { content: [await binaryContent(result, `${env.baseUrl}${action.path}`, mimeType)] };
451
- return { content: [text(JSON.stringify(result))] };
581
+ if (result === void 0) return content("Done.");
582
+ if (typeof result === "string") return content(result);
583
+ if (result instanceof ArrayBuffer || typeof Blob !== "undefined" && result instanceof Blob) return {
584
+ kind: "content",
585
+ result: { content: [await binaryContent(result, `${env.baseUrl}${action.path}`, mimeType)] }
586
+ };
587
+ return content(JSON.stringify(result));
452
588
  }
453
- default: return { content: [text("Done.")] };
589
+ default: return content("Done.");
454
590
  }
455
591
  }
456
592
  /** The error a failed call carries, before the host's `mapError`. */
@@ -489,15 +625,18 @@ function errorResult(error) {
489
625
  //#endregion
490
626
  //#region src/model.ts
491
627
  /** The artefact's own format. A runtime refuses a format it doesn't know. */
492
- const MODEL_FORMAT = 3;
628
+ const MODEL_FORMAT = 4;
493
629
  /**
494
630
  * Every format this runtime reads. Format 1 has no `credentialSchemes`. Formats 1 and 2 have no
495
631
  * `includeDepth`, and store each operation's `scopes` and the `security` of its operation object.
632
+ * Formats 1 to 3 have no `resourceLinks`, and their output schemas describe the results before
633
+ * the envelope, which the runtime still returns for them.
496
634
  */
497
635
  const READABLE_FORMATS = [
498
636
  1,
499
637
  2,
500
- 3
638
+ 3,
639
+ 4
501
640
  ];
502
641
  /** The package that writes the artefact. */
503
642
  const MODEL_GENERATOR = "@haddowg/json-api-mcp";
@@ -511,14 +650,16 @@ var McpModelError = class extends Error {
511
650
  /** Check a parsed artefact before the runtime trusts it. */
512
651
  function assertModel(value) {
513
652
  if (!isObject(value) || value["generator"] !== "@haddowg/json-api-mcp") throw new McpModelError(`This isn't a model written by ${MODEL_GENERATOR}. Generate one with the json-api-mcp CLI.`);
514
- if (typeof value["format"] !== "number" || !READABLE_FORMATS.includes(value["format"])) throw new McpModelError(`The model is format ${String(value["format"])} and this runtime reads formats ${READABLE_FORMATS.slice(0, -1).join(", ")} and 3. Regenerate it with the same version of ${MODEL_GENERATOR} the server runs.`);
653
+ if (typeof value["format"] !== "number" || !READABLE_FORMATS.includes(value["format"])) throw new McpModelError(`The model is format ${String(value["format"])} and this runtime reads formats ${READABLE_FORMATS.slice(0, -1).join(", ")} and 4. Regenerate it with the same version of ${MODEL_GENERATOR} the server runs.`);
515
654
  const contract = value["contract"];
516
- if (typeof contract !== "number" || contract < 13 || contract > 18) throw new McpModelError(`The model was built from a document with generator contract ${String(contract)}, and this runtime reads contracts 13 to 18.`);
655
+ if (typeof contract !== "number" || contract < 13 || contract > 19) throw new McpModelError(`The model was built from a document with generator contract ${String(contract)}, and this runtime reads contracts 13 to 19.`);
517
656
  if (!isObject(value["descriptor"]) || !Array.isArray(value["tools"])) throw new McpModelError("The model has no descriptor or tools; regenerate it.");
518
657
  const credentialSchemes = value["credentialSchemes"];
519
658
  if (value["format"] !== 1 && (!Array.isArray(credentialSchemes) || !credentialSchemes.every((s) => typeof s === "string"))) throw new McpModelError("The model has no credentialSchemes list; regenerate it.");
520
659
  const includeDepth = value["includeDepth"];
521
- if (value["format"] === 3 && includeDepth !== null && !(typeof includeDepth === "number" && Number.isInteger(includeDepth) && includeDepth >= 0)) throw new McpModelError("The model has no includeDepth; regenerate it.");
660
+ if (value["format"] >= 3 && includeDepth !== null && !(typeof includeDepth === "number" && Number.isInteger(includeDepth) && includeDepth >= 0)) throw new McpModelError("The model has no includeDepth; regenerate it.");
661
+ const resourceLinks = value["resourceLinks"];
662
+ if (value["format"] === 4 && !(isObject(resourceLinks) && Object.values(resourceLinks).every((names) => Array.isArray(names) && names.every((name) => typeof name === "string")))) throw new McpModelError("The model has no resourceLinks; regenerate it.");
522
663
  return value;
523
664
  }
524
665
  /**
@@ -632,6 +773,8 @@ function createJsonApiMcp(options) {
632
773
  const defaultBaseUrl = options.baseUrl ?? model.baseUrl;
633
774
  const credentialSchemes = resolveCredentialSchemes(model, options.credentialSchemes);
634
775
  const includeDepth = model.includeDepth ?? null;
776
+ const resourceLinks = model.resourceLinks ?? {};
777
+ const legacyResults = model.format < 4;
635
778
  async function forRequest(request) {
636
779
  const ctx = request.context;
637
780
  let entries = catalogue.entries;
@@ -671,7 +814,9 @@ function createJsonApiMcp(options) {
671
814
  transport: options.transport,
672
815
  headers: init.headers,
673
816
  signal: callOptions.signal,
674
- includeDepth
817
+ includeDepth,
818
+ resourceLinks,
819
+ legacyResults
675
820
  });
676
821
  } catch (error) {
677
822
  const toolError = toToolError(error);