@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/CHANGELOG.md +36 -0
- package/dist/index.d.ts +387 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +253 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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, {
|
|
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);
|