@frihet/mcp-server 1.16.6 → 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 +488 -56
  13. package/dist/demo-client.d.ts +16 -13
  14. package/dist/demo-client.js +171 -17
  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 +25 -21
  25. package/dist/openai-profile.js +1166 -156
  26. package/dist/openai-review-contract.d.ts +15 -6
  27. package/dist/openai-review-contract.js +715 -41
  28. package/dist/openai-review-oauth.d.ts +47 -0
  29. package/dist/openai-review-oauth.js +73 -3
  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 +2 -2
  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 +30 -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`
@@ -55,12 +78,11 @@ const SOURCE_USER_AGENT = "frihet-mcp-server";
55
78
  /**
56
79
  * Fresh idempotency key, always a syntactically valid UUID v4.
57
80
  *
58
- * `crypto.randomUUID` is a global on the Cloudflare Workers runtime and on
59
- * Node >= 19. On Node 18 — our declared `engines` floor — `globalThis.crypto`
60
- * is behind `--experimental-global-webcrypto`, so the fallback is a REAL code
61
- * path there, not a theoretical one. It therefore has to produce a UUID and
62
- * not an ad-hoc string: the backend documents "UUID v4 recommended", and
63
- * 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.
64
86
  */
65
87
  function newIdempotencyKey() {
66
88
  const c = globalThis.crypto;
@@ -86,27 +108,159 @@ function normalizeIdempotencyKey(key) {
86
108
  const trimmed = key?.trim();
87
109
  return trimmed ? trimmed : undefined;
88
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
+ }
89
171
  export class FrihetApiError extends Error {
90
172
  statusCode;
91
173
  errorCode;
92
- constructor(statusCode, errorCode, message) {
93
- super(message ?? errorCode);
174
+ detail;
175
+ constructor(statusCode, errorCode, message, detail) {
176
+ super(statusCode === 403
177
+ ? sanitizeServerRemediation(message, errorCode) ?? errorCode
178
+ : message ?? errorCode);
94
179
  this.statusCode = statusCode;
95
180
  this.errorCode = errorCode;
181
+ this.detail = detail;
96
182
  this.name = "FrihetApiError";
97
183
  }
98
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
+ }
99
237
  export class FrihetClient {
100
238
  apiKey;
101
239
  baseUrl;
102
240
  timeoutMs;
241
+ oauthServiceSecret;
103
242
  constructor(apiKey, baseUrl, options) {
104
243
  if (!apiKey) {
105
244
  throw new Error("FRIHET_API_KEY is required. Set it as an environment variable or pass it to the constructor.");
106
245
  }
246
+ const resolvedBaseUrl = baseUrl ?? BASE_URL;
107
247
  this.apiKey = apiKey;
108
- this.baseUrl = baseUrl ?? BASE_URL;
248
+ this.baseUrl = resolvedBaseUrl;
109
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
+ }
110
264
  }
111
265
  // ------------------------------------------------------------------ HTTP
112
266
  // ------------------------------------------------------------------
@@ -133,6 +287,9 @@ export class FrihetClient {
133
287
  "X-Frihet-Source": SOURCE_MARKER,
134
288
  "User-Agent": SOURCE_USER_AGENT,
135
289
  };
290
+ if (this.oauthServiceSecret) {
291
+ headers["x-frihet-oauth-key"] = this.oauthServiceSecret;
292
+ }
136
293
  if (resolvedIdempotencyKey) {
137
294
  headers["Idempotency-Key"] = resolvedIdempotencyKey;
138
295
  }
@@ -146,23 +303,23 @@ export class FrihetClient {
146
303
  headers,
147
304
  body: body ? JSON.stringify(body) : undefined,
148
305
  signal: controller.signal,
306
+ redirect: "error",
149
307
  });
150
308
  }
151
309
  catch (error) {
310
+ clearTimeout(timeoutId);
152
311
  const durationMs = Math.round(Date.now() - startTime);
153
312
  if (error instanceof Error && error.name === "AbortError") {
154
313
  logApiCall(method, path, 408, durationMs);
155
314
  throw new FrihetApiError(408, "request_timeout", `Request timed out after ${this.timeoutMs / 1000} seconds`);
156
315
  }
157
316
  logApiCall(method, path, 0, durationMs);
158
- throw error;
159
- }
160
- finally {
161
- clearTimeout(timeoutId);
317
+ throw new FrihetApiError(503, "network_error", "The network request failed before Frihet could confirm its outcome.");
162
318
  }
163
319
  const durationMs = Math.round(Date.now() - startTime);
164
320
  // Rate limit handling
165
321
  if (response.status === 429) {
322
+ clearTimeout(timeoutId);
166
323
  logApiCall(method, path, 429, durationMs);
167
324
  if (retryCount >= MAX_RETRIES) {
168
325
  throw new FrihetApiError(429, "rate_limit_exceeded", "Rate limit exceeded after multiple retries. Please try again later.");
@@ -180,28 +337,207 @@ export class FrihetClient {
180
337
  logApiCall(method, path, response.status, durationMs);
181
338
  let errorBody;
182
339
  try {
183
- errorBody = (await response.json());
340
+ errorBody = normalizeApiError(await response.json(), response.status, response.statusText);
184
341
  }
185
342
  catch {
186
- errorBody = {
187
- error: `http_${response.status}`,
188
- message: response.statusText,
189
- };
343
+ errorBody = normalizeApiError(undefined, response.status, response.statusText);
190
344
  }
191
- 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);
192
347
  }
193
348
  logApiCall(method, path, response.status, durationMs);
194
349
  // 204 No Content (e.g. DELETE)
195
350
  if (response.status === 204) {
351
+ clearTimeout(timeoutId);
196
352
  return undefined;
197
353
  }
198
- 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);
199
370
  // Basic response validation
200
371
  if (data === null || data === undefined) {
201
- 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.");
202
373
  }
203
374
  return data;
204
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
+ }
205
541
  sleep(ms) {
206
542
  return new Promise((resolve) => setTimeout(resolve, ms));
207
543
  }
@@ -220,10 +556,15 @@ export class FrihetClient {
220
556
  * output schema (id/clientName/items/success all read as `undefined`). This
221
557
  * unwraps to `body.data`. Every create/update/action mutation routes through
222
558
  * here for exactly this reason; single-object reads (`getInvoice`/…) have since
223
- * #64. `deleteX` methods deliberately keep `request` (they return `void` — the
224
- * tool discards the body and synthesizes `{ success, id }`, so there is nothing
225
- * 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
226
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".
227
568
  *
228
569
  * Only unwraps when the body is an object carrying a `data` property that is
229
570
  * itself a (non-array) object — i.e. a genuine single-object envelope. It is
@@ -282,8 +623,22 @@ export class FrihetClient {
282
623
  async updateInvoice(id, data) {
283
624
  return this.requestUnwrapped("PATCH", `/invoices/${encodeURIComponent(id)}`, data);
284
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
+ */
285
640
  async deleteInvoice(id) {
286
- return this.request("DELETE", `/invoices/${encodeURIComponent(id)}`);
641
+ return this.requestUnwrapped("DELETE", `/invoices/${encodeURIComponent(id)}`);
287
642
  }
288
643
  async searchInvoices(query, params) {
289
644
  return this.requestPaginated("GET", "/invoices", undefined, {
@@ -356,7 +711,9 @@ export class FrihetClient {
356
711
  cursor: params?.after,
357
712
  fields: params?.fields,
358
713
  q: params?.q,
359
- 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,
360
717
  });
361
718
  }
362
719
  async getProduct(id) {
@@ -395,8 +752,14 @@ export class FrihetClient {
395
752
  async updateQuote(id, data) {
396
753
  return this.requestUnwrapped("PATCH", `/quotes/${encodeURIComponent(id)}`, data);
397
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
+ */
398
761
  async deleteQuote(id) {
399
- return this.request("DELETE", `/quotes/${encodeURIComponent(id)}`);
762
+ return this.requestUnwrapped("DELETE", `/quotes/${encodeURIComponent(id)}`);
400
763
  }
401
764
  // ---------------------------------------------------------------- Vendors
402
765
  // ----------------------------------------------------------------
@@ -424,21 +787,72 @@ export class FrihetClient {
424
787
  // ---------------------------------------------------------------- Invoice Actions
425
788
  // ----------------------------------------------------------------
426
789
  async sendInvoice(id, to) {
427
- return this.requestUnwrapped("POST", `/invoices/${encodeURIComponent(id)}/send`, to ? { to } : undefined);
790
+ return this.requestUnwrapped("POST", `/invoices/${encodeURIComponent(id)}/send`, to ? { recipientEmail: to } : undefined);
428
791
  }
429
792
  async markInvoicePaid(id, paidDate) {
430
793
  return this.requestUnwrapped("POST", `/invoices/${encodeURIComponent(id)}/paid`, paidDate ? { paidDate } : undefined);
431
794
  }
432
795
  async getInvoicePdf(id) {
433
- // get_invoice_pdf's outputSchema (pdfResultOutput) expects the flat
434
- // { id, url?, contentType? } item, same as every other single-object read.
435
- 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
+ };
436
816
  }
437
817
  async getInvoiceEInvoice(invoiceId) {
438
- // get_invoice_einvoice's outputSchema expects the flat { xml, filename, format }
439
- // item. requestUnwrapped is a no-op if the body isn't an object envelope
440
- // (e.g. a bare XML string), so this is safe either way.
441
- 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
+ };
442
856
  }
443
857
  /**
444
858
  * `POST /v1/invoices/:id/credit-note` — creates a rectificativa DRAFT.
@@ -461,24 +875,38 @@ export class FrihetClient {
461
875
  // ---------------------------------------------------------------- Quote Actions
462
876
  // ----------------------------------------------------------------
463
877
  async sendQuote(id, to) {
464
- return this.requestUnwrapped("POST", `/quotes/${encodeURIComponent(id)}/send`, to ? { to } : undefined);
878
+ return this.requestUnwrapped("POST", `/quotes/${encodeURIComponent(id)}/send`, to ? { recipientEmail: to } : undefined);
465
879
  }
466
880
  // ---------------------------------------------------------------- Webhooks
467
881
  // ----------------------------------------------------------------
468
- async listWebhooks(params) {
469
- return this.requestPaginated("GET", "/webhooks", undefined, {
470
- limit: params?.limit,
471
- offset: params?.offset,
472
- });
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
+ };
473
898
  }
474
899
  async getWebhook(id) {
475
- return this.requestUnwrapped("GET", `/webhooks/${encodeURIComponent(id)}`);
900
+ const result = await this.requestUnwrapped("GET", `/webhooks/${encodeURIComponent(id)}`);
901
+ return normalizeWebhookRecord(result);
476
902
  }
477
903
  async createWebhook(data) {
478
- 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);
479
906
  }
480
907
  async updateWebhook(id, data) {
481
- return this.requestUnwrapped("PATCH", `/webhooks/${encodeURIComponent(id)}`, data);
908
+ const result = await this.requestUnwrapped("PATCH", `/webhooks/${encodeURIComponent(id)}`, data);
909
+ return normalizeWebhookRecord(result);
482
910
  }
483
911
  async deleteWebhook(id) {
484
912
  return this.request("DELETE", `/webhooks/${encodeURIComponent(id)}`);
@@ -938,7 +1366,8 @@ export class FrihetClient {
938
1366
  return this.requestUnwrapped("POST", "/banking/rules", data);
939
1367
  }
940
1368
  // ---------------------------------------------------------------- HR (Leaves + Attendance + Anomalies)
941
- // 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.
942
1371
  async listLeaves(params) {
943
1372
  return this.requestPaginated("GET", "/leaves", undefined, {
944
1373
  employeeId: params?.employeeId,
@@ -969,7 +1398,7 @@ export class FrihetClient {
969
1398
  return this.requestUnwrapped("PATCH", `/time-entries/${encodeURIComponent(entryId)}/clock-out`, {});
970
1399
  }
971
1400
  async getOvertimeReport(params) {
972
- return this.request("GET", "/time-entries/overtime", undefined, {
1401
+ return this.requestUnwrapped("GET", "/time-entries/overtime", undefined, {
973
1402
  period: params.period,
974
1403
  employeeId: params.employeeId,
975
1404
  });
@@ -990,15 +1419,16 @@ export class FrihetClient {
990
1419
  return this.requestUnwrapped("POST", `/webhooks/${encodeURIComponent(id)}/test`, data ?? {});
991
1420
  }
992
1421
  // ---------------------------------------------------------------- Payroll
993
- // 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.
994
1424
  async exportPayroll(params) {
995
- return this.request("GET", "/payroll/prep/export", undefined, {
1425
+ return this.requestUnwrapped("GET", "/payroll/prep/export", undefined, {
996
1426
  format: params.format,
997
1427
  month: params.month,
998
1428
  });
999
1429
  }
1000
1430
  async getPayrollChecklist(params) {
1001
- return this.request("GET", "/payroll/prep/employees", undefined, {
1431
+ return this.requestUnwrapped("GET", "/payroll/prep/employees", undefined, {
1002
1432
  month: params.month,
1003
1433
  });
1004
1434
  }
@@ -1011,20 +1441,22 @@ export class FrihetClient {
1011
1441
  return this.requestUnwrapped("PATCH", "/onboarding/persona", data);
1012
1442
  }
1013
1443
  // ---------------------------------------------------------------- Permissions
1014
- // 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.
1015
1446
  async getPermissionsMatrix() {
1016
- return this.request("GET", "/permissions/matrix");
1447
+ return this.requestUnwrapped("GET", "/permissions/matrix");
1017
1448
  }
1018
1449
  async getMyPermissions() {
1019
- return this.request("GET", "/permissions/me");
1450
+ return this.requestUnwrapped("GET", "/permissions/me");
1020
1451
  }
1021
1452
  // ---------------------------------------------------------------- Period Close
1022
- // 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.
1023
1455
  async getCurrentPeriod(params) {
1024
- if (params?.periodId) {
1025
- return this.request("GET", `/periods/${encodeURIComponent(params.periodId)}`);
1456
+ if (params?.fiscalYear) {
1457
+ return this.requestUnwrapped("GET", `/periods/${encodeURIComponent(params.fiscalYear)}`);
1026
1458
  }
1027
- return this.request("GET", "/periods/current");
1459
+ return this.requestUnwrapped("GET", "/periods/current");
1028
1460
  }
1029
1461
  async closePeriod(data) {
1030
1462
  return this.requestUnwrapped("POST", "/periods/close", data);