@withpica/mcp-sdk 3.24.0 → 3.26.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,99 @@ 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, which must not read as the tool's own work having been
89
+ * answered (the MCP server judges a call "every part refused" by that
90
+ * report). No caller since the MCP billing gate was retired (ADR-335); kept
91
+ * because it is part of the published SDK.
92
+ */
93
+ export function asideFromReport(fn) {
94
+ const current = catalogueContext.getStore();
95
+ if (!current)
96
+ return fn();
97
+ return catalogueContext.run({ catalogueId: current.catalogueId, answered: 0, refused: [] }, fn);
98
+ }
99
+ /**
100
+ * Record a relationship refusal on the current call's report (no-op outside
101
+ * one). Every client-catalogue refusal, from the proxy or a route, answers
102
+ * `{ success: false, error: { code, message, details: { reason_code } } }`
103
+ * (catalogueRefusalBody, lib/auth/relationship-refused-error.ts; pinned by
104
+ * lib/auth/__tests__/catalogue-refusal-shape.test.ts).
105
+ */
106
+ function recordCatalogueRefusal(url, body) {
107
+ const report = catalogueContext.getStore();
108
+ if (!report)
109
+ return;
110
+ let parsed;
111
+ try {
112
+ parsed = JSON.parse(body);
113
+ }
114
+ catch {
115
+ return;
116
+ }
117
+ if (parsed?.error?.code !== "RELATIONSHIP_REFUSED")
118
+ return;
119
+ const reason = parsed.error.details?.reason_code;
120
+ report.refused.push({
121
+ path: new URL(url).pathname,
122
+ reason: typeof reason === "string" ? reason : null,
123
+ });
124
+ }
125
+ export class CatalogueNotHonouredError extends ApiError {
126
+ catalogueId;
127
+ constructor(catalogueId, path) {
128
+ super(`This action can't be done for a client's catalogue yet, so nothing was done for catalogue ${catalogueId} (${path}).`, 0, false);
129
+ this.catalogueId = catalogueId;
130
+ this.name = "CatalogueNotHonouredError";
131
+ }
132
+ }
35
133
  /**
36
134
  * Thrown when the API responds 404 — the entity at the given id either
37
135
  * doesn't exist or doesn't belong to the caller's org. Distinguished from
@@ -106,9 +204,30 @@ class BaseResource {
106
204
  const timingOn = typeof process !== "undefined" && process.env?.PICA_SDK_TIMING === "1";
107
205
  const t0 = timingOn ? Date.now() : 0;
108
206
  let statusForTiming = 0;
207
+ const catalogueId = currentCatalogue();
208
+ const headers = catalogueId
209
+ ? {
210
+ ...init.headers,
211
+ [CATALOGUE_HEADER]: catalogueId,
212
+ }
213
+ : init.headers;
109
214
  try {
110
- const res = await fetch(url, { ...init, signal: controller.signal });
215
+ const res = await fetch(url, {
216
+ ...init,
217
+ headers,
218
+ signal: controller.signal,
219
+ });
111
220
  statusForTiming = res.status;
221
+ if (catalogueId &&
222
+ res.ok &&
223
+ res.headers.get(CATALOGUE_RESOLVED_HEADER) !== catalogueId) {
224
+ throw new CatalogueNotHonouredError(catalogueId, new URL(url).pathname);
225
+ }
226
+ if (catalogueId && res.ok) {
227
+ const report = catalogueContext.getStore();
228
+ if (report)
229
+ report.answered += 1;
230
+ }
112
231
  return res;
113
232
  }
114
233
  catch (err) {
@@ -142,6 +261,7 @@ class BaseResource {
142
261
  if (response.ok)
143
262
  return response;
144
263
  const errorText = await response.text();
264
+ recordCatalogueRefusal(url, errorText);
145
265
  // Auth errors — fail fast, never retry
146
266
  if (response.status === 401 || response.status === 403) {
147
267
  throw new ApiError(`Authentication failed: ${response.status} ${errorText}`, response.status, false);
@@ -868,6 +988,13 @@ class CreditsResource extends BaseResource {
868
988
  async listForWork(workId) {
869
989
  return this.request("GET", `/admin/works/${workId}/credits`);
870
990
  }
991
+ /**
992
+ * Every credit row on a work, composition and master, one entry per row
993
+ * with its own id (listForWork groups per person and keeps one id).
994
+ */
995
+ async listRowsForWork(workId) {
996
+ return this.request("GET", `/admin/works/${workId}/credits?view=rows`);
997
+ }
871
998
  async updateForWork(workId, credits) {
872
999
  return this.request("POST", `/admin/works/${workId}/credits`, credits);
873
1000
  }
@@ -2775,6 +2902,24 @@ class DuplicatesResource extends BaseResource {
2775
2902
  loser_ids: loserIds,
2776
2903
  });
2777
2904
  }
2905
+ /**
2906
+ * "They're different": mark every pair among `ids` (2–25 recordings or
2907
+ * releases of this catalogue) as not duplicates, so findDuplicates stops
2908
+ * offering them. Idempotent. Resolves {entity_type, ids, pairs, changed}.
2909
+ */
2910
+ async markDifferent(entityType, ids) {
2911
+ return this.request("POST", "/admin/catalog/duplicates/exclusions", {
2912
+ entity_type: entityType,
2913
+ ids,
2914
+ });
2915
+ }
2916
+ /** Take back markDifferent for every pair among `ids`. */
2917
+ async unmarkDifferent(entityType, ids) {
2918
+ return this.request("DELETE", "/admin/catalog/duplicates/exclusions", {
2919
+ entity_type: entityType,
2920
+ ids,
2921
+ });
2922
+ }
2778
2923
  }
2779
2924
  // --- Entity Context Resource (ADR-116 Phase 3) ---
2780
2925
  class EntityContextResource extends BaseResource {
@@ -4040,6 +4185,113 @@ class RightsResource extends BaseResource {
4040
4185
  return this.request("GET", "/admin/rights-statements/measures");
4041
4186
  }
4042
4187
  }
4188
+ function pageQuery(params) {
4189
+ const qp = new URLSearchParams();
4190
+ for (const [key, value] of Object.entries(params)) {
4191
+ if (value === undefined)
4192
+ continue;
4193
+ if (Array.isArray(value))
4194
+ value.forEach((v) => qp.append(key, v));
4195
+ else
4196
+ qp.set(key, String(value));
4197
+ }
4198
+ const qs = qp.toString();
4199
+ return qs ? `?${qs}` : "";
4200
+ }
4201
+ /**
4202
+ * Catalogues an organisation builds for a client, who accepts to own them
4203
+ * (ADR-333 D5). Builder side only: the client answers on screen, signed in.
4204
+ */
4205
+ class HandoversResource extends BaseResource {
4206
+ /**
4207
+ * POST /admin/handovers — create a catalogue for a client
4208
+ * (catalogue_name), or a new hand-over on one this organisation built whose
4209
+ * last one closed (catalogue_org_id). Nothing is sent.
4210
+ */
4211
+ async create(body) {
4212
+ return this.request("POST", "/admin/handovers", body);
4213
+ }
4214
+ /** POST /admin/handovers/{id}/send { expected_version? } — email the client. */
4215
+ async send(id, body) {
4216
+ return this.request("POST", `/admin/handovers/${encodeURIComponent(id)}/send`, body);
4217
+ }
4218
+ }
4219
+ /**
4220
+ * /admin/relationships/** — both sides of a relationship. Refusals arrive as
4221
+ * `{ success: false, error: { code, message, details? } }` (read them with
4222
+ * the MCP package's parseApiErrorBody); a step whose terms changed since they
4223
+ * were read is a 409 `status_changed` carrying `details.relationship`.
4224
+ */
4225
+ class RelationshipsResource extends BaseResource {
4226
+ /** GET /admin/relationships?side=owner|organisation&status=&cursor=&limit= */
4227
+ async list(params) {
4228
+ return this.request("GET", `/admin/relationships${pageQuery({
4229
+ side: params.side,
4230
+ status: params.statuses,
4231
+ cursor: params.cursor,
4232
+ limit: params.limit,
4233
+ })}`);
4234
+ }
4235
+ /** GET /admin/relationships/{id} */
4236
+ async get(id) {
4237
+ return this.request("GET", `/admin/relationships/${encodeURIComponent(id)}`);
4238
+ }
4239
+ /**
4240
+ * POST /admin/relationships { as, email, type, scope? } — invite someone by
4241
+ * email. as="owner" asks someone at an organisation to look after this
4242
+ * catalogue; as="organisation" asks a client to let this organisation look
4243
+ * after theirs. The reply is the same whether or not the address is on PICA.
4244
+ */
4245
+ async invite(body) {
4246
+ return this.request("POST", "/admin/relationships", body);
4247
+ }
4248
+ /** PATCH /admin/relationships/{id} { action: accept | decline | edit, expected_version, … } */
4249
+ async answer(id, body) {
4250
+ return this.request("PATCH", `/admin/relationships/${encodeURIComponent(id)}`, body);
4251
+ }
4252
+ /** DELETE /admin/relationships/{id} { expected_version, reason? } — the owner ends it. */
4253
+ async revoke(id, body) {
4254
+ return this.request("DELETE", `/admin/relationships/${encodeURIComponent(id)}`, body);
4255
+ }
4256
+ /** POST /admin/relationships/{id}/step-down { expected_version, reason? } — the organisation ends its side. */
4257
+ async stepDown(id, body) {
4258
+ return this.request("POST", `/admin/relationships/${encodeURIComponent(id)}/step-down`, body);
4259
+ }
4260
+ /** GET /admin/relationships/{id}/activity — one relationship's history, either party. */
4261
+ async events(id, params = {}) {
4262
+ return this.request("GET", `/admin/relationships/${encodeURIComponent(id)}/activity${pageQuery(params)}`);
4263
+ }
4264
+ /**
4265
+ * GET /admin/relationships/activity?side=&org= — side=owner: what
4266
+ * organisations did in this catalogue (org = one organisation);
4267
+ * side=organisation: this organisation's own actions (org = one catalogue).
4268
+ */
4269
+ async activity(params) {
4270
+ return this.request("GET", `/admin/relationships/activity${pageQuery(params)}`);
4271
+ }
4272
+ /**
4273
+ * PATCH /admin/relationships/invites/{id} { action: "accept", organisation_id, type?, scope? }
4274
+ * — answer yes to an invitation for one of the person's organisations.
4275
+ */
4276
+ async acceptInvite(inviteId, body) {
4277
+ return this.request("PATCH", `/admin/relationships/invites/${encodeURIComponent(inviteId)}`, { action: "accept", ...body });
4278
+ }
4279
+ }
4280
+ /**
4281
+ * /admin/portfolio — the client catalogues an organisation looks after. A
4282
+ * page may hold fewer rows than `limit` and still not be the last: follow
4283
+ * `nextCursor` until it is null.
4284
+ */
4285
+ class PortfolioResource extends BaseResource {
4286
+ /** GET /admin/portfolio?cursor=&limit= (limit ≤ 25) */
4287
+ async list(params = {}) {
4288
+ return this.request("GET", `/admin/portfolio${pageQuery(params)}`);
4289
+ }
4290
+ /** GET /admin/portfolio/briefing?cursor=&limit= — the list plus what needs attention. */
4291
+ async briefing(params = {}) {
4292
+ return this.request("GET", `/admin/portfolio/briefing${pageQuery(params)}`);
4293
+ }
4294
+ }
4043
4295
  class RoyaltiesResource extends BaseResource {
4044
4296
  async payments(params) {
4045
4297
  const qp = new URLSearchParams();
@@ -4542,6 +4794,11 @@ export class PicaClient {
4542
4794
  producerAgreements;
4543
4795
  workForHire;
4544
4796
  royalties;
4797
+ /** ADR-333 — who looks after whose catalogue, from either side. */
4798
+ relationships;
4799
+ handovers;
4800
+ /** ADR-333 — the client catalogues an organisation looks after. */
4801
+ portfolio;
4545
4802
  /** ADR-321 Phase 2 — programmable rights (propose / status / inspect). */
4546
4803
  rights;
4547
4804
  statements;
@@ -4653,6 +4910,9 @@ export class PicaClient {
4653
4910
  this.producerAgreements = new ProducerAgreementsResource(baseUrl, config.apiKey, debug);
4654
4911
  this.workForHire = new WorkForHireResource(baseUrl, config.apiKey, debug);
4655
4912
  this.royalties = new RoyaltiesResource(baseUrl, config.apiKey, debug);
4913
+ this.relationships = new RelationshipsResource(baseUrl, config.apiKey, debug);
4914
+ this.handovers = new HandoversResource(baseUrl, config.apiKey, debug);
4915
+ this.portfolio = new PortfolioResource(baseUrl, config.apiKey, debug);
4656
4916
  this.rights = new RightsResource(baseUrl, config.apiKey, debug);
4657
4917
  this.statements = new StatementsResource(baseUrl, config.apiKey, debug);
4658
4918
  this.selections = new SelectionsResource(baseUrl, config.apiKey, debug);