@withpica/mcp-sdk 3.24.0 → 3.25.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.js CHANGED
@@ -1,5 +1,10 @@
1
1
  // Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
2
2
  /* eslint-disable @typescript-eslint/no-explicit-any */
3
+ /**
4
+ * Lightweight PICA SDK client for MCP server
5
+ * Uses the PICA API directly without external dependencies
6
+ */
7
+ import { AsyncLocalStorage } from "node:async_hooks";
3
8
  // --- Request infrastructure ---
4
9
  const DEFAULT_TIMEOUT_MS = 30_000;
5
10
  const LONG_TIMEOUT_MS = 120_000;
@@ -32,6 +37,98 @@ export class ApiError extends Error {
32
37
  this.name = "ApiError";
33
38
  }
34
39
  }
40
+ /**
41
+ * ADR-333 D7 — acting for a client catalogue, one call at a time.
42
+ *
43
+ * An organisation's assistant names the client catalogue on each tool call
44
+ * (the optional `catalogue` argument). The MCP server runs that call inside
45
+ * `withCatalogue(id, …)`, and every request the call makes carries
46
+ * `x-pica-catalogue: <id>`. The app resolves it through the relationship on
47
+ * every request (PR 1.3) and answers with `x-pica-catalogue-resolved: <id>`.
48
+ *
49
+ * A per-call context, not a field on the client: the stdio server shares one
50
+ * client across concurrent tool calls, so a field would leak one call's
51
+ * catalogue into another's requests.
52
+ *
53
+ * Fails closed: a 2xx answer without a matching echo means the route did not
54
+ * act for that catalogue (it may have used the caller's own), so the call
55
+ * throws `CatalogueNotHonouredError` instead of reporting success. This is the
56
+ * client's check only; stopping such a route from writing at all is the app's
57
+ * job (PR 1.3 refuses a catalogue header it did not resolve).
58
+ */
59
+ export const CATALOGUE_HEADER = "x-pica-catalogue";
60
+ export const CATALOGUE_RESOLVED_HEADER = "x-pica-catalogue-resolved";
61
+ const catalogueContext = new AsyncLocalStorage();
62
+ /** Run `fn` with every SDK request in it acting for `catalogueId`. */
63
+ export function withCatalogue(catalogueId, fn) {
64
+ return catalogueId
65
+ ? runInCatalogue(catalogueId, fn).then((r) => r.value)
66
+ : fn();
67
+ }
68
+ /**
69
+ * `withCatalogue`, also returning the call's report. The report is the one
70
+ * the call's requests wrote to; it is complete once `fn` settles.
71
+ */
72
+ export async function runInCatalogue(catalogueId, fn) {
73
+ const report = {
74
+ catalogueId,
75
+ answered: 0,
76
+ refused: [],
77
+ };
78
+ const value = await catalogueContext.run(report, fn);
79
+ return { value, report };
80
+ }
81
+ /** The catalogue the current call acts for, if any. */
82
+ export function currentCatalogue() {
83
+ return catalogueContext.getStore()?.catalogueId;
84
+ }
85
+ /**
86
+ * Run `fn` in the current client catalogue (its requests still name it), but
87
+ * leave its requests off the current call's report: for a check PICA makes
88
+ * for itself (the billing gate reading the client's plan), which must not
89
+ * read as the tool's own work having been answered (the MCP server judges a
90
+ * call "every part refused" by that report).
91
+ */
92
+ export function asideFromReport(fn) {
93
+ const current = catalogueContext.getStore();
94
+ if (!current)
95
+ return fn();
96
+ return catalogueContext.run({ catalogueId: current.catalogueId, answered: 0, refused: [] }, fn);
97
+ }
98
+ /**
99
+ * Record a relationship refusal on the current call's report (no-op outside
100
+ * one). Every client-catalogue refusal, from the proxy or a route, answers
101
+ * `{ success: false, error: { code, message, details: { reason_code } } }`
102
+ * (catalogueRefusalBody, lib/auth/relationship-refused-error.ts; pinned by
103
+ * lib/auth/__tests__/catalogue-refusal-shape.test.ts).
104
+ */
105
+ function recordCatalogueRefusal(url, body) {
106
+ const report = catalogueContext.getStore();
107
+ if (!report)
108
+ return;
109
+ let parsed;
110
+ try {
111
+ parsed = JSON.parse(body);
112
+ }
113
+ catch {
114
+ return;
115
+ }
116
+ if (parsed?.error?.code !== "RELATIONSHIP_REFUSED")
117
+ return;
118
+ const reason = parsed.error.details?.reason_code;
119
+ report.refused.push({
120
+ path: new URL(url).pathname,
121
+ reason: typeof reason === "string" ? reason : null,
122
+ });
123
+ }
124
+ export class CatalogueNotHonouredError extends ApiError {
125
+ catalogueId;
126
+ constructor(catalogueId, path) {
127
+ super(`This action can't be done for a client's catalogue yet, so nothing was done for catalogue ${catalogueId} (${path}).`, 0, false);
128
+ this.catalogueId = catalogueId;
129
+ this.name = "CatalogueNotHonouredError";
130
+ }
131
+ }
35
132
  /**
36
133
  * Thrown when the API responds 404 — the entity at the given id either
37
134
  * doesn't exist or doesn't belong to the caller's org. Distinguished from
@@ -106,9 +203,30 @@ class BaseResource {
106
203
  const timingOn = typeof process !== "undefined" && process.env?.PICA_SDK_TIMING === "1";
107
204
  const t0 = timingOn ? Date.now() : 0;
108
205
  let statusForTiming = 0;
206
+ const catalogueId = currentCatalogue();
207
+ const headers = catalogueId
208
+ ? {
209
+ ...init.headers,
210
+ [CATALOGUE_HEADER]: catalogueId,
211
+ }
212
+ : init.headers;
109
213
  try {
110
- const res = await fetch(url, { ...init, signal: controller.signal });
214
+ const res = await fetch(url, {
215
+ ...init,
216
+ headers,
217
+ signal: controller.signal,
218
+ });
111
219
  statusForTiming = res.status;
220
+ if (catalogueId &&
221
+ res.ok &&
222
+ res.headers.get(CATALOGUE_RESOLVED_HEADER) !== catalogueId) {
223
+ throw new CatalogueNotHonouredError(catalogueId, new URL(url).pathname);
224
+ }
225
+ if (catalogueId && res.ok) {
226
+ const report = catalogueContext.getStore();
227
+ if (report)
228
+ report.answered += 1;
229
+ }
112
230
  return res;
113
231
  }
114
232
  catch (err) {
@@ -142,6 +260,7 @@ class BaseResource {
142
260
  if (response.ok)
143
261
  return response;
144
262
  const errorText = await response.text();
263
+ recordCatalogueRefusal(url, errorText);
145
264
  // Auth errors — fail fast, never retry
146
265
  if (response.status === 401 || response.status === 403) {
147
266
  throw new ApiError(`Authentication failed: ${response.status} ${errorText}`, response.status, false);
@@ -2775,6 +2894,24 @@ class DuplicatesResource extends BaseResource {
2775
2894
  loser_ids: loserIds,
2776
2895
  });
2777
2896
  }
2897
+ /**
2898
+ * "They're different": mark every pair among `ids` (2–25 recordings or
2899
+ * releases of this catalogue) as not duplicates, so findDuplicates stops
2900
+ * offering them. Idempotent. Resolves {entity_type, ids, pairs, changed}.
2901
+ */
2902
+ async markDifferent(entityType, ids) {
2903
+ return this.request("POST", "/admin/catalog/duplicates/exclusions", {
2904
+ entity_type: entityType,
2905
+ ids,
2906
+ });
2907
+ }
2908
+ /** Take back markDifferent for every pair among `ids`. */
2909
+ async unmarkDifferent(entityType, ids) {
2910
+ return this.request("DELETE", "/admin/catalog/duplicates/exclusions", {
2911
+ entity_type: entityType,
2912
+ ids,
2913
+ });
2914
+ }
2778
2915
  }
2779
2916
  // --- Entity Context Resource (ADR-116 Phase 3) ---
2780
2917
  class EntityContextResource extends BaseResource {
@@ -4040,6 +4177,113 @@ class RightsResource extends BaseResource {
4040
4177
  return this.request("GET", "/admin/rights-statements/measures");
4041
4178
  }
4042
4179
  }
4180
+ function pageQuery(params) {
4181
+ const qp = new URLSearchParams();
4182
+ for (const [key, value] of Object.entries(params)) {
4183
+ if (value === undefined)
4184
+ continue;
4185
+ if (Array.isArray(value))
4186
+ value.forEach((v) => qp.append(key, v));
4187
+ else
4188
+ qp.set(key, String(value));
4189
+ }
4190
+ const qs = qp.toString();
4191
+ return qs ? `?${qs}` : "";
4192
+ }
4193
+ /**
4194
+ * Catalogues an organisation builds for a client, who accepts to own them
4195
+ * (ADR-333 D5). Builder side only: the client answers on screen, signed in.
4196
+ */
4197
+ class HandoversResource extends BaseResource {
4198
+ /**
4199
+ * POST /admin/handovers — create a catalogue for a client
4200
+ * (catalogue_name), or a new hand-over on one this organisation built whose
4201
+ * last one closed (catalogue_org_id). Nothing is sent.
4202
+ */
4203
+ async create(body) {
4204
+ return this.request("POST", "/admin/handovers", body);
4205
+ }
4206
+ /** POST /admin/handovers/{id}/send { expected_version? } — email the client. */
4207
+ async send(id, body) {
4208
+ return this.request("POST", `/admin/handovers/${encodeURIComponent(id)}/send`, body);
4209
+ }
4210
+ }
4211
+ /**
4212
+ * /admin/relationships/** — both sides of a relationship. Refusals arrive as
4213
+ * `{ success: false, error: { code, message, details? } }` (read them with
4214
+ * the MCP package's parseApiErrorBody); a step whose terms changed since they
4215
+ * were read is a 409 `status_changed` carrying `details.relationship`.
4216
+ */
4217
+ class RelationshipsResource extends BaseResource {
4218
+ /** GET /admin/relationships?side=owner|organisation&status=&cursor=&limit= */
4219
+ async list(params) {
4220
+ return this.request("GET", `/admin/relationships${pageQuery({
4221
+ side: params.side,
4222
+ status: params.statuses,
4223
+ cursor: params.cursor,
4224
+ limit: params.limit,
4225
+ })}`);
4226
+ }
4227
+ /** GET /admin/relationships/{id} */
4228
+ async get(id) {
4229
+ return this.request("GET", `/admin/relationships/${encodeURIComponent(id)}`);
4230
+ }
4231
+ /**
4232
+ * POST /admin/relationships { as, email, type, scope? } — invite someone by
4233
+ * email. as="owner" asks someone at an organisation to look after this
4234
+ * catalogue; as="organisation" asks a client to let this organisation look
4235
+ * after theirs. The reply is the same whether or not the address is on PICA.
4236
+ */
4237
+ async invite(body) {
4238
+ return this.request("POST", "/admin/relationships", body);
4239
+ }
4240
+ /** PATCH /admin/relationships/{id} { action: accept | decline | edit, expected_version, … } */
4241
+ async answer(id, body) {
4242
+ return this.request("PATCH", `/admin/relationships/${encodeURIComponent(id)}`, body);
4243
+ }
4244
+ /** DELETE /admin/relationships/{id} { expected_version, reason? } — the owner ends it. */
4245
+ async revoke(id, body) {
4246
+ return this.request("DELETE", `/admin/relationships/${encodeURIComponent(id)}`, body);
4247
+ }
4248
+ /** POST /admin/relationships/{id}/step-down { expected_version, reason? } — the organisation ends its side. */
4249
+ async stepDown(id, body) {
4250
+ return this.request("POST", `/admin/relationships/${encodeURIComponent(id)}/step-down`, body);
4251
+ }
4252
+ /** GET /admin/relationships/{id}/activity — one relationship's history, either party. */
4253
+ async events(id, params = {}) {
4254
+ return this.request("GET", `/admin/relationships/${encodeURIComponent(id)}/activity${pageQuery(params)}`);
4255
+ }
4256
+ /**
4257
+ * GET /admin/relationships/activity?side=&org= — side=owner: what
4258
+ * organisations did in this catalogue (org = one organisation);
4259
+ * side=organisation: this organisation's own actions (org = one catalogue).
4260
+ */
4261
+ async activity(params) {
4262
+ return this.request("GET", `/admin/relationships/activity${pageQuery(params)}`);
4263
+ }
4264
+ /**
4265
+ * PATCH /admin/relationships/invites/{id} { action: "accept", organisation_id, type?, scope? }
4266
+ * — answer yes to an invitation for one of the person's organisations.
4267
+ */
4268
+ async acceptInvite(inviteId, body) {
4269
+ return this.request("PATCH", `/admin/relationships/invites/${encodeURIComponent(inviteId)}`, { action: "accept", ...body });
4270
+ }
4271
+ }
4272
+ /**
4273
+ * /admin/portfolio — the client catalogues an organisation looks after. A
4274
+ * page may hold fewer rows than `limit` and still not be the last: follow
4275
+ * `nextCursor` until it is null.
4276
+ */
4277
+ class PortfolioResource extends BaseResource {
4278
+ /** GET /admin/portfolio?cursor=&limit= (limit ≤ 25) */
4279
+ async list(params = {}) {
4280
+ return this.request("GET", `/admin/portfolio${pageQuery(params)}`);
4281
+ }
4282
+ /** GET /admin/portfolio/briefing?cursor=&limit= — the list plus what needs attention. */
4283
+ async briefing(params = {}) {
4284
+ return this.request("GET", `/admin/portfolio/briefing${pageQuery(params)}`);
4285
+ }
4286
+ }
4043
4287
  class RoyaltiesResource extends BaseResource {
4044
4288
  async payments(params) {
4045
4289
  const qp = new URLSearchParams();
@@ -4542,6 +4786,11 @@ export class PicaClient {
4542
4786
  producerAgreements;
4543
4787
  workForHire;
4544
4788
  royalties;
4789
+ /** ADR-333 — who looks after whose catalogue, from either side. */
4790
+ relationships;
4791
+ handovers;
4792
+ /** ADR-333 — the client catalogues an organisation looks after. */
4793
+ portfolio;
4545
4794
  /** ADR-321 Phase 2 — programmable rights (propose / status / inspect). */
4546
4795
  rights;
4547
4796
  statements;
@@ -4653,6 +4902,9 @@ export class PicaClient {
4653
4902
  this.producerAgreements = new ProducerAgreementsResource(baseUrl, config.apiKey, debug);
4654
4903
  this.workForHire = new WorkForHireResource(baseUrl, config.apiKey, debug);
4655
4904
  this.royalties = new RoyaltiesResource(baseUrl, config.apiKey, debug);
4905
+ this.relationships = new RelationshipsResource(baseUrl, config.apiKey, debug);
4906
+ this.handovers = new HandoversResource(baseUrl, config.apiKey, debug);
4907
+ this.portfolio = new PortfolioResource(baseUrl, config.apiKey, debug);
4656
4908
  this.rights = new RightsResource(baseUrl, config.apiKey, debug);
4657
4909
  this.statements = new StatementsResource(baseUrl, config.apiKey, debug);
4658
4910
  this.selections = new SelectionsResource(baseUrl, config.apiKey, debug);