@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
@@ -9,6 +9,7 @@
9
9
  import { z } from "zod/v4";
10
10
  import { log, logToolCall } from "../logger.js";
11
11
  import { recordToolCall } from "../metrics.js";
12
+ import { sanitizeServerRemediation } from "../redaction.js";
12
13
  /* ------------------------------------------------------------------ */
13
14
  /* Safety annotations for MCP tool registrations */
14
15
  /* ------------------------------------------------------------------ */
@@ -43,47 +44,73 @@ export const ERROR_CONTENT_ANNOTATIONS = {
43
44
  /* Response size guard */
44
45
  /* ------------------------------------------------------------------ */
45
46
  const MAX_RESPONSE_CHARS = 80_000; // ~20,000 tokens safety margin
46
- export function truncateResponse(text) {
47
+ export function truncateResponse(text, truncatedNotice = '\n\n[Response truncated. Use pagination (limit/offset) to retrieve smaller result sets.]') {
47
48
  if (text.length <= MAX_RESPONSE_CHARS)
48
49
  return text;
49
- return text.slice(0, MAX_RESPONSE_CHARS) +
50
- '\n\n[Response truncated. Use pagination (limit/offset) to retrieve smaller result sets.]';
50
+ return text.slice(0, MAX_RESPONSE_CHARS) + truncatedNotice;
51
51
  }
52
52
  function isFrihetApiError(error) {
53
53
  return (typeof error === "object" &&
54
54
  error !== null &&
55
55
  "statusCode" in error &&
56
56
  "errorCode" in error &&
57
- typeof error.statusCode === "number");
57
+ typeof error.statusCode === "number" &&
58
+ typeof error.errorCode === "string" &&
59
+ typeof error.message === "string");
58
60
  }
61
+ const FORBIDDEN_FRIENDLY_MESSAGE = "Access denied. Reconnect Frihet or use configured credentials with permission for this action. / Acceso denegado. Vuelve a conectar Frihet o usa credenciales con permiso para esta accion.";
59
62
  /**
60
63
  * Maps an error to a user-friendly MCP tool response with error annotations.
61
64
  * Emits structured log entries for all errors.
62
65
  */
63
66
  export function handleToolError(error, toolName) {
64
67
  if (isFrihetApiError(error)) {
68
+ const loggedMessage = error.statusCode === 403 ? "Forbidden" : error.message;
69
+ const loggedCode = error.statusCode === 403 ? "forbidden" : error.errorCode;
65
70
  log({
66
71
  level: "error",
67
- message: `API error: ${error.statusCode} ${error.errorCode}: ${error.message}`,
72
+ message: `API error: ${error.statusCode} ${loggedCode}: ${loggedMessage}`,
68
73
  tool: toolName,
69
74
  operation: "tool_error",
70
75
  error: {
71
- message: error.message,
72
- code: error.errorCode,
76
+ message: loggedMessage,
77
+ code: loggedCode,
73
78
  statusCode: error.statusCode,
74
79
  },
75
80
  });
76
81
  const messages = {
77
82
  400: "Bad request. Check your input parameters. / Solicitud incorrecta. Revisa los parametros.",
78
- 401: "Authentication failed. Check your API key. / Autenticacion fallida. Revisa tu API key.",
79
- 403: "Access denied. Your API key does not have permission for this action. / Acceso denegado.",
83
+ 401: "Authentication failed. Reconnect Frihet or check the configured credentials. / Autenticacion fallida. Vuelve a conectar Frihet o revisa las credenciales configuradas.",
84
+ 403: FORBIDDEN_FRIENDLY_MESSAGE,
80
85
  404: "Resource not found. / Recurso no encontrado.",
81
86
  405: "Method not allowed. / Metodo no permitido.",
82
87
  413: "Request body too large (max 1MB). / Cuerpo de la solicitud demasiado grande (max 1MB).",
83
88
  429: "Rate limit exceeded. Try again later. / Limite de peticiones excedido. Intenta mas tarde.",
84
89
  500: "Internal server error. Try again later. / Error interno del servidor.",
90
+ 503: "Frihet could not confirm the request outcome because the network connection failed.",
85
91
  };
86
- const friendlyMessage = messages[error.statusCode] ?? `API error ${error.statusCode}. Contact support if this persists.`;
92
+ const transportOutcomeUnknown = error.errorCode === "request_timeout" || error.errorCode === "network_error";
93
+ const operationOutcomeUnknown = transportOutcomeUnknown
94
+ || error.errorCode === "response_timeout"
95
+ || error.errorCode === "invalid_response_after_success"
96
+ || (error.statusCode >= 500 && error.statusCode <= 599)
97
+ || (error.statusCode === 409
98
+ && error.errorCode.toUpperCase() === "IDEMPOTENCY_REQUEST_IN_PROGRESS");
99
+ let friendlyMessage;
100
+ if (error.statusCode === 413 && error.errorCode === "payload_too_large") {
101
+ friendlyMessage = `Document response too large: ${error.message} / Respuesta de documento demasiado grande.`;
102
+ }
103
+ else if (error.statusCode === 403) {
104
+ const guidance = sanitizeServerRemediation(error.detail, error.errorCode)
105
+ ?? sanitizeServerRemediation(error.message, error.errorCode);
106
+ friendlyMessage = guidance
107
+ ? `${FORBIDDEN_FRIENDLY_MESSAGE} Server guidance: ${guidance}`
108
+ : FORBIDDEN_FRIENDLY_MESSAGE;
109
+ }
110
+ else {
111
+ friendlyMessage = messages[error.statusCode]
112
+ ?? `API error ${error.statusCode}. Contact support if this persists.`;
113
+ }
87
114
  return {
88
115
  content: [
89
116
  {
@@ -93,6 +120,16 @@ export function handleToolError(error, toolName) {
93
120
  },
94
121
  ],
95
122
  isError: true,
123
+ ...(operationOutcomeUnknown
124
+ ? {
125
+ _meta: {
126
+ "io.frihet/operationOutcomeUnknown": true,
127
+ ...(transportOutcomeUnknown
128
+ ? { "io.frihet/transportOutcomeUnknown": true }
129
+ : {}),
130
+ },
131
+ }
132
+ : {}),
96
133
  };
97
134
  }
98
135
  const errMsg = error instanceof Error ? error.stack ?? error.message : String(error);
@@ -132,6 +169,15 @@ export function formatPaginatedResponse(resourceName, response) {
132
169
  }
133
170
  return truncateResponse(lines.join("\n"));
134
171
  }
172
+ /** Format an exhaustive list whose backend exposes no pagination controls. */
173
+ export function formatUnpaginatedListResponse(resourceName, response) {
174
+ const lines = [`Found ${response.total} ${resourceName}:`, ""];
175
+ for (const item of response.data) {
176
+ lines.push(JSON.stringify(item, null, 2));
177
+ lines.push("---");
178
+ }
179
+ return truncateResponse(lines.join("\n"), "\n\n[Response truncated.]");
180
+ }
135
181
  /**
136
182
  * Formats a single record for display.
137
183
  */
@@ -157,11 +203,22 @@ export function mutateContent(text) {
157
203
  return { type: "text", text, annotations: MUTATE_CONTENT_ANNOTATIONS };
158
204
  }
159
205
  /* ------------------------------------------------------------------ */
160
- /* Contextual enrichment — _suggestions and _warnings */
206
+ /* Contextual enrichment — text appended to `content`, not structuredContent */
161
207
  /* ------------------------------------------------------------------ */
162
208
  /**
163
- * Adds contextual suggestions and warnings to tool responses.
164
- * Helps AI agents know what to do next without guessing.
209
+ * Returns agent-facing suggestions and warnings for a tool call, formatted
210
+ * as plain text to be APPENDED to the human-readable `content` block.
211
+ *
212
+ * Why text, not structured fields: `structuredContent` is the strict
213
+ * outputSchema contract. Spreading `{ _suggestions, _warnings }` into
214
+ * structuredContent would make them undeclared enrichment (Inspector /
215
+ * conformance failures, drift in the OpenAI reviewed surface's frozen
216
+ * snapshot). The hints are agent-facing and deterministic from the
217
+ * emitted data, so they belong in the text the model reads, not in the
218
+ * JSON the schema validates. Pinned by
219
+ * src/__tests__/paginated-strict-output.test.ts (ERP #1580): the strict
220
+ * contract test asserts structuredContent never carries the old
221
+ * `_suggestions` / `_warnings` keys.
165
222
  */
166
223
  export function enrichResponse(resource, operation, data) {
167
224
  const suggestions = [];
@@ -237,10 +294,20 @@ export function enrichResponse(resource, operation, data) {
237
294
  if (operation === "delete") {
238
295
  warnings.push("This action cannot be undone / Esta accion no se puede deshacer");
239
296
  }
240
- return {
241
- ...(suggestions.length > 0 ? { _suggestions: suggestions } : {}),
242
- ...(warnings.length > 0 ? { _warnings: warnings } : {}),
243
- };
297
+ if (suggestions.length === 0 && warnings.length === 0)
298
+ return "";
299
+ const lines = [];
300
+ if (warnings.length > 0) {
301
+ lines.push("Warnings:");
302
+ for (const w of warnings)
303
+ lines.push(` - ${w}`);
304
+ }
305
+ if (suggestions.length > 0) {
306
+ lines.push("Suggested next steps:");
307
+ for (const s of suggestions)
308
+ lines.push(` - ${s}`);
309
+ }
310
+ return "\n" + lines.join("\n");
244
311
  }
245
312
  /* ------------------------------------------------------------------ */
246
313
  /* Output schemas (MCP spec 2025-11-25: outputSchema + structuredContent) */
@@ -268,6 +335,15 @@ export function paginatedOutput(itemSchema, opts = {}) {
268
335
  // so they keep the item schema's own required contract (an `{}` row there is
269
336
  // an API regression the schema must catch, not tolerate).
270
337
  const rowSchema = opts.projectable ? itemSchema.partial() : itemSchema;
338
+ // Canonical envelope contract: the emitted `structuredContent` of every
339
+ // paginated tool is exactly this object. The MCP SDK validates the runtime
340
+ // value against this schema on every call; anything not declared here is
341
+ // an Inspector/conformance failure. Pinned by
342
+ // src/__tests__/paginated-strict-output.test.ts (ERP #1580). The
343
+ // OpenAI reviewed surface's frozen snapshot (`openai-review-descriptor`)
344
+ // is a byte contract on this shape, so adding a field here would drift
345
+ // the snapshot — the right place for agent-facing hints is the `content`
346
+ // text block, not `structuredContent` (see enrichResponse).
271
347
  return z.object({
272
348
  data: z.array(rowSchema),
273
349
  total: z.number(),
@@ -281,6 +357,32 @@ export const deleteResultOutput = z.object({
281
357
  success: z.boolean(),
282
358
  id: z.string(),
283
359
  });
360
+ /**
361
+ * Schema for deletes of FISCAL DOCUMENTS (invoices, quotes) — the only two
362
+ * resources where "delete" is not always a delete.
363
+ *
364
+ * The backend refuses to destroy a non-draft invoice/quote (VeriFactu hash
365
+ * chain) and soft-CANCELS it instead, distinguishing the outcomes on the wire:
366
+ * 204 = row destroyed, 200 + body = document kept with status=cancelled
367
+ * (erp-main publicApi.ts `deleteResource`). `outcome` is what lets an agent
368
+ * tell the user which one actually happened (GAP-12).
369
+ *
370
+ * Deliberately NOT folded into {@link deleteResultOutput}: that one is shared by
371
+ * nine non-fiscal delete tools whose backend has no cancel branch, and widening
372
+ * it would drift their published output schema for no behavioural reason.
373
+ */
374
+ export const documentDeleteResultOutput = z.object({
375
+ success: z.boolean(),
376
+ id: z.string(),
377
+ /** "deleted" = row destroyed (204). "cancelled" = document kept, status=cancelled (200). */
378
+ outcome: z.enum(["deleted", "cancelled"]).optional(),
379
+ /** Document status after a soft cancel (always "cancelled" when present). */
380
+ status: z.string().optional(),
381
+ /** Status the document held before it was cancelled (e.g. "sent", "paid"). */
382
+ previousStatus: z.string().optional(),
383
+ /** Backend path that performed the soft cancellation, when reported. */
384
+ cancelledVia: z.string().optional(),
385
+ });
284
386
  /**
285
387
  * Permissive structured-output schema for tools whose API returns an open
286
388
  * `Record<string, unknown>` (summaries, single-object actions, VIES/portal
@@ -296,9 +398,12 @@ export function openObjectOutput(description) {
296
398
  }
297
399
  /* --- Per-resource item schemas ------------------------------------ */
298
400
  const lineItemSchema = z.object({
401
+ id: z.string().optional(),
299
402
  description: z.string(),
300
403
  quantity: z.number(),
301
404
  unitPrice: z.number(),
405
+ taxRate: z.number().optional(),
406
+ discount: z.number().optional(),
302
407
  });
303
408
  export const invoiceItemOutput = z.object({
304
409
  id: z.string(),
@@ -306,6 +411,7 @@ export const invoiceItemOutput = z.object({
306
411
  // draft — verified live. Kept documented but non-required so get/create/update
307
412
  // of a draft or a client-less invoice validates. `id` stays required: every
308
413
  // single-object read/create/update returns the doc id.
414
+ clientId: z.string().optional(),
309
415
  clientName: z.string().nullable().optional(),
310
416
  items: z.array(lineItemSchema).optional(),
311
417
  issueDate: z.string().optional(),
@@ -313,6 +419,7 @@ export const invoiceItemOutput = z.object({
313
419
  status: z.string().optional(),
314
420
  notes: z.string().optional(),
315
421
  taxRate: z.number().optional(),
422
+ discountRate: z.number().optional(),
316
423
  total: z.number().optional(),
317
424
  createdAt: z.string().optional(),
318
425
  updatedAt: z.string().optional(),
@@ -323,8 +430,10 @@ export const expenseItemOutput = z.object({
323
430
  amount: z.number(),
324
431
  category: z.string().optional(),
325
432
  date: z.string().optional(),
433
+ vendorId: z.string().optional(),
326
434
  vendor: z.string().optional(),
327
435
  taxDeductible: z.boolean().optional(),
436
+ paidDate: z.string().optional(),
328
437
  createdAt: z.string().optional(),
329
438
  updatedAt: z.string().optional(),
330
439
  }).passthrough();
@@ -340,6 +449,7 @@ export const clientItemOutput = z.object({
340
449
  name: z.string(),
341
450
  email: z.string().optional(),
342
451
  phone: z.string().optional(),
452
+ stage: z.string().optional(),
343
453
  taxId: z.string().optional(),
344
454
  address: addressOutputSchema,
345
455
  createdAt: z.string().optional(),
@@ -351,18 +461,24 @@ export const productItemOutput = z.object({
351
461
  unitPrice: z.number(),
352
462
  description: z.string().optional(),
353
463
  taxRate: z.number().optional(),
464
+ isActive: z.boolean().optional(),
354
465
  createdAt: z.string().optional(),
355
466
  updatedAt: z.string().optional(),
356
467
  }).passthrough();
357
468
  export const quoteItemOutput = z.object({
358
469
  id: z.string(),
359
470
  // Same draft/client-less tolerance as invoices (shared form engine).
471
+ clientId: z.string().optional(),
360
472
  clientName: z.string().nullable().optional(),
361
473
  items: z.array(lineItemSchema).optional(),
474
+ issueDate: z.string().optional(),
475
+ dueDate: z.string().optional(),
362
476
  validUntil: z.string().optional(),
363
477
  notes: z.string().optional(),
364
478
  status: z.string().optional(),
365
479
  total: z.number().optional(),
480
+ taxRate: z.number().optional(),
481
+ discountRate: z.number().optional(),
366
482
  createdAt: z.string().optional(),
367
483
  updatedAt: z.string().optional(),
368
484
  }).passthrough();
@@ -378,13 +494,31 @@ export const vendorItemOutput = z.object({
378
494
  }).passthrough();
379
495
  export const webhookItemOutput = z.object({
380
496
  id: z.string(),
497
+ userId: z.string().optional(),
498
+ name: z.string(),
381
499
  url: z.string(),
382
500
  events: z.array(z.string()),
383
- active: z.boolean().optional(),
384
- secret: z.string().optional(),
501
+ status: z.enum(["active", "inactive", "paused"]),
502
+ metadata: z.record(z.string(), z.unknown()).optional(),
503
+ hasSecret: z.boolean(),
504
+ pausedReason: z.string().optional(),
505
+ lastTriggeredAt: z.string().optional(),
385
506
  createdAt: z.string().optional(),
386
507
  updatedAt: z.string().optional(),
387
- }).passthrough();
508
+ _demo: z.literal(true).optional(),
509
+ _demoNotice: z.string().optional(),
510
+ }).strict();
511
+ /** Create alone may echo the caller-supplied signing secret once. */
512
+ export const webhookCreateOutput = webhookItemOutput.extend({
513
+ secret: z.string().optional(),
514
+ }).strict();
515
+ /** `/webhooks` has no backend pagination contract. */
516
+ export const webhookListOutput = z.object({
517
+ data: z.array(webhookItemOutput),
518
+ total: z.number(),
519
+ _demo: z.literal(true).optional(),
520
+ _demoNotice: z.string().optional(),
521
+ }).strict();
388
522
  /* --- CRM subcollection item schemas -------------------------------- */
389
523
  export const contactItemOutput = z.object({
390
524
  id: z.string(),
@@ -401,10 +535,14 @@ export const activityItemOutput = z.object({
401
535
  type: z.string(),
402
536
  title: z.string(),
403
537
  description: z.string().optional(),
404
- date: z.string().optional(),
538
+ metadata: z.record(z.string(), z.string()).optional(),
539
+ timestamp: z.string(),
540
+ createdBy: z.enum(["system", "user"]).optional(),
405
541
  createdAt: z.string().optional(),
406
542
  updatedAt: z.string().optional(),
407
- }).passthrough();
543
+ _demo: z.literal(true).optional(),
544
+ _demoNotice: z.string().optional(),
545
+ }).strict();
408
546
  export const noteItemOutput = z.object({
409
547
  id: z.string(),
410
548
  content: z.string(),
@@ -543,6 +681,7 @@ export const actionResultOutput = z.object({
543
681
  success: z.boolean().optional(),
544
682
  id: z.string().optional(),
545
683
  message: z.string().optional(),
684
+ messageId: z.string().optional(),
546
685
  // Anti-envelope tripwire: with everything optional + passthrough, a raw
547
686
  // {data, meta} API envelope would otherwise VALIDATE — silently re-hiding the
548
687
  // exact bug this PR fixes if a client method ever regresses to raw request().
@@ -568,6 +707,9 @@ export const creditNoteResultOutput = z.object({
568
707
  originalInvoiceId: z.string().optional(),
569
708
  reason: z.string().optional(),
570
709
  fullCredit: z.boolean().optional(),
710
+ status: z.string().optional(),
711
+ rectificationMethod: z.string().optional(),
712
+ totalCredited: z.number().optional(),
571
713
  }).passthrough().optional(),
572
714
  // Anti-envelope tripwire — see actionResultOutput.
573
715
  data: z.never().optional(),
@@ -729,11 +871,32 @@ export const recurringInvoiceItemOutput = z.object({
729
871
  createdAt: z.string().optional(),
730
872
  updatedAt: z.string().optional(),
731
873
  }).passthrough();
732
- /** Schema for PDF results */
874
+ /**
875
+ * Schema for the MIME-discriminated `get_invoice_einvoice` artifact.
876
+ *
877
+ * Stored UBL/Facturae artifacts return strict UTF-8 `xml`; Factur-X artifacts
878
+ * return PDF bytes as `base64`. Identity, MIME and byte size are always present.
879
+ */
880
+ export const einvoiceResultOutput = z.object({
881
+ id: z.string(),
882
+ contentType: z.string(),
883
+ sizeBytes: z.number().int().nonnegative(),
884
+ xml: z.string().optional(),
885
+ base64: z.string().optional(),
886
+ filename: z.string().optional(),
887
+ }).passthrough().refine((value) => (typeof value.xml === "string") !== (typeof value.base64 === "string"), { message: "Exactly one of xml or base64 is required" });
888
+ /**
889
+ * Schema for `get_invoice_pdf` results (#1393 — content-type-aware).
890
+ *
891
+ * The backend serves raw `application/pdf` bytes. MCP `structuredContent` is
892
+ * JSON-only, so callers receive bounded base64 plus request identity and size.
893
+ */
733
894
  export const pdfResultOutput = z.object({
734
895
  id: z.string(),
735
- url: z.string().optional(),
736
- contentType: z.string().optional(),
896
+ contentType: z.string(),
897
+ sizeBytes: z.number().int().nonnegative(),
898
+ base64: z.string(),
899
+ filename: z.string().optional(),
737
900
  }).passthrough();
738
901
  /* --- Time summary schema --------------------------------------------------- */
739
902
  const timeSummaryGroupItemSchema = z.object({
@@ -756,14 +919,15 @@ export const timeSummaryOutput = z.object({
756
919
  /* --- Team member item schema ----------------------------------------------- */
757
920
  export const teamMemberItemOutput = z.object({
758
921
  id: z.string(),
759
- name: z.string().optional(),
760
- email: z.string(),
761
- role: z.enum(["owner", "admin", "member", "viewer"]).optional(),
922
+ name: z.string().nullable().optional(),
923
+ email: z.string().nullable().optional(),
924
+ role: z.enum(["owner", "admin", "editor", "accountant", "viewer"]).optional(),
762
925
  status: z.enum(["active", "pending"]).optional(),
763
- invitedAt: z.string().optional(),
764
- joinedAt: z.string().optional(),
765
- createdAt: z.string().optional(),
766
- updatedAt: z.string().optional(),
926
+ invitedAt: z.string().nullable().optional(),
927
+ joinedAt: z.string().nullable().optional(),
928
+ createdAt: z.string().nullable().optional(),
929
+ updatedAt: z.string().nullable().optional(),
930
+ expiresAt: z.string().nullable().optional(),
767
931
  }).passthrough();
768
932
  /* --- Gestoria item schemas ------------------------------------------------- */
769
933
  /**
@@ -885,19 +1049,40 @@ export const attendanceEntryItemOutput = z.object({
885
1049
  createdAt: z.string().optional(),
886
1050
  updatedAt: z.string().optional(),
887
1051
  }).passthrough();
888
- /** Overtime aggregated report. */
1052
+ /** Exact ERP DTO for `GET /v1/time-entries/overtime`. */
889
1053
  export const overtimeReportOutput = z.object({
890
- period: z.string(),
891
- totalRegularHours: z.number().optional(),
892
- totalOvertimeHours: z.number().optional(),
893
- estimatedCostEur: z.number().optional(),
894
- byEmployee: z.array(z.object({
895
- employeeId: z.string(),
896
- employeeName: z.string().optional(),
897
- regularHours: z.number().optional(),
898
- overtimeHours: z.number().optional(),
899
- }).passthrough()).optional(),
900
- generatedAt: z.string().optional(),
1054
+ period: z.string().regex(/^\d{4}(-(0[1-9]|1[0-2]))?$/),
1055
+ employeeId: z.string().nullable(),
1056
+ recordCount: z.number().int().nonnegative(),
1057
+ dailyOvertime: z.array(z.object({
1058
+ date: z.string(),
1059
+ minutes: z.number().nonnegative(),
1060
+ exceedsDaily: z.boolean(),
1061
+ })),
1062
+ weeklyOvertime: z.array(z.object({
1063
+ weekStart: z.string(),
1064
+ totalMinutes: z.number().nonnegative(),
1065
+ overtimeMinutes: z.number().nonnegative(),
1066
+ })),
1067
+ monthlyTotal: z.object({
1068
+ workedMinutes: z.number().nonnegative(),
1069
+ overtimeMinutes: z.number().nonnegative(),
1070
+ regularMinutes: z.number().nonnegative(),
1071
+ }),
1072
+ annualOvertimeHours: z.number().nonnegative(),
1073
+ alerts: z.array(z.object({
1074
+ type: z.enum(["daily_exceeded", "weekly_exceeded", "annual_approaching", "annual_exceeded", "missing_break"]),
1075
+ severity: z.enum(["warning", "critical"]),
1076
+ message: z.string(),
1077
+ date: z.string(),
1078
+ })),
1079
+ data: z.never().optional(),
1080
+ meta: z.never().optional(),
1081
+ totalRegularHours: z.never().optional(),
1082
+ totalOvertimeHours: z.never().optional(),
1083
+ estimatedCostEur: z.never().optional(),
1084
+ byEmployee: z.never().optional(),
1085
+ generatedAt: z.never().optional(),
901
1086
  }).passthrough();
902
1087
  /** Anomaly detection record — backend `/v1/anomalies`. */
903
1088
  export const anomalyItemOutput = z.object({
@@ -922,28 +1107,60 @@ export const webhookTestResultOutput = z.object({
922
1107
  attemptedAt: z.string().optional(),
923
1108
  error: z.string().optional(),
924
1109
  }).passthrough();
925
- /** Payroll export result — backend `/v1/payroll/prep/export`. */
1110
+ /** Exact normalized dataset DTO from `GET /v1/payroll/prep/export`. */
926
1111
  export const payrollExportOutput = z.object({
927
- format: z.enum(["a3", "contasol", "sage", "holded", "siltra"]),
928
- month: z.string(),
929
- fileUrl: z.string().optional(),
930
- filename: z.string().optional(),
931
- rowCount: z.number().int().optional(),
932
- generatedAt: z.string().optional(),
1112
+ month: z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/),
1113
+ format: z.enum(["a3", "contasol", "sage", "siltra"]),
1114
+ employees: z.array(z.object({
1115
+ id: z.string(),
1116
+ name: z.string(),
1117
+ nss: z.string(),
1118
+ irpfPct: z.number(),
1119
+ salaryGrossAnnual: z.number(),
1120
+ convenioColectivo: z.string().nullable(),
1121
+ categoriaProfesional: z.string().nullable(),
1122
+ prorrateoPagasExtras: z.boolean().nullable(),
1123
+ formaPago: z.enum(["transferencia", "efectivo", "cheque"]),
1124
+ iban: z.string().nullable(),
1125
+ })),
1126
+ summary: z.object({
1127
+ exportedCount: z.number().int().nonnegative(),
1128
+ skippedNotReady: z.number().int().nonnegative(),
1129
+ totalGrossAnnual: z.number(),
1130
+ }),
1131
+ data: z.never().optional(),
1132
+ meta: z.never().optional(),
1133
+ fileUrl: z.never().optional(),
1134
+ filename: z.never().optional(),
1135
+ rowCount: z.never().optional(),
1136
+ generatedAt: z.never().optional(),
933
1137
  }).passthrough();
934
- /** Payroll checklist — backend `/v1/payroll/prep/employees`. */
1138
+ /** Exact readiness DTO from `GET /v1/payroll/prep/employees`. */
935
1139
  export const payrollChecklistOutput = z.object({
936
- month: z.string(),
937
- totalEmployees: z.number().int().optional(),
938
- readyEmployees: z.number().int().optional(),
939
- missingEmployees: z.number().int().optional(),
1140
+ month: z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/),
940
1141
  employees: z.array(z.object({
941
- employeeId: z.string(),
942
- employeeName: z.string().optional(),
943
- status: z.enum(["ready", "missing_data", "blocked"]).optional(),
944
- missingFields: z.array(z.string()).optional(),
945
- }).passthrough()).optional(),
946
- generatedAt: z.string().optional(),
1142
+ id: z.string(),
1143
+ name: z.string(),
1144
+ status: z.enum(["active", "onLeave"]),
1145
+ hasPayrollProfile: z.boolean(),
1146
+ ready: z.boolean(),
1147
+ missingFields: z.array(z.enum(["nss", "irpfPct", "salaryGrossAnnual", "formaPago", "iban"])),
1148
+ reviewedForMonth: z.string().nullable(),
1149
+ reviewedThisMonth: z.boolean(),
1150
+ reviewedAt: z.string().nullable(),
1151
+ })),
1152
+ summary: z.object({
1153
+ total: z.number().int().nonnegative(),
1154
+ ready: z.number().int().nonnegative(),
1155
+ notReady: z.number().int().nonnegative(),
1156
+ reviewedThisMonth: z.number().int().nonnegative(),
1157
+ }),
1158
+ data: z.never().optional(),
1159
+ meta: z.never().optional(),
1160
+ totalEmployees: z.never().optional(),
1161
+ readyEmployees: z.never().optional(),
1162
+ missingEmployees: z.never().optional(),
1163
+ generatedAt: z.never().optional(),
947
1164
  }).passthrough();
948
1165
  /** Onboarding workspace state — backend `/v1/onboarding/status`. */
949
1166
  export const onboardingStatusOutput = z.object({
@@ -961,23 +1178,82 @@ export const onboardingPersonaResultOutput = z.object({
961
1178
  persona: z.enum(["autonomo", "empresa", "agencia", "gestoria"]),
962
1179
  updatedAt: z.string().optional(),
963
1180
  }).passthrough();
964
- /** Permissions matrix — backend `/v1/permissions/matrix`. */
1181
+ const rbacRoleOutput = z.enum(["owner", "admin", "manager", "sales", "accountant", "employee", "viewer"]);
1182
+ const rbacResourceOutput = z.enum([
1183
+ "workspace", "invoices", "quotes", "expenses", "clients", "products", "accounting",
1184
+ "people", "payroll", "integrations", "banking", "settings", "audit_log", "billing",
1185
+ ]);
1186
+ const rbacActionOutput = z.enum(["read", "create", "update", "delete", "bulk_delete", "export"]);
1187
+ const rbacActionsOutput = z.array(rbacActionOutput);
1188
+ // Matrix rows intentionally omit denied resources. Keep every known resource
1189
+ // explicit and optional so sparse backend rows validate consistently.
1190
+ const rbacResourceGrantsOutput = z.object({
1191
+ workspace: rbacActionsOutput.optional(),
1192
+ invoices: rbacActionsOutput.optional(),
1193
+ quotes: rbacActionsOutput.optional(),
1194
+ expenses: rbacActionsOutput.optional(),
1195
+ clients: rbacActionsOutput.optional(),
1196
+ products: rbacActionsOutput.optional(),
1197
+ accounting: rbacActionsOutput.optional(),
1198
+ people: rbacActionsOutput.optional(),
1199
+ payroll: rbacActionsOutput.optional(),
1200
+ integrations: rbacActionsOutput.optional(),
1201
+ banking: rbacActionsOutput.optional(),
1202
+ settings: rbacActionsOutput.optional(),
1203
+ audit_log: rbacActionsOutput.optional(),
1204
+ billing: rbacActionsOutput.optional(),
1205
+ }).strict();
1206
+ const rbacMatrixOutput = z.object({
1207
+ owner: rbacResourceGrantsOutput,
1208
+ admin: rbacResourceGrantsOutput,
1209
+ manager: rbacResourceGrantsOutput,
1210
+ sales: rbacResourceGrantsOutput,
1211
+ accountant: rbacResourceGrantsOutput,
1212
+ employee: rbacResourceGrantsOutput,
1213
+ viewer: rbacResourceGrantsOutput,
1214
+ }).strict();
1215
+ /**
1216
+ * Documented RBAC-model snapshot — backend `/v1/permissions/matrix`.
1217
+ * This is not an exhaustive report of runtime authorization or Firestore rules.
1218
+ */
965
1219
  export const permissionsMatrixOutput = z.object({
966
- roles: z.array(z.object({
967
- role: z.string(),
968
- permissions: z.array(z.string()),
969
- }).passthrough()).optional(),
970
- resources: z.array(z.string()).optional(),
971
- generatedAt: z.string().optional(),
1220
+ roles: z.array(rbacRoleOutput).describe("Role names represented by this documented RBAC model"),
1221
+ resources: z.array(rbacResourceOutput).describe("Resource names represented by this documented RBAC model"),
1222
+ actions: z.array(rbacActionOutput).describe("Action names represented by this documented RBAC model"),
1223
+ legacyAliases: z.record(z.string(), rbacRoleOutput).describe("Legacy role name to canonical RBAC-model role"),
1224
+ matrix: rbacMatrixOutput
1225
+ .describe("Documented role/resource/action grants; not a runtime authorization guarantee"),
1226
+ source: z.string().describe("Backend explanation of the model's source and limitations"),
972
1227
  }).passthrough();
973
- /** Caller's own permissions — backend `/v1/permissions/me`. */
1228
+ /**
1229
+ * Caller's RBAC-model row and API-key scope state — backend `/v1/permissions/me`.
1230
+ * Known denials are intentionally non-exhaustive; a backend 403 is authoritative.
1231
+ */
974
1232
  export const permissionsMeOutput = z.object({
975
- userId: z.string().optional(),
976
- role: z.string().optional(),
977
- permissions: z.array(z.string()).optional(),
978
- workspaceId: z.string().optional(),
1233
+ role: rbacRoleOutput.describe("Backwards-compatible RBAC-model role"),
1234
+ isOwner: z.boolean().describe("Backwards-compatible RBAC-model owner flag"),
1235
+ resources: rbacResourceGrantsOutput.describe("Backwards-compatible RBAC-model resource grants"),
1236
+ scopes: z.array(z.string()).describe("Backwards-compatible RBAC capabilities; not API-key scopes"),
1237
+ legacyFieldSemantics: z.object({
1238
+ resources: z.literal("rbacResources"),
1239
+ scopes: z.literal("rbacCapabilities"),
1240
+ }).describe("Explicit meaning of the backwards-compatible resources/scopes fields"),
1241
+ rbac: z.object({
1242
+ role: rbacRoleOutput,
1243
+ isOwner: z.boolean(),
1244
+ resources: rbacResourceGrantsOutput,
1245
+ capabilities: z.array(z.string()),
1246
+ }).describe("Documented RBAC-model view; not an exhaustive authorization prediction"),
1247
+ apiKeyScopes: z.array(z.string()).describe("Actual scopes stored for this API key"),
1248
+ apiKeyUnrestricted: z.boolean().describe("Whether empty/absent stored scopes use unrestricted-key semantics"),
1249
+ denied: z.object({
1250
+ einvoice: z.boolean(),
1251
+ }).describe("Known API-key-scope denials only"),
1252
+ deniedSemantics: z.string().describe("Backend limitation statement for denied"),
1253
+ notIncluded: z.array(z.enum(["featureFlags", "deployedEndpoints"]))
1254
+ .describe("Authorization surfaces this endpoint cannot truthfully report"),
979
1255
  }).passthrough();
980
- /** Accounting period state — backend `/v1/periods/*`. */
1256
+ /** Legacy demo/write result retained for period_close and period_reopen only. */
981
1257
  export const periodStatusOutput = z.object({
982
1258
  id: z.string(),
983
1259
  type: z.enum(["monthly", "quarterly"]).optional(),
@@ -991,6 +1267,35 @@ export const periodStatusOutput = z.object({
991
1267
  createdAt: z.string().optional(),
992
1268
  updatedAt: z.string().optional(),
993
1269
  }).passthrough();
1270
+ /** Exact ERP DTO for `GET /v1/periods/current` and `GET /v1/periods/YYYY`. */
1271
+ export const currentPeriodOutput = z.object({
1272
+ fiscalYear: z.string().regex(/^\d{4}$/),
1273
+ fiscalYearStart: z.string().regex(/^\d{2}-\d{2}$/),
1274
+ status: z.enum(["open", "closed"]),
1275
+ dateRange: z.object({
1276
+ from: z.string(),
1277
+ to: z.string(),
1278
+ }),
1279
+ closing: z.object({
1280
+ status: z.string(),
1281
+ closedAt: z.string().nullable(),
1282
+ netIncome: z.number().nullable(),
1283
+ totalIncome: z.number().nullable(),
1284
+ totalExpenses: z.number().nullable(),
1285
+ journalEntries: z.number().nullable(),
1286
+ }).nullable(),
1287
+ data: z.never().optional(),
1288
+ meta: z.never().optional(),
1289
+ id: z.never().optional(),
1290
+ type: z.never().optional(),
1291
+ startDate: z.never().optional(),
1292
+ endDate: z.never().optional(),
1293
+ closedAt: z.never().optional(),
1294
+ closedBy: z.never().optional(),
1295
+ reopenedAt: z.never().optional(),
1296
+ reopenReason: z.never().optional(),
1297
+ generatedAt: z.never().optional(),
1298
+ }).passthrough();
994
1299
  /**
995
1300
  * Wraps a tool handler to automatically log execution time, success/failure,
996
1301
  * and record metrics. Catches errors and routes them through handleToolError.
@@ -1014,7 +1319,12 @@ export async function withToolLogging(toolName, fn) {
1014
1319
  }
1015
1320
  catch (error) {
1016
1321
  const durationMs = Math.round(Date.now() - startTime);
1017
- logToolCall(toolName, startTime, false, error instanceof Error ? error : new Error(String(error)));
1322
+ const errorForLog = isFrihetApiError(error) && error.statusCode === 403
1323
+ ? Object.assign(new Error("Forbidden"), { statusCode: 403, errorCode: "forbidden" })
1324
+ : error instanceof Error
1325
+ ? error
1326
+ : new Error(String(error));
1327
+ logToolCall(toolName, startTime, false, errorForLog);
1018
1328
  recordToolCall(toolName, durationMs, false);
1019
1329
  return handleToolError(error, toolName);
1020
1330
  }