@frihet/mcp-server 1.16.5 → 1.17.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.
Files changed (68) hide show
  1. package/README.md +92 -31
  2. package/dist/agent-onboarding.d.ts +98 -0
  3. package/dist/agent-onboarding.js +275 -0
  4. package/dist/api-origin.d.ts +9 -0
  5. package/dist/api-origin.js +41 -0
  6. package/dist/canary/mcp-harness.d.ts +77 -0
  7. package/dist/canary/mcp-harness.js +549 -0
  8. package/dist/capability-truth.d.ts +29 -0
  9. package/dist/capability-truth.js +254 -0
  10. package/dist/client-interface.d.ts +22 -14
  11. package/dist/client.d.ts +114 -18
  12. package/dist/client.js +520 -58
  13. package/dist/demo-client.d.ts +16 -13
  14. package/dist/demo-client.js +209 -29
  15. package/dist/demo-fixtures.d.ts +3 -3
  16. package/dist/demo-fixtures.js +7 -7
  17. package/dist/fiscal-aliases.d.ts +44 -0
  18. package/dist/fiscal-aliases.js +62 -0
  19. package/dist/index.js +41 -56
  20. package/dist/logger.d.ts +2 -2
  21. package/dist/logger.js +140 -8
  22. package/dist/observability.d.ts +18 -29
  23. package/dist/observability.js +192 -105
  24. package/dist/openai-profile.d.ts +27 -15
  25. package/dist/openai-profile.js +1191 -145
  26. package/dist/openai-review-contract.d.ts +49 -0
  27. package/dist/openai-review-contract.js +919 -0
  28. package/dist/openai-review-oauth.d.ts +98 -0
  29. package/dist/openai-review-oauth.js +136 -0
  30. package/dist/prompts/register-all.d.ts +2 -0
  31. package/dist/prompts/register-all.js +2 -0
  32. package/dist/public-capability-contract.d.ts +39 -0
  33. package/dist/public-capability-contract.js +186 -0
  34. package/dist/redaction.d.ts +10 -0
  35. package/dist/redaction.js +74 -0
  36. package/dist/resources/register-all.js +6 -12
  37. package/dist/server-composition.d.ts +16 -0
  38. package/dist/server-composition.js +44 -0
  39. package/dist/tool-exposure.d.ts +25 -22
  40. package/dist/tool-exposure.js +199 -77
  41. package/dist/tools/accountingClose.d.ts +14 -5
  42. package/dist/tools/accountingClose.js +41 -23
  43. package/dist/tools/backend-availability.d.ts +3 -3
  44. package/dist/tools/backend-availability.js +3 -3
  45. package/dist/tools/clients.js +9 -9
  46. package/dist/tools/crm.js +22 -21
  47. package/dist/tools/deposits.js +30 -11
  48. package/dist/tools/einvoice.d.ts +1 -1
  49. package/dist/tools/expenses.d.ts +16 -0
  50. package/dist/tools/expenses.js +47 -27
  51. package/dist/tools/hr.d.ts +2 -3
  52. package/dist/tools/hr.js +8 -9
  53. package/dist/tools/intelligence.js +95 -4
  54. package/dist/tools/invoices.js +150 -47
  55. package/dist/tools/payroll.d.ts +5 -8
  56. package/dist/tools/payroll.js +19 -32
  57. package/dist/tools/permissions.d.ts +4 -7
  58. package/dist/tools/permissions.js +11 -14
  59. package/dist/tools/products.js +6 -6
  60. package/dist/tools/quotes.js +109 -24
  61. package/dist/tools/shared.d.ts +1431 -75
  62. package/dist/tools/shared.js +386 -76
  63. package/dist/tools/team.js +18 -14
  64. package/dist/tools/vendors.js +9 -9
  65. package/dist/tools/webhooks.js +35 -30
  66. package/dist/types.d.ts +28 -4
  67. package/docs/agent-onboarding.json +381 -0
  68. package/package.json +32 -7
package/dist/client.js CHANGED
@@ -11,11 +11,34 @@
11
11
  * (`req.query.cursor`, functions/src/publicApi.ts in Frihet-ERP). `after`
12
12
  * is never sent on the wire. See src/__tests__/pagination-cursor-param.test.ts.
13
13
  */
14
+ import { sanitizeServerRemediation } from "./redaction.js";
14
15
  import { logApiCall, logRetry } from "./logger.js";
15
16
  const BASE_URL = "https://api.frihet.io/v1";
17
+ const OAUTH_CLOUD_FUNCTION_BASE_URL = "https://europe-west1-gen-lang-client-0335716041.cloudfunctions.net/publicApi/api/v1";
18
+ const OAUTH_SERVICE_TRUSTED_BASE_URLS = new Set([
19
+ BASE_URL,
20
+ OAUTH_CLOUD_FUNCTION_BASE_URL,
21
+ ]);
16
22
  const MAX_RETRIES = 3;
17
23
  const DEFAULT_RETRY_DELAY_MS = 1000;
18
24
  const REQUEST_TIMEOUT_MS = 30000;
25
+ /**
26
+ * Hard size caps for document responses. Enforced TWICE: precheck on
27
+ * `Content-Length` (so an honest server doesn't waste bandwidth) and after
28
+ * streaming (so a missing/lying `Content-Length` still can't trigger an
29
+ * unbounded allocation).
30
+ *
31
+ * - PDF: 25 MiB — generous for any ERP-issued invoice PDF, including
32
+ * embedded logos, Facturae XML attachments, and stamp signatures.
33
+ * - XML: 5 MiB — UBL / Facturae / PEPPOL documents stay well under 1 MiB
34
+ * in practice; 5 MiB absorbs any historical / annex-laden outlier.
35
+ *
36
+ * Anything larger is rejected with `413 payload_too_large` BEFORE we allocate
37
+ * — the user sees a clean error, the worker doesn't OOM.
38
+ */
39
+ export const MAX_PDF_BYTES = 25 * 1024 * 1024;
40
+ export const MAX_XML_BYTES = 5 * 1024 * 1024;
41
+ const MAX_ERROR_BYTES = 64 * 1024;
19
42
  /**
20
43
  * HTTP methods for which the backend treats the request as a mutation and
21
44
  * therefore accepts (and for some endpoints REQUIRES) an `Idempotency-Key`
@@ -24,15 +47,42 @@ const REQUEST_TIMEOUT_MS = 30000;
24
47
  * of the time — see src/__tests__/idempotency-key-contract.test.ts.
25
48
  */
26
49
  const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
50
+ /**
51
+ * Origin marker sent on every request so the backend can tell an MCP-driven
52
+ * create apart from a direct-API one.
53
+ *
54
+ * The backend classifier (`detectApiInvoiceSource`, Frihet-ERP
55
+ * functions/src/publicApi.ts) matches `mcp` / `frihet-mcp` / `@frihet/mcp`
56
+ * across three headers: `x-frihet-source`, `x-frihet-client` and `user-agent`.
57
+ * We send TWO markers because they survive different network paths:
58
+ *
59
+ * - `X-Frihet-Source: mcp` is the explicit, documented marker. It reaches the
60
+ * backend when the client talks to the Cloud Function directly (custom
61
+ * `baseUrl`, self-hosted proxy).
62
+ * - `User-Agent` is what actually reaches the backend on the DEFAULT baseUrl:
63
+ * `api.frihet.io` is fronted by workers/api-proxy/worker.js, whose
64
+ * `ALLOWED_REQUEST_HEADERS` allowlist forwards `user-agent` but drops
65
+ * `x-frihet-source` (verified live: /agents.json answers 200 at
66
+ * api.frihet.io and 401 at the Cloud Function, so the Worker is in path).
67
+ * Without the UA the source header is a phantom — set, then stripped at the
68
+ * edge, and every MCP invoice still lands as `source: 'api'`.
69
+ *
70
+ * Deliberately carries no version: the version lives in package.json only
71
+ * (see src/index.ts PKG_VERSION) and a second hardcoded copy is exactly the
72
+ * drift `scripts/audit-mcp-refs.mjs` exists to catch.
73
+ *
74
+ * Pinned on the wire by src/__tests__/source-header-contract.test.ts.
75
+ */
76
+ const SOURCE_MARKER = "mcp";
77
+ const SOURCE_USER_AGENT = "frihet-mcp-server";
27
78
  /**
28
79
  * Fresh idempotency key, always a syntactically valid UUID v4.
29
80
  *
30
- * `crypto.randomUUID` is a global on the Cloudflare Workers runtime and on
31
- * Node >= 19. On Node 18 — our declared `engines` floor — `globalThis.crypto`
32
- * is behind `--experimental-global-webcrypto`, so the fallback is a REAL code
33
- * path there, not a theoretical one. It therefore has to produce a UUID and
34
- * not an ad-hoc string: the backend documents "UUID v4 recommended", and
35
- * src/__tests__/idempotency-key-contract.test.ts asserts the shape.
81
+ * `crypto.randomUUID` is global on Cloudflare Workers and the supported Node
82
+ * runtime. The fallback remains defense in depth for constrained embedders and
83
+ * tests that intentionally remove the global; it must still produce a UUID,
84
+ * not an ad-hoc string. The backend recommends UUID v4 and the contract test
85
+ * asserts the shape.
36
86
  */
37
87
  function newIdempotencyKey() {
38
88
  const c = globalThis.crypto;
@@ -58,27 +108,159 @@ function normalizeIdempotencyKey(key) {
58
108
  const trimmed = key?.trim();
59
109
  return trimmed ? trimmed : undefined;
60
110
  }
111
+ function normalizedContentType(contentType) {
112
+ return contentType.split(";", 1)[0].trim().toLowerCase();
113
+ }
114
+ function isXmlContentType(contentType) {
115
+ const normalized = normalizedContentType(contentType);
116
+ return normalized === "application/xml" || normalized === "text/xml" || normalized.endsWith("+xml");
117
+ }
118
+ function hasPdfSignature(bytes) {
119
+ return bytes.byteLength >= 5 &&
120
+ bytes[0] === 0x25 &&
121
+ bytes[1] === 0x50 &&
122
+ bytes[2] === 0x44 &&
123
+ bytes[3] === 0x46 &&
124
+ bytes[4] === 0x2d;
125
+ }
126
+ function attachmentFilename(contentDisposition) {
127
+ if (!contentDisposition)
128
+ return undefined;
129
+ const encoded = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
130
+ const quoted = contentDisposition.match(/filename="([^"]+)"/i)?.[1];
131
+ const plain = contentDisposition.match(/filename=([^;]+)/i)?.[1];
132
+ const candidate = (encoded ?? quoted ?? plain)?.trim();
133
+ if (!candidate)
134
+ return undefined;
135
+ try {
136
+ return decodeURIComponent(candidate).split(/[\\/]/).pop();
137
+ }
138
+ catch {
139
+ return candidate.split(/[\\/]/).pop();
140
+ }
141
+ }
142
+ function isAbortError(error) {
143
+ return error instanceof Error && error.name === "AbortError";
144
+ }
145
+ function decodeUtf8(bytes, label) {
146
+ try {
147
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes);
148
+ }
149
+ catch {
150
+ throw new FrihetApiError(200, "invalid_response", `${label} is not valid UTF-8`);
151
+ }
152
+ }
153
+ const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
154
+ /** Encode bytes without relying on Node's Buffer in browser/Worker runtimes. */
155
+ function encodeBase64(bytes) {
156
+ let encoded = "";
157
+ for (let index = 0; index < bytes.length; index += 3) {
158
+ const first = bytes[index];
159
+ const hasSecond = index + 1 < bytes.length;
160
+ const hasThird = index + 2 < bytes.length;
161
+ const second = hasSecond ? bytes[index + 1] : 0;
162
+ const third = hasThird ? bytes[index + 2] : 0;
163
+ const chunk = (first << 16) | (second << 8) | third;
164
+ encoded += BASE64_ALPHABET[(chunk >>> 18) & 0x3f];
165
+ encoded += BASE64_ALPHABET[(chunk >>> 12) & 0x3f];
166
+ encoded += hasSecond ? BASE64_ALPHABET[(chunk >>> 6) & 0x3f] : "=";
167
+ encoded += hasThird ? BASE64_ALPHABET[chunk & 0x3f] : "=";
168
+ }
169
+ return encoded;
170
+ }
61
171
  export class FrihetApiError extends Error {
62
172
  statusCode;
63
173
  errorCode;
64
- constructor(statusCode, errorCode, message) {
65
- super(message ?? errorCode);
174
+ detail;
175
+ constructor(statusCode, errorCode, message, detail) {
176
+ super(statusCode === 403
177
+ ? sanitizeServerRemediation(message, errorCode) ?? errorCode
178
+ : message ?? errorCode);
66
179
  this.statusCode = statusCode;
67
180
  this.errorCode = errorCode;
181
+ this.detail = detail;
68
182
  this.name = "FrihetApiError";
69
183
  }
70
184
  }
185
+ /**
186
+ * Enforce the webhook secret's one-time write echo and normalize the one
187
+ * Firestore Timestamp that the ERP webhook route currently emits without its
188
+ * usual serializer. Invalid timestamp objects are preserved so the strict MCP
189
+ * output schema fails closed instead of fabricating a value.
190
+ */
191
+ function normalizeWebhookRecord(webhook, allowedSecret) {
192
+ const normalized = { ...webhook };
193
+ if (typeof allowedSecret !== "string" ||
194
+ typeof normalized.secret !== "string" ||
195
+ normalized.secret !== allowedSecret) {
196
+ delete normalized.secret;
197
+ }
198
+ const lastTriggeredAt = normalized.lastTriggeredAt;
199
+ if (lastTriggeredAt !== null &&
200
+ typeof lastTriggeredAt === "object" &&
201
+ !Array.isArray(lastTriggeredAt)) {
202
+ const timestamp = lastTriggeredAt;
203
+ const seconds = timestamp._seconds;
204
+ const nanoseconds = timestamp._nanoseconds;
205
+ if (typeof seconds === "number" &&
206
+ Number.isInteger(seconds) &&
207
+ Number.isFinite(seconds) &&
208
+ typeof nanoseconds === "number" &&
209
+ Number.isInteger(nanoseconds) &&
210
+ nanoseconds >= 0 &&
211
+ nanoseconds < 1_000_000_000) {
212
+ const date = new Date(seconds * 1000 + Math.floor(nanoseconds / 1_000_000));
213
+ if (Number.isFinite(date.getTime())) {
214
+ normalized.lastTriggeredAt = date.toISOString();
215
+ }
216
+ }
217
+ }
218
+ return normalized;
219
+ }
220
+ function normalizeApiError(value, statusCode, statusText) {
221
+ const fallbackError = `http_${statusCode}`;
222
+ const record = typeof value === "object" && value !== null
223
+ ? value
224
+ : undefined;
225
+ const error = typeof record?.error === "string" && record.error.trim()
226
+ ? record.error
227
+ : fallbackError;
228
+ const message = typeof record?.message === "string"
229
+ ? record.message
230
+ : (error !== fallbackError ? error : statusText || fallbackError);
231
+ return {
232
+ error,
233
+ message,
234
+ ...(typeof record?.detail === "string" ? { detail: record.detail } : {}),
235
+ };
236
+ }
71
237
  export class FrihetClient {
72
238
  apiKey;
73
239
  baseUrl;
74
240
  timeoutMs;
241
+ oauthServiceSecret;
75
242
  constructor(apiKey, baseUrl, options) {
76
243
  if (!apiKey) {
77
244
  throw new Error("FRIHET_API_KEY is required. Set it as an environment variable or pass it to the constructor.");
78
245
  }
246
+ const resolvedBaseUrl = baseUrl ?? BASE_URL;
79
247
  this.apiKey = apiKey;
80
- this.baseUrl = baseUrl ?? BASE_URL;
248
+ this.baseUrl = resolvedBaseUrl;
81
249
  this.timeoutMs = options?.timeoutMs ?? REQUEST_TIMEOUT_MS;
250
+ if (options?.oauthServiceSecret !== undefined) {
251
+ if (new TextEncoder().encode(options.oauthServiceSecret).byteLength < 32
252
+ || /[\r\n\0]/u.test(options.oauthServiceSecret)) {
253
+ throw new Error("OAuth API-key service authentication is not configured");
254
+ }
255
+ // A dashboard/API-key caller may still use a custom API base. The
256
+ // internal OAuth second factor may not: pin its authority at the sink so
257
+ // a future caller cannot exfiltrate both credentials by changing only
258
+ // constructor input while leaving the approved fetch fingerprint intact.
259
+ if (!OAUTH_SERVICE_TRUSTED_BASE_URLS.has(resolvedBaseUrl)) {
260
+ throw new Error("OAuth API-key service authority is not trusted");
261
+ }
262
+ this.oauthServiceSecret = options.oauthServiceSecret;
263
+ }
82
264
  }
83
265
  // ------------------------------------------------------------------ HTTP
84
266
  // ------------------------------------------------------------------
@@ -102,7 +284,12 @@ export class FrihetClient {
102
284
  "X-API-Key": this.apiKey,
103
285
  "Content-Type": "application/json",
104
286
  Accept: "application/json",
287
+ "X-Frihet-Source": SOURCE_MARKER,
288
+ "User-Agent": SOURCE_USER_AGENT,
105
289
  };
290
+ if (this.oauthServiceSecret) {
291
+ headers["x-frihet-oauth-key"] = this.oauthServiceSecret;
292
+ }
106
293
  if (resolvedIdempotencyKey) {
107
294
  headers["Idempotency-Key"] = resolvedIdempotencyKey;
108
295
  }
@@ -116,23 +303,23 @@ export class FrihetClient {
116
303
  headers,
117
304
  body: body ? JSON.stringify(body) : undefined,
118
305
  signal: controller.signal,
306
+ redirect: "error",
119
307
  });
120
308
  }
121
309
  catch (error) {
310
+ clearTimeout(timeoutId);
122
311
  const durationMs = Math.round(Date.now() - startTime);
123
312
  if (error instanceof Error && error.name === "AbortError") {
124
313
  logApiCall(method, path, 408, durationMs);
125
314
  throw new FrihetApiError(408, "request_timeout", `Request timed out after ${this.timeoutMs / 1000} seconds`);
126
315
  }
127
316
  logApiCall(method, path, 0, durationMs);
128
- throw error;
129
- }
130
- finally {
131
- clearTimeout(timeoutId);
317
+ throw new FrihetApiError(503, "network_error", "The network request failed before Frihet could confirm its outcome.");
132
318
  }
133
319
  const durationMs = Math.round(Date.now() - startTime);
134
320
  // Rate limit handling
135
321
  if (response.status === 429) {
322
+ clearTimeout(timeoutId);
136
323
  logApiCall(method, path, 429, durationMs);
137
324
  if (retryCount >= MAX_RETRIES) {
138
325
  throw new FrihetApiError(429, "rate_limit_exceeded", "Rate limit exceeded after multiple retries. Please try again later.");
@@ -150,28 +337,207 @@ export class FrihetClient {
150
337
  logApiCall(method, path, response.status, durationMs);
151
338
  let errorBody;
152
339
  try {
153
- errorBody = (await response.json());
340
+ errorBody = normalizeApiError(await response.json(), response.status, response.statusText);
154
341
  }
155
342
  catch {
156
- errorBody = {
157
- error: `http_${response.status}`,
158
- message: response.statusText,
159
- };
343
+ errorBody = normalizeApiError(undefined, response.status, response.statusText);
160
344
  }
161
- throw new FrihetApiError(response.status, errorBody.error, errorBody.message ?? errorBody.error);
345
+ clearTimeout(timeoutId);
346
+ throw new FrihetApiError(response.status, errorBody.error, errorBody.message ?? errorBody.error, typeof errorBody.detail === "string" ? errorBody.detail : undefined);
162
347
  }
163
348
  logApiCall(method, path, response.status, durationMs);
164
349
  // 204 No Content (e.g. DELETE)
165
350
  if (response.status === 204) {
351
+ clearTimeout(timeoutId);
166
352
  return undefined;
167
353
  }
168
- const data = await response.json();
354
+ let data;
355
+ try {
356
+ // Keep the request AbortSignal live through body consumption. A server
357
+ // can send 2xx headers after committing a write and then stall or truncate
358
+ // the JSON body; clearing the timer at headers would let this hang forever
359
+ // and make a committed mutation look safely retryable.
360
+ data = await response.json();
361
+ }
362
+ catch (error) {
363
+ clearTimeout(timeoutId);
364
+ const timedOut = error instanceof Error && error.name === "AbortError";
365
+ throw new FrihetApiError(timedOut ? 408 : response.status, timedOut ? "response_timeout" : "invalid_response_after_success", timedOut
366
+ ? `Response body timed out after ${this.timeoutMs / 1000} seconds`
367
+ : "Frihet returned an unreadable success response after processing the request.");
368
+ }
369
+ clearTimeout(timeoutId);
169
370
  // Basic response validation
170
371
  if (data === null || data === undefined) {
171
- throw new FrihetApiError(response.status, 'invalid_response', 'API returned empty response');
372
+ throw new FrihetApiError(response.status, "invalid_response_after_success", "Frihet returned an empty success response after processing the request.");
172
373
  }
173
374
  return data;
174
375
  }
376
+ /** Fetch a raw response. The caller owns timeout and body consumption. */
377
+ async fetchRaw(method, path, body, query, idempotencyKey, signal) {
378
+ const resolvedIdempotencyKey = normalizeIdempotencyKey(idempotencyKey) ??
379
+ (MUTATING_METHODS.has(method) ? newIdempotencyKey() : undefined);
380
+ const url = new URL(`${this.baseUrl}${path}`);
381
+ if (query) {
382
+ for (const [key, value] of Object.entries(query)) {
383
+ if (value !== undefined)
384
+ url.searchParams.set(key, String(value));
385
+ }
386
+ }
387
+ const headers = {
388
+ "X-API-Key": this.apiKey,
389
+ "Content-Type": "application/json",
390
+ // CRITICAL: do NOT set `Accept: application/json` here. The PDF / XML
391
+ // endpoints must be allowed to return their native content type;
392
+ // forcing `Accept: application/json` would make the server negotiate
393
+ // an error envelope instead of the document bytes.
394
+ Accept: "*/*",
395
+ "X-Frihet-Source": SOURCE_MARKER,
396
+ "User-Agent": SOURCE_USER_AGENT,
397
+ };
398
+ if (this.oauthServiceSecret) {
399
+ headers["x-frihet-oauth-key"] = this.oauthServiceSecret;
400
+ }
401
+ if (resolvedIdempotencyKey) {
402
+ headers["Idempotency-Key"] = resolvedIdempotencyKey;
403
+ }
404
+ return fetch(url.toString(), {
405
+ method,
406
+ headers,
407
+ body: body ? JSON.stringify(body) : undefined,
408
+ signal,
409
+ redirect: "error",
410
+ });
411
+ }
412
+ /** Consume one response body without ever retaining more than `maxBytes`. */
413
+ async readBoundedBody(response, maxBytes, controller) {
414
+ const declaredLengthRaw = response.headers.get("content-length")?.trim();
415
+ const declaredLength = declaredLengthRaw && /^\d+$/.test(declaredLengthRaw)
416
+ ? Number(declaredLengthRaw)
417
+ : Number.NaN;
418
+ if (Number.isSafeInteger(declaredLength) && declaredLength > maxBytes) {
419
+ controller.abort();
420
+ try {
421
+ await response.body?.cancel();
422
+ }
423
+ catch { /* best effort */ }
424
+ throw new FrihetApiError(413, "payload_too_large", `Document response exceeds ${maxBytes} bytes (Content-Length: ${declaredLength})`);
425
+ }
426
+ if (!response.body) {
427
+ throw new FrihetApiError(response.status, "invalid_response", "Response body is null");
428
+ }
429
+ const reader = response.body.getReader();
430
+ const chunks = [];
431
+ let totalBytes = 0;
432
+ try {
433
+ while (true) {
434
+ const { value, done } = await reader.read();
435
+ if (done)
436
+ break;
437
+ if (!value)
438
+ continue;
439
+ totalBytes += value.byteLength;
440
+ if (totalBytes > maxBytes) {
441
+ controller.abort();
442
+ try {
443
+ await reader.cancel("document response exceeded size limit");
444
+ }
445
+ catch { /* best effort */ }
446
+ throw new FrihetApiError(413, "payload_too_large", `Document response exceeds ${maxBytes} bytes during streaming`);
447
+ }
448
+ chunks.push(value);
449
+ }
450
+ }
451
+ catch (error) {
452
+ if (error instanceof FrihetApiError || isAbortError(error))
453
+ throw error;
454
+ try {
455
+ await reader.cancel("document stream failed");
456
+ }
457
+ catch { /* best effort */ }
458
+ controller.abort();
459
+ throw new FrihetApiError(response.status, "stream_failed", `Failed while reading document response: ${error instanceof Error ? error.message : String(error)}`);
460
+ }
461
+ finally {
462
+ reader.releaseLock();
463
+ }
464
+ const flat = new Uint8Array(totalBytes);
465
+ let offset = 0;
466
+ for (const chunk of chunks) {
467
+ flat.set(chunk, offset);
468
+ offset += chunk.byteLength;
469
+ }
470
+ return flat;
471
+ }
472
+ /**
473
+ * Bounded document fetch — ONE call, ONE Response, dispatch on Content-Type.
474
+ *
475
+ * Success and error bodies use the same bounded reader. The abort timer stays
476
+ * active until the body is complete, and a normal success performs one GET.
477
+ * A 429 may retry, matching the generic JSON client path.
478
+ */
479
+ async requestDocument(method, path, body, query, idempotencyKey, maxBytesForContentType, retryCount = 0) {
480
+ const controller = new AbortController();
481
+ const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
482
+ const startedAt = Date.now();
483
+ try {
484
+ const response = await this.fetchRaw(method, path, body, query, idempotencyKey, controller.signal);
485
+ const contentType = response.headers.get("content-type") ?? "application/octet-stream";
486
+ const maxBytes = response.ok ? maxBytesForContentType(contentType) : MAX_ERROR_BYTES;
487
+ const bytes = await this.readBoundedBody(response, maxBytes, controller);
488
+ const durationMs = Math.round(Date.now() - startedAt);
489
+ if (response.status === 429) {
490
+ logApiCall(method, path, 429, durationMs);
491
+ if (retryCount >= MAX_RETRIES) {
492
+ throw new FrihetApiError(429, "rate_limit_exceeded", "Rate limit exceeded after multiple retries. Please try again later.");
493
+ }
494
+ const retryAfter = response.headers.get("Retry-After");
495
+ const parsedRetryAfter = retryAfter ? Number.parseInt(retryAfter, 10) : Number.NaN;
496
+ const delayMs = Number.isFinite(parsedRetryAfter)
497
+ ? parsedRetryAfter * 1000
498
+ : DEFAULT_RETRY_DELAY_MS * Math.pow(2, retryCount);
499
+ logRetry(method, path, retryCount, delayMs);
500
+ clearTimeout(timeoutId);
501
+ await this.sleep(delayMs);
502
+ return this.requestDocument(method, path, body, query, idempotencyKey, maxBytesForContentType, retryCount + 1);
503
+ }
504
+ if (!response.ok) {
505
+ logApiCall(method, path, response.status, durationMs);
506
+ let errorBody = normalizeApiError(undefined, response.status, response.statusText);
507
+ try {
508
+ errorBody = normalizeApiError(JSON.parse(decodeUtf8(bytes, "API error response")), response.status, response.statusText);
509
+ }
510
+ catch (error) {
511
+ if (error instanceof FrihetApiError && error.errorCode !== "invalid_response")
512
+ throw error;
513
+ }
514
+ throw new FrihetApiError(response.status, errorBody.error, errorBody.message ?? errorBody.error, typeof errorBody.detail === "string" ? errorBody.detail : undefined);
515
+ }
516
+ logApiCall(method, path, response.status, durationMs);
517
+ const filename = attachmentFilename(response.headers.get("content-disposition"));
518
+ return {
519
+ contentType,
520
+ bytes,
521
+ sizeBytes: bytes.byteLength,
522
+ ...(filename ? { filename } : {}),
523
+ };
524
+ }
525
+ catch (error) {
526
+ if (isAbortError(error)) {
527
+ const durationMs = Math.round(Date.now() - startedAt);
528
+ logApiCall(method, path, 408, durationMs);
529
+ throw new FrihetApiError(408, "request_timeout", `Request timed out after ${this.timeoutMs / 1000} seconds`);
530
+ }
531
+ if (error instanceof FrihetApiError)
532
+ throw error;
533
+ const durationMs = Math.round(Date.now() - startedAt);
534
+ logApiCall(method, path, 0, durationMs);
535
+ throw new FrihetApiError(503, "network_error", "The network request failed before Frihet could confirm its outcome.");
536
+ }
537
+ finally {
538
+ clearTimeout(timeoutId);
539
+ }
540
+ }
175
541
  sleep(ms) {
176
542
  return new Promise((resolve) => setTimeout(resolve, ms));
177
543
  }
@@ -190,10 +556,15 @@ export class FrihetClient {
190
556
  * output schema (id/clientName/items/success all read as `undefined`). This
191
557
  * unwraps to `body.data`. Every create/update/action mutation routes through
192
558
  * here for exactly this reason; single-object reads (`getInvoice`/…) have since
193
- * #64. `deleteX` methods deliberately keep `request` (they return `void` — the
194
- * tool discards the body and synthesizes `{ success, id }`, so there is nothing
195
- * to unwrap). Only unwraps a non-array-object `data` (see guard below), so an
559
+ * #64. Most `deleteX` methods keep `request` (they return `void` — the tool
560
+ * discards the body and synthesizes `{ success, id }`, so there is nothing to
561
+ * unwrap). `deleteInvoice`/`deleteQuote` are the EXCEPTION and route through
562
+ * here: their backend answers 204 for a real delete but 200 + `{ data, meta }`
563
+ * when it soft-CANCELS a non-draft document instead (see those methods).
564
+ * Only unwraps a non-array-object `data` (see guard below), so an
196
565
  * array-`data` list envelope and any non-enveloped body pass through unchanged.
566
+ * A 204 yields `undefined` from `request`, which falls through untouched — that
567
+ * `undefined` is what tells the delete tools "destroyed, not cancelled".
197
568
  *
198
569
  * Only unwraps when the body is an object carrying a `data` property that is
199
570
  * itself a (non-array) object — i.e. a genuine single-object envelope. It is
@@ -252,8 +623,22 @@ export class FrihetClient {
252
623
  async updateInvoice(id, data) {
253
624
  return this.requestUnwrapped("PATCH", `/invoices/${encodeURIComponent(id)}`, data);
254
625
  }
626
+ /**
627
+ * DELETE /invoices/{id} — NOT an unconditional destroy.
628
+ *
629
+ * The backend refuses to destroy a non-draft invoice (VeriFactu hash-chain
630
+ * integrity) and soft-CANCELS it instead, and it distinguishes the two
631
+ * outcomes on the wire (`publicApi.ts` DELETE branch):
632
+ * - 204 No Content → the draft row was really removed
633
+ * - 200 + `{ data: { id, status: 'cancelled', previousStatus,
634
+ * cancelledVia }, meta }` → the document still exists, now cancelled
635
+ *
636
+ * This used to be typed `Promise<void>` and the body was thrown away, so the
637
+ * tool reported "deleted successfully" for a document that was still there.
638
+ * Resolves to the unwrapped soft-cancel payload on 200, `undefined` on 204.
639
+ */
255
640
  async deleteInvoice(id) {
256
- return this.request("DELETE", `/invoices/${encodeURIComponent(id)}`);
641
+ return this.requestUnwrapped("DELETE", `/invoices/${encodeURIComponent(id)}`);
257
642
  }
258
643
  async searchInvoices(query, params) {
259
644
  return this.requestPaginated("GET", "/invoices", undefined, {
@@ -326,7 +711,9 @@ export class FrihetClient {
326
711
  cursor: params?.after,
327
712
  fields: params?.fields,
328
713
  q: params?.q,
329
- isActive: params?.isActive !== undefined ? (params.isActive ? 1 : 0) : undefined,
714
+ // Public API accepts the literal strings "true"/"false". Numeric 1/0
715
+ // makes `isActive=true` evaluate as false in the backend parser.
716
+ isActive: params?.isActive !== undefined ? String(params.isActive) : undefined,
330
717
  });
331
718
  }
332
719
  async getProduct(id) {
@@ -365,8 +752,14 @@ export class FrihetClient {
365
752
  async updateQuote(id, data) {
366
753
  return this.requestUnwrapped("PATCH", `/quotes/${encodeURIComponent(id)}`, data);
367
754
  }
755
+ /**
756
+ * DELETE /quotes/{id} — same two-outcome contract as {@link deleteInvoice}:
757
+ * the backend soft-CANCELS a non-draft quote (200 + body), destroys only a
758
+ * clean draft with no delivery/response/attachment/conversion evidence (204),
759
+ * and refuses a protected draft with 409.
760
+ */
368
761
  async deleteQuote(id) {
369
- return this.request("DELETE", `/quotes/${encodeURIComponent(id)}`);
762
+ return this.requestUnwrapped("DELETE", `/quotes/${encodeURIComponent(id)}`);
370
763
  }
371
764
  // ---------------------------------------------------------------- Vendors
372
765
  // ----------------------------------------------------------------
@@ -394,21 +787,72 @@ export class FrihetClient {
394
787
  // ---------------------------------------------------------------- Invoice Actions
395
788
  // ----------------------------------------------------------------
396
789
  async sendInvoice(id, to) {
397
- return this.requestUnwrapped("POST", `/invoices/${encodeURIComponent(id)}/send`, to ? { to } : undefined);
790
+ return this.requestUnwrapped("POST", `/invoices/${encodeURIComponent(id)}/send`, to ? { recipientEmail: to } : undefined);
398
791
  }
399
792
  async markInvoicePaid(id, paidDate) {
400
793
  return this.requestUnwrapped("POST", `/invoices/${encodeURIComponent(id)}/paid`, paidDate ? { paidDate } : undefined);
401
794
  }
402
795
  async getInvoicePdf(id) {
403
- // get_invoice_pdf's outputSchema (pdfResultOutput) expects the flat
404
- // { id, url?, contentType? } item, same as every other single-object read.
405
- return this.requestUnwrapped("GET", `/invoices/${encodeURIComponent(id)}/pdf`);
796
+ // One fetch, one Response: validate MIME and signature only after the
797
+ // bounded stream completes. The ERP endpoint has always returned bytes;
798
+ // there is no pre-signed-URL JSON success contract to emulate.
799
+ const url = `/invoices/${encodeURIComponent(id)}/pdf`;
800
+ const doc = await this.requestDocument("GET", url, undefined, undefined, undefined, (contentType) => normalizedContentType(contentType) === "application/pdf"
801
+ ? MAX_PDF_BYTES
802
+ : MAX_ERROR_BYTES);
803
+ if (normalizedContentType(doc.contentType) !== "application/pdf") {
804
+ throw new FrihetApiError(200, "invalid_response", `Invoice PDF endpoint returned unexpected Content-Type: ${doc.contentType}`);
805
+ }
806
+ if (!hasPdfSignature(doc.bytes)) {
807
+ throw new FrihetApiError(200, "invalid_response", "Invoice PDF response is malformed");
808
+ }
809
+ return {
810
+ id,
811
+ contentType: doc.contentType,
812
+ sizeBytes: doc.sizeBytes,
813
+ base64: encodeBase64(doc.bytes),
814
+ ...(doc.filename ? { filename: doc.filename } : {}),
815
+ };
406
816
  }
407
817
  async getInvoiceEInvoice(invoiceId) {
408
- // get_invoice_einvoice's outputSchema expects the flat { xml, filename, format }
409
- // item. requestUnwrapped is a no-op if the body isn't an object envelope
410
- // (e.g. a bare XML string), so this is safe either way.
411
- return this.requestUnwrapped("GET", `/invoices/${encodeURIComponent(invoiceId)}/xml`);
818
+ // Stored e-invoices are either XML or Factur-X PDF. Select the size cap
819
+ // from the actual MIME before reading and preserve the request identity.
820
+ const url = `/invoices/${encodeURIComponent(invoiceId)}/xml`;
821
+ const doc = await this.requestDocument("GET", url, undefined, undefined, undefined, (contentType) => {
822
+ const normalized = normalizedContentType(contentType);
823
+ if (normalized === "application/pdf")
824
+ return MAX_PDF_BYTES;
825
+ if (isXmlContentType(contentType))
826
+ return MAX_XML_BYTES;
827
+ return MAX_ERROR_BYTES;
828
+ });
829
+ const normalized = normalizedContentType(doc.contentType);
830
+ if (normalized === "application/pdf") {
831
+ if (!hasPdfSignature(doc.bytes)) {
832
+ throw new FrihetApiError(200, "invalid_response", "Factur-X PDF response is malformed");
833
+ }
834
+ return {
835
+ id: invoiceId,
836
+ contentType: doc.contentType,
837
+ sizeBytes: doc.sizeBytes,
838
+ base64: encodeBase64(doc.bytes),
839
+ ...(doc.filename ? { filename: doc.filename } : {}),
840
+ };
841
+ }
842
+ if (!isXmlContentType(doc.contentType)) {
843
+ throw new FrihetApiError(200, "invalid_response", `E-invoice endpoint returned unexpected Content-Type: ${doc.contentType}`);
844
+ }
845
+ const xmlText = decodeUtf8(doc.bytes, "E-invoice XML response");
846
+ if (!xmlText.trimStart().startsWith("<")) {
847
+ throw new FrihetApiError(200, "invalid_response", "E-invoice XML response is malformed");
848
+ }
849
+ return {
850
+ id: invoiceId,
851
+ contentType: doc.contentType,
852
+ xml: xmlText,
853
+ sizeBytes: doc.sizeBytes,
854
+ ...(doc.filename ? { filename: doc.filename } : {}),
855
+ };
412
856
  }
413
857
  /**
414
858
  * `POST /v1/invoices/:id/credit-note` — creates a rectificativa DRAFT.
@@ -431,24 +875,38 @@ export class FrihetClient {
431
875
  // ---------------------------------------------------------------- Quote Actions
432
876
  // ----------------------------------------------------------------
433
877
  async sendQuote(id, to) {
434
- return this.requestUnwrapped("POST", `/quotes/${encodeURIComponent(id)}/send`, to ? { to } : undefined);
878
+ return this.requestUnwrapped("POST", `/quotes/${encodeURIComponent(id)}/send`, to ? { recipientEmail: to } : undefined);
435
879
  }
436
880
  // ---------------------------------------------------------------- Webhooks
437
881
  // ----------------------------------------------------------------
438
- async listWebhooks(params) {
439
- return this.requestPaginated("GET", "/webhooks", undefined, {
440
- limit: params?.limit,
441
- offset: params?.offset,
442
- });
882
+ async listWebhooks() {
883
+ const result = await this.request("GET", "/webhooks");
884
+ if (result === null ||
885
+ typeof result !== "object" ||
886
+ !("data" in result) ||
887
+ !("total" in result) ||
888
+ !Array.isArray(result.data) ||
889
+ typeof result.total !== "number") {
890
+ throw new FrihetApiError(200, "invalid_response", "API returned invalid webhook list response");
891
+ }
892
+ // `/webhooks` is deliberately unpaginated. Keep its `{ data, total }`
893
+ // contract and discard only the transport-level `meta` sibling.
894
+ return {
895
+ data: result.data.map((webhook) => normalizeWebhookRecord(webhook)),
896
+ total: result.total,
897
+ };
443
898
  }
444
899
  async getWebhook(id) {
445
- return this.requestUnwrapped("GET", `/webhooks/${encodeURIComponent(id)}`);
900
+ const result = await this.requestUnwrapped("GET", `/webhooks/${encodeURIComponent(id)}`);
901
+ return normalizeWebhookRecord(result);
446
902
  }
447
903
  async createWebhook(data) {
448
- return this.requestUnwrapped("POST", "/webhooks", data);
904
+ const result = await this.requestUnwrapped("POST", "/webhooks", data);
905
+ return normalizeWebhookRecord(result, typeof data.secret === "string" ? data.secret : undefined);
449
906
  }
450
907
  async updateWebhook(id, data) {
451
- return this.requestUnwrapped("PATCH", `/webhooks/${encodeURIComponent(id)}`, data);
908
+ const result = await this.requestUnwrapped("PATCH", `/webhooks/${encodeURIComponent(id)}`, data);
909
+ return normalizeWebhookRecord(result);
452
910
  }
453
911
  async deleteWebhook(id) {
454
912
  return this.request("DELETE", `/webhooks/${encodeURIComponent(id)}`);
@@ -663,10 +1121,10 @@ export class FrihetClient {
663
1121
  // ---------------------------------------------------------------- Intelligence
664
1122
  // ----------------------------------------------------------------
665
1123
  async getBusinessContext() {
666
- return this.request("GET", "/context");
1124
+ return this.requestUnwrapped("GET", "/context");
667
1125
  }
668
1126
  async getMonthlySummary(month) {
669
- return this.request("GET", "/monthly", undefined, {
1127
+ return this.requestUnwrapped("GET", "/monthly", undefined, {
670
1128
  month,
671
1129
  });
672
1130
  }
@@ -908,7 +1366,8 @@ export class FrihetClient {
908
1366
  return this.requestUnwrapped("POST", "/banking/rules", data);
909
1367
  }
910
1368
  // ---------------------------------------------------------------- HR (Leaves + Attendance + Anomalies)
911
- // NOTE: /v1/leaves, /v1/time-entries, /v1/anomalies — D4-A parallel deploy. 404 propagates until backend ships.
1369
+ // The ERP routes are live. Single-object family reports use the standard
1370
+ // `{ data, meta }` response envelope and must be unwrapped exactly once.
912
1371
  async listLeaves(params) {
913
1372
  return this.requestPaginated("GET", "/leaves", undefined, {
914
1373
  employeeId: params?.employeeId,
@@ -939,7 +1398,7 @@ export class FrihetClient {
939
1398
  return this.requestUnwrapped("PATCH", `/time-entries/${encodeURIComponent(entryId)}/clock-out`, {});
940
1399
  }
941
1400
  async getOvertimeReport(params) {
942
- return this.request("GET", "/time-entries/overtime", undefined, {
1401
+ return this.requestUnwrapped("GET", "/time-entries/overtime", undefined, {
943
1402
  period: params.period,
944
1403
  employeeId: params.employeeId,
945
1404
  });
@@ -960,15 +1419,16 @@ export class FrihetClient {
960
1419
  return this.requestUnwrapped("POST", `/webhooks/${encodeURIComponent(id)}/test`, data ?? {});
961
1420
  }
962
1421
  // ---------------------------------------------------------------- Payroll
963
- // NOTE: /v1/payroll/prep/* — D4-A parallel deploy. 404 propagates until backend ships.
1422
+ // These live read routes return normalized payroll-preparation records in
1423
+ // the standard single-object `{ data, meta }` envelope.
964
1424
  async exportPayroll(params) {
965
- return this.request("GET", "/payroll/prep/export", undefined, {
1425
+ return this.requestUnwrapped("GET", "/payroll/prep/export", undefined, {
966
1426
  format: params.format,
967
1427
  month: params.month,
968
1428
  });
969
1429
  }
970
1430
  async getPayrollChecklist(params) {
971
- return this.request("GET", "/payroll/prep/employees", undefined, {
1431
+ return this.requestUnwrapped("GET", "/payroll/prep/employees", undefined, {
972
1432
  month: params.month,
973
1433
  });
974
1434
  }
@@ -981,20 +1441,22 @@ export class FrihetClient {
981
1441
  return this.requestUnwrapped("PATCH", "/onboarding/persona", data);
982
1442
  }
983
1443
  // ---------------------------------------------------------------- Permissions
984
- // NOTE: /v1/permissions/* — D4-A parallel deploy. 404 propagates until backend ships.
1444
+ // These are standard ERP family responses: unwrap their single-object
1445
+ // `{ data, meta }` envelope before handing the payload to MCP output schemas.
985
1446
  async getPermissionsMatrix() {
986
- return this.request("GET", "/permissions/matrix");
1447
+ return this.requestUnwrapped("GET", "/permissions/matrix");
987
1448
  }
988
1449
  async getMyPermissions() {
989
- return this.request("GET", "/permissions/me");
1450
+ return this.requestUnwrapped("GET", "/permissions/me");
990
1451
  }
991
1452
  // ---------------------------------------------------------------- Period Close
992
- // NOTE: /v1/periods/* — D4-A parallel deploy. 404 propagates until backend ships.
1453
+ // Period reads are live and enveloped. Fiscal close/reopen writes remain
1454
+ // honest ERP 501 responses and are intentionally unchanged here.
993
1455
  async getCurrentPeriod(params) {
994
- if (params?.periodId) {
995
- return this.request("GET", `/periods/${encodeURIComponent(params.periodId)}`);
1456
+ if (params?.fiscalYear) {
1457
+ return this.requestUnwrapped("GET", `/periods/${encodeURIComponent(params.fiscalYear)}`);
996
1458
  }
997
- return this.request("GET", "/periods/current");
1459
+ return this.requestUnwrapped("GET", "/periods/current");
998
1460
  }
999
1461
  async closePeriod(data) {
1000
1462
  return this.requestUnwrapped("POST", "/periods/close", data);