@frihet/mcp-server 1.16.6 → 2.0.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 (108) hide show
  1. package/README.md +168 -82
  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 +552 -0
  8. package/dist/capability-truth.d.ts +29 -0
  9. package/dist/capability-truth.js +262 -0
  10. package/dist/claude-candidate-contract.d.ts +482 -0
  11. package/dist/claude-candidate-contract.js +87 -0
  12. package/dist/claude-output-privacy.d.ts +17 -0
  13. package/dist/claude-output-privacy.js +240 -0
  14. package/dist/claude-profile.d.ts +18 -0
  15. package/dist/claude-profile.js +260 -0
  16. package/dist/client-interface.d.ts +47 -33
  17. package/dist/client.d.ts +172 -40
  18. package/dist/client.js +708 -165
  19. package/dist/demo-client.d.ts +41 -32
  20. package/dist/demo-client.js +202 -31
  21. package/dist/demo-fixtures.d.ts +3 -3
  22. package/dist/demo-fixtures.js +7 -7
  23. package/dist/deposit-contract.d.ts +29 -0
  24. package/dist/deposit-contract.js +76 -0
  25. package/dist/fetch-no-redirect.d.ts +3 -0
  26. package/dist/fetch-no-redirect.js +16 -0
  27. package/dist/fiscal-aliases.d.ts +44 -0
  28. package/dist/fiscal-aliases.js +62 -0
  29. package/dist/fiscal-period.d.ts +26 -0
  30. package/dist/fiscal-period.js +31 -0
  31. package/dist/google-profile.d.ts +19 -0
  32. package/dist/google-profile.js +122 -0
  33. package/dist/index.js +68 -59
  34. package/dist/logger.d.ts +2 -2
  35. package/dist/logger.js +140 -8
  36. package/dist/muse-profile.d.ts +19 -0
  37. package/dist/muse-profile.js +122 -0
  38. package/dist/observability.d.ts +18 -29
  39. package/dist/observability.js +194 -106
  40. package/dist/openai-profile.d.ts +26 -22
  41. package/dist/openai-profile.js +1193 -157
  42. package/dist/openai-review-contract.d.ts +15 -6
  43. package/dist/openai-review-contract.js +714 -41
  44. package/dist/openai-review-oauth.d.ts +149 -2
  45. package/dist/openai-review-oauth.js +220 -6
  46. package/dist/prompts/register-all.d.ts +2 -0
  47. package/dist/prompts/register-all.js +34 -78
  48. package/dist/public-capability-contract.d.ts +39 -0
  49. package/dist/public-capability-contract.js +186 -0
  50. package/dist/redaction.d.ts +15 -0
  51. package/dist/redaction.js +83 -0
  52. package/dist/resources/register-all.js +154 -64
  53. package/dist/retry-after.d.ts +19 -0
  54. package/dist/retry-after.js +84 -0
  55. package/dist/server-composition.d.ts +19 -0
  56. package/dist/server-composition.js +74 -0
  57. package/dist/tool-exposure.d.ts +31 -27
  58. package/dist/tool-exposure.js +211 -87
  59. package/dist/tools/accountingClose.d.ts +14 -5
  60. package/dist/tools/accountingClose.js +41 -23
  61. package/dist/tools/audit_gl.js +8 -8
  62. package/dist/tools/backend-availability.d.ts +8 -29
  63. package/dist/tools/backend-availability.js +31 -30
  64. package/dist/tools/bank_rules.js +66 -15
  65. package/dist/tools/banking.js +5 -13
  66. package/dist/tools/clients.js +261 -15
  67. package/dist/tools/crm.js +22 -21
  68. package/dist/tools/deposits.js +114 -85
  69. package/dist/tools/einvoice.d.ts +16 -2
  70. package/dist/tools/einvoice.js +59 -12
  71. package/dist/tools/expenses.d.ts +16 -0
  72. package/dist/tools/expenses.js +47 -27
  73. package/dist/tools/fiscal.d.ts +3 -4
  74. package/dist/tools/fiscal.js +93 -60
  75. package/dist/tools/gestoria.js +15 -7
  76. package/dist/tools/hr.d.ts +2 -3
  77. package/dist/tools/hr.js +49 -20
  78. package/dist/tools/igic.d.ts +9 -8
  79. package/dist/tools/igic.js +34 -64
  80. package/dist/tools/impuesto_sociedades.d.ts +4 -5
  81. package/dist/tools/impuesto_sociedades.js +16 -37
  82. package/dist/tools/intelligence.js +166 -12
  83. package/dist/tools/invoices.js +224 -54
  84. package/dist/tools/kitchen.js +19 -9
  85. package/dist/tools/payroll.d.ts +5 -8
  86. package/dist/tools/payroll.js +19 -32
  87. package/dist/tools/permissions.d.ts +4 -7
  88. package/dist/tools/permissions.js +11 -14
  89. package/dist/tools/products.js +6 -6
  90. package/dist/tools/quotes.js +172 -26
  91. package/dist/tools/recurring.js +26 -25
  92. package/dist/tools/register-all.d.ts +2 -2
  93. package/dist/tools/register-all.js +9 -6
  94. package/dist/tools/search.d.ts +19 -0
  95. package/dist/tools/search.js +84 -0
  96. package/dist/tools/shared.d.ts +1445 -109
  97. package/dist/tools/shared.js +478 -102
  98. package/dist/tools/team.js +24 -28
  99. package/dist/tools/time.js +4 -12
  100. package/dist/tools/vendors.js +249 -15
  101. package/dist/tools/webhooks.js +35 -30
  102. package/dist/types.d.ts +31 -5
  103. package/dist/workflows/daily-operational-brief.d.ts +163 -0
  104. package/dist/workflows/daily-operational-brief.js +881 -0
  105. package/docs/agent-onboarding.json +391 -0
  106. package/package.json +49 -12
  107. package/dist/tools/onboarding.d.ts +0 -19
  108. package/dist/tools/onboarding.js +0 -64
@@ -4,11 +4,14 @@
4
4
  * This module is used by both the local (stdio) and remote (Cloudflare Workers)
5
5
  * MCP servers. It must NOT import concrete classes from either client — error
6
6
  * detection uses duck-typing (checking for `statusCode`/`errorCode` properties)
7
- * so it works regardless of which FrihetApiError class threw the error.
7
+ * so it works regardless of which FrihetApiError class threw the error. The
8
+ * client-owned predicate separately verifies local validation provenance.
8
9
  */
9
10
  import { z } from "zod/v4";
10
11
  import { log, logToolCall } from "../logger.js";
11
12
  import { recordToolCall } from "../metrics.js";
13
+ import { sanitizeServerRemediation } from "../redaction.js";
14
+ import { isLocalInputValidationError } from "../client.js";
12
15
  /* ------------------------------------------------------------------ */
13
16
  /* Safety annotations for MCP tool registrations */
14
17
  /* ------------------------------------------------------------------ */
@@ -19,15 +22,16 @@ export const DELETE_ANNOTATIONS = { readOnlyHint: false, destructiveHint: true,
19
22
  /* ------------------------------------------------------------------ */
20
23
  /* Content annotations for tool responses */
21
24
  /* ------------------------------------------------------------------ */
22
- /** List operations: useful to both user and assistant for navigation, medium priority. */
25
+ // MCP permits fractional priorities, but some clients (including Codex CLI
26
+ // 0.153.4) fail to decode them. Omit this optional hint on read responses while
27
+ // retaining their audience. See https://github.com/openai/codex/issues/38979.
28
+ /** List operations: useful to both user and assistant for navigation. */
23
29
  export const LIST_CONTENT_ANNOTATIONS = {
24
30
  audience: ["user", "assistant"],
25
- priority: 0.5,
26
31
  };
27
- /** Get/read operations: useful to both, higher priority as specifically requested data. */
32
+ /** Get/read operations: useful to both user and assistant. */
28
33
  export const GET_CONTENT_ANNOTATIONS = {
29
34
  audience: ["user", "assistant"],
30
- priority: 0.7,
31
35
  };
32
36
  /** Mutating operations (create/update/delete): primarily for the user, highest priority. */
33
37
  export const MUTATE_CONTENT_ANNOTATIONS = {
@@ -43,47 +47,100 @@ export const ERROR_CONTENT_ANNOTATIONS = {
43
47
  /* Response size guard */
44
48
  /* ------------------------------------------------------------------ */
45
49
  const MAX_RESPONSE_CHARS = 80_000; // ~20,000 tokens safety margin
46
- export function truncateResponse(text) {
50
+ export function truncateResponse(text, truncatedNotice = '\n\n[Response truncated. Use pagination (limit/offset) to retrieve smaller result sets.]') {
47
51
  if (text.length <= MAX_RESPONSE_CHARS)
48
52
  return text;
49
- return text.slice(0, MAX_RESPONSE_CHARS) +
50
- '\n\n[Response truncated. Use pagination (limit/offset) to retrieve smaller result sets.]';
53
+ return text.slice(0, MAX_RESPONSE_CHARS) + truncatedNotice;
51
54
  }
52
55
  function isFrihetApiError(error) {
53
56
  return (typeof error === "object" &&
54
57
  error !== null &&
55
58
  "statusCode" in error &&
56
59
  "errorCode" in error &&
57
- typeof error.statusCode === "number");
60
+ typeof error.statusCode === "number" &&
61
+ typeof error.errorCode === "string" &&
62
+ typeof error.message === "string");
58
63
  }
64
+ /** Error class names safe to echo from the unexpected-error path. */
65
+ const UNEXPECTED_ERROR_CLASSES = new Set([
66
+ "FrihetApiError",
67
+ "TypeError",
68
+ "SyntaxError",
69
+ "RangeError",
70
+ "AbortError",
71
+ "ZodError",
72
+ ]);
73
+ 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
74
  /**
60
75
  * Maps an error to a user-friendly MCP tool response with error annotations.
61
76
  * Emits structured log entries for all errors.
62
77
  */
63
78
  export function handleToolError(error, toolName) {
79
+ // NOTE: FrihetApiError carries the backend `requestId`, but it is NOT echoed
80
+ // here: `requestId` is in SENSITIVE_FIELD_NAMES (src/redaction.ts), whose
81
+ // values must not leave the process. Surfacing it needs an explicit policy
82
+ // change, not a side effect of error formatting.
64
83
  if (isFrihetApiError(error)) {
84
+ const loggedMessage = error.statusCode === 403 ? "Forbidden" : error.message;
85
+ const loggedCode = error.statusCode === 403 ? "forbidden" : error.errorCode;
65
86
  log({
66
87
  level: "error",
67
- message: `API error: ${error.statusCode} ${error.errorCode}: ${error.message}`,
88
+ message: `API error: ${error.statusCode} ${loggedCode}: ${loggedMessage}`,
68
89
  tool: toolName,
69
90
  operation: "tool_error",
70
91
  error: {
71
- message: error.message,
72
- code: error.errorCode,
92
+ message: loggedMessage,
93
+ code: loggedCode,
73
94
  statusCode: error.statusCode,
74
95
  },
75
96
  });
76
97
  const messages = {
77
98
  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.",
99
+ 401: "Authentication failed. Reconnect Frihet or check the configured credentials. / Autenticacion fallida. Vuelve a conectar Frihet o revisa las credenciales configuradas.",
100
+ 403: FORBIDDEN_FRIENDLY_MESSAGE,
80
101
  404: "Resource not found. / Recurso no encontrado.",
81
102
  405: "Method not allowed. / Metodo no permitido.",
82
103
  413: "Request body too large (max 1MB). / Cuerpo de la solicitud demasiado grande (max 1MB).",
83
104
  429: "Rate limit exceeded. Try again later. / Limite de peticiones excedido. Intenta mas tarde.",
84
105
  500: "Internal server error. Try again later. / Error interno del servidor.",
106
+ 503: "Frihet could not confirm the request outcome because the network connection failed.",
85
107
  };
86
- const friendlyMessage = messages[error.statusCode] ?? `API error ${error.statusCode}. Contact support if this persists.`;
108
+ const transportOutcomeUnknown = error.errorCode === "request_timeout" || error.errorCode === "network_error";
109
+ const operationOutcomeUnknown = transportOutcomeUnknown
110
+ || error.errorCode === "response_timeout"
111
+ || error.errorCode === "invalid_response_after_success"
112
+ || (error.statusCode >= 500 && error.statusCode <= 599)
113
+ || (error.statusCode === 409
114
+ && error.errorCode.toUpperCase() === "IDEMPOTENCY_REQUEST_IN_PROGRESS");
115
+ // Fixed booleans only: private output profiles must preserve definitive
116
+ // API denial semantics without forwarding provider text, codes or IDs.
117
+ const definitiveDenial = !operationOutcomeUnknown
118
+ && (error.statusCode === 404 || error.statusCode === 403);
119
+ const localValidationRejected = !operationOutcomeUnknown && error.statusCode === 400
120
+ && isLocalInputValidationError(error);
121
+ const requestRejected = !operationOutcomeUnknown && error.statusCode === 400
122
+ && !localValidationRejected;
123
+ let friendlyMessage;
124
+ if (operationOutcomeUnknown) {
125
+ friendlyMessage = "Frihet could not confirm whether this request completed. Read the current record before deciding whether to retry. / Frihet no pudo confirmar si la solicitud se completó. Consulta el estado actual antes de decidir si reintentar.";
126
+ }
127
+ else if (localValidationRejected) {
128
+ friendlyMessage = "Input validation failed before sending the request. No action was performed. Correct the ID and input parameters before retrying. / La validación falló antes de enviar la solicitud. No se realizó ninguna acción. Corrige el ID y los parámetros antes de reintentar.";
129
+ }
130
+ else if (error.statusCode === 413 && error.errorCode === "payload_too_large") {
131
+ friendlyMessage = `Document response too large: ${error.message} / Respuesta de documento demasiado grande.`;
132
+ }
133
+ else if (error.statusCode === 403) {
134
+ const guidance = sanitizeServerRemediation(error.detail, error.errorCode)
135
+ ?? sanitizeServerRemediation(error.message, error.errorCode);
136
+ friendlyMessage = guidance
137
+ ? `${FORBIDDEN_FRIENDLY_MESSAGE} Server guidance: ${guidance}`
138
+ : FORBIDDEN_FRIENDLY_MESSAGE;
139
+ }
140
+ else {
141
+ friendlyMessage = messages[error.statusCode]
142
+ ?? `API error ${error.statusCode}. Contact support if this persists.`;
143
+ }
87
144
  return {
88
145
  content: [
89
146
  {
@@ -93,6 +150,30 @@ export function handleToolError(error, toolName) {
93
150
  },
94
151
  ],
95
152
  isError: true,
153
+ ...(error.statusCode === 401 || operationOutcomeUnknown || definitiveDenial || localValidationRejected || requestRejected
154
+ ? {
155
+ _meta: {
156
+ ...(definitiveDenial
157
+ ? { [error.statusCode === 404 ? "io.frihet/resourceNotFound" : "io.frihet/accessDenied"]: true }
158
+ : {}),
159
+ ...(error.statusCode === 401
160
+ ? { "io.frihet/authenticationRequired": true }
161
+ : {}),
162
+ ...(localValidationRejected
163
+ ? { "io.frihet/localValidationRejected": true }
164
+ : {}),
165
+ ...(requestRejected
166
+ ? { "io.frihet/requestRejected": true }
167
+ : {}),
168
+ ...(operationOutcomeUnknown
169
+ ? { "io.frihet/operationOutcomeUnknown": true }
170
+ : {}),
171
+ ...(transportOutcomeUnknown
172
+ ? { "io.frihet/transportOutcomeUnknown": true }
173
+ : {}),
174
+ },
175
+ }
176
+ : {}),
96
177
  };
97
178
  }
98
179
  const errMsg = error instanceof Error ? error.stack ?? error.message : String(error);
@@ -106,8 +187,19 @@ export function handleToolError(error, toolName) {
106
187
  code: error instanceof Error ? error.name : "unknown",
107
188
  },
108
189
  });
190
+ // Class name only, from a fixed allowlist — never the message, stack, or an
191
+ // arbitrary `name` (a thrown object can set `name` to any identifier-shaped
192
+ // value, including a secret).
193
+ const errorClass = error instanceof Error && UNEXPECTED_ERROR_CLASSES.has(error.name)
194
+ ? error.name
195
+ : "UnknownError";
109
196
  return {
110
- content: [{ type: "text", text: "Error: An unexpected error occurred. Contact support if this persists.", annotations: ERROR_CONTENT_ANNOTATIONS }],
197
+ content: [{
198
+ type: "text",
199
+ text: `Error: An unexpected error occurred (${errorClass}). Contact support if this persists.`,
200
+ annotations: ERROR_CONTENT_ANNOTATIONS,
201
+ }],
202
+ structuredContent: { error: "unexpected_error", errorClass },
111
203
  isError: true,
112
204
  };
113
205
  }
@@ -123,15 +215,32 @@ export function formatPaginatedResponse(resourceName, response) {
123
215
  lines.push(JSON.stringify(item, null, 2));
124
216
  lines.push("---");
125
217
  }
126
- if (response.total > response.offset + response.data.length) {
218
+ const hasMoreByTotal = response.total > response.offset + response.data.length;
219
+ const isTruncatedScanWindow = Boolean(response.nextCursor && response.data.length < response.limit);
220
+ if (isTruncatedScanWindow && response.nextCursor) {
221
+ lines.push(`More results available beyond the current scan window. Continue with after='${response.nextCursor}' to fetch the next page.`);
222
+ }
223
+ else if (hasMoreByTotal) {
127
224
  const nextOffset = response.offset + response.data.length;
128
225
  lines.push(`More results available. Use offset=${nextOffset} to see the next page.`);
129
226
  if (response.nextCursor) {
130
227
  lines.push(`Cursor pagination: use after='${response.nextCursor}' for efficient cursor-based pagination.`);
131
228
  }
132
229
  }
230
+ else if (response.nextCursor) {
231
+ lines.push(`Cursor pagination: use after='${response.nextCursor}' for efficient cursor-based pagination.`);
232
+ }
133
233
  return truncateResponse(lines.join("\n"));
134
234
  }
235
+ /** Format an exhaustive list whose backend exposes no pagination controls. */
236
+ export function formatUnpaginatedListResponse(resourceName, response) {
237
+ const lines = [`Found ${response.total} ${resourceName}:`, ""];
238
+ for (const item of response.data) {
239
+ lines.push(JSON.stringify(item, null, 2));
240
+ lines.push("---");
241
+ }
242
+ return truncateResponse(lines.join("\n"), "\n\n[Response truncated.]");
243
+ }
135
244
  /**
136
245
  * Formats a single record for display.
137
246
  */
@@ -157,11 +266,22 @@ export function mutateContent(text) {
157
266
  return { type: "text", text, annotations: MUTATE_CONTENT_ANNOTATIONS };
158
267
  }
159
268
  /* ------------------------------------------------------------------ */
160
- /* Contextual enrichment — _suggestions and _warnings */
269
+ /* Contextual enrichment — text appended to `content`, not structuredContent */
161
270
  /* ------------------------------------------------------------------ */
162
271
  /**
163
- * Adds contextual suggestions and warnings to tool responses.
164
- * Helps AI agents know what to do next without guessing.
272
+ * Returns agent-facing suggestions and warnings for a tool call, formatted
273
+ * as plain text to be APPENDED to the human-readable `content` block.
274
+ *
275
+ * Why text, not structured fields: `structuredContent` is the strict
276
+ * outputSchema contract. Spreading `{ _suggestions, _warnings }` into
277
+ * structuredContent would make them undeclared enrichment (Inspector /
278
+ * conformance failures, drift in the OpenAI reviewed surface's frozen
279
+ * snapshot). The hints are agent-facing and deterministic from the
280
+ * emitted data, so they belong in the text the model reads, not in the
281
+ * JSON the schema validates. Pinned by
282
+ * src/__tests__/paginated-strict-output.test.ts (ERP #1580): the strict
283
+ * contract test asserts structuredContent never carries the old
284
+ * `_suggestions` / `_warnings` keys.
165
285
  */
166
286
  export function enrichResponse(resource, operation, data) {
167
287
  const suggestions = [];
@@ -237,10 +357,20 @@ export function enrichResponse(resource, operation, data) {
237
357
  if (operation === "delete") {
238
358
  warnings.push("This action cannot be undone / Esta accion no se puede deshacer");
239
359
  }
240
- return {
241
- ...(suggestions.length > 0 ? { _suggestions: suggestions } : {}),
242
- ...(warnings.length > 0 ? { _warnings: warnings } : {}),
243
- };
360
+ if (suggestions.length === 0 && warnings.length === 0)
361
+ return "";
362
+ const lines = [];
363
+ if (warnings.length > 0) {
364
+ lines.push("Warnings:");
365
+ for (const w of warnings)
366
+ lines.push(` - ${w}`);
367
+ }
368
+ if (suggestions.length > 0) {
369
+ lines.push("Suggested next steps:");
370
+ for (const s of suggestions)
371
+ lines.push(` - ${s}`);
372
+ }
373
+ return "\n" + lines.join("\n");
244
374
  }
245
375
  /* ------------------------------------------------------------------ */
246
376
  /* Output schemas (MCP spec 2025-11-25: outputSchema + structuredContent) */
@@ -268,6 +398,15 @@ export function paginatedOutput(itemSchema, opts = {}) {
268
398
  // so they keep the item schema's own required contract (an `{}` row there is
269
399
  // an API regression the schema must catch, not tolerate).
270
400
  const rowSchema = opts.projectable ? itemSchema.partial() : itemSchema;
401
+ // Canonical envelope contract: the emitted `structuredContent` of every
402
+ // paginated tool is exactly this object. The MCP SDK validates the runtime
403
+ // value against this schema on every call; anything not declared here is
404
+ // an Inspector/conformance failure. Pinned by
405
+ // src/__tests__/paginated-strict-output.test.ts (ERP #1580). The
406
+ // OpenAI reviewed surface's frozen snapshot (`openai-review-descriptor`)
407
+ // is a byte contract on this shape, so adding a field here would drift
408
+ // the snapshot — the right place for agent-facing hints is the `content`
409
+ // text block, not `structuredContent` (see enrichResponse).
271
410
  return z.object({
272
411
  data: z.array(rowSchema),
273
412
  total: z.number(),
@@ -281,6 +420,32 @@ export const deleteResultOutput = z.object({
281
420
  success: z.boolean(),
282
421
  id: z.string(),
283
422
  });
423
+ /**
424
+ * Schema for deletes of FISCAL DOCUMENTS (invoices, quotes) — the only two
425
+ * resources where "delete" is not always a delete.
426
+ *
427
+ * The backend refuses to destroy a non-draft invoice/quote (VeriFactu hash
428
+ * chain) and soft-CANCELS it instead, distinguishing the outcomes on the wire:
429
+ * 204 = row destroyed, 200 + body = document kept with status=cancelled
430
+ * (erp-main publicApi.ts `deleteResource`). `outcome` is what lets an agent
431
+ * tell the user which one actually happened (GAP-12).
432
+ *
433
+ * Deliberately NOT folded into {@link deleteResultOutput}: that one is shared by
434
+ * nine non-fiscal delete tools whose backend has no cancel branch, and widening
435
+ * it would drift their published output schema for no behavioural reason.
436
+ */
437
+ export const documentDeleteResultOutput = z.object({
438
+ success: z.boolean(),
439
+ id: z.string(),
440
+ /** "deleted" = row destroyed (204). "cancelled" = document kept, status=cancelled (200). */
441
+ outcome: z.enum(["deleted", "cancelled"]).optional(),
442
+ /** Document status after a soft cancel (always "cancelled" when present). */
443
+ status: z.string().optional(),
444
+ /** Status the document held before it was cancelled (e.g. "sent", "paid"). */
445
+ previousStatus: z.string().optional(),
446
+ /** Backend path that performed the soft cancellation, when reported. */
447
+ cancelledVia: z.string().optional(),
448
+ });
284
449
  /**
285
450
  * Permissive structured-output schema for tools whose API returns an open
286
451
  * `Record<string, unknown>` (summaries, single-object actions, VIES/portal
@@ -296,9 +461,12 @@ export function openObjectOutput(description) {
296
461
  }
297
462
  /* --- Per-resource item schemas ------------------------------------ */
298
463
  const lineItemSchema = z.object({
464
+ id: z.string().optional(),
299
465
  description: z.string(),
300
466
  quantity: z.number(),
301
467
  unitPrice: z.number(),
468
+ taxRate: z.number().optional(),
469
+ discount: z.number().optional(),
302
470
  });
303
471
  export const invoiceItemOutput = z.object({
304
472
  id: z.string(),
@@ -306,6 +474,7 @@ export const invoiceItemOutput = z.object({
306
474
  // draft — verified live. Kept documented but non-required so get/create/update
307
475
  // of a draft or a client-less invoice validates. `id` stays required: every
308
476
  // single-object read/create/update returns the doc id.
477
+ clientId: z.string().optional(),
309
478
  clientName: z.string().nullable().optional(),
310
479
  items: z.array(lineItemSchema).optional(),
311
480
  issueDate: z.string().optional(),
@@ -313,6 +482,7 @@ export const invoiceItemOutput = z.object({
313
482
  status: z.string().optional(),
314
483
  notes: z.string().optional(),
315
484
  taxRate: z.number().optional(),
485
+ discountRate: z.number().optional(),
316
486
  total: z.number().optional(),
317
487
  createdAt: z.string().optional(),
318
488
  updatedAt: z.string().optional(),
@@ -323,8 +493,10 @@ export const expenseItemOutput = z.object({
323
493
  amount: z.number(),
324
494
  category: z.string().optional(),
325
495
  date: z.string().optional(),
496
+ vendorId: z.string().optional(),
326
497
  vendor: z.string().optional(),
327
498
  taxDeductible: z.boolean().optional(),
499
+ paidDate: z.string().optional(),
328
500
  createdAt: z.string().optional(),
329
501
  updatedAt: z.string().optional(),
330
502
  }).passthrough();
@@ -340,6 +512,7 @@ export const clientItemOutput = z.object({
340
512
  name: z.string(),
341
513
  email: z.string().optional(),
342
514
  phone: z.string().optional(),
515
+ stage: z.string().optional(),
343
516
  taxId: z.string().optional(),
344
517
  address: addressOutputSchema,
345
518
  createdAt: z.string().optional(),
@@ -351,18 +524,24 @@ export const productItemOutput = z.object({
351
524
  unitPrice: z.number(),
352
525
  description: z.string().optional(),
353
526
  taxRate: z.number().optional(),
527
+ isActive: z.boolean().optional(),
354
528
  createdAt: z.string().optional(),
355
529
  updatedAt: z.string().optional(),
356
530
  }).passthrough();
357
531
  export const quoteItemOutput = z.object({
358
532
  id: z.string(),
359
533
  // Same draft/client-less tolerance as invoices (shared form engine).
534
+ clientId: z.string().optional(),
360
535
  clientName: z.string().nullable().optional(),
361
536
  items: z.array(lineItemSchema).optional(),
537
+ issueDate: z.string().optional(),
538
+ dueDate: z.string().optional(),
362
539
  validUntil: z.string().optional(),
363
540
  notes: z.string().optional(),
364
541
  status: z.string().optional(),
365
542
  total: z.number().optional(),
543
+ taxRate: z.number().optional(),
544
+ discountRate: z.number().optional(),
366
545
  createdAt: z.string().optional(),
367
546
  updatedAt: z.string().optional(),
368
547
  }).passthrough();
@@ -378,13 +557,31 @@ export const vendorItemOutput = z.object({
378
557
  }).passthrough();
379
558
  export const webhookItemOutput = z.object({
380
559
  id: z.string(),
560
+ userId: z.string().optional(),
561
+ name: z.string(),
381
562
  url: z.string(),
382
563
  events: z.array(z.string()),
383
- active: z.boolean().optional(),
384
- secret: z.string().optional(),
564
+ status: z.enum(["active", "inactive", "paused"]),
565
+ metadata: z.record(z.string(), z.unknown()).optional(),
566
+ hasSecret: z.boolean(),
567
+ pausedReason: z.string().optional(),
568
+ lastTriggeredAt: z.string().optional(),
385
569
  createdAt: z.string().optional(),
386
570
  updatedAt: z.string().optional(),
387
- }).passthrough();
571
+ _demo: z.literal(true).optional(),
572
+ _demoNotice: z.string().optional(),
573
+ }).strict();
574
+ /** Create alone may echo the caller-supplied signing secret once. */
575
+ export const webhookCreateOutput = webhookItemOutput.extend({
576
+ secret: z.string().optional(),
577
+ }).strict();
578
+ /** `/webhooks` has no backend pagination contract. */
579
+ export const webhookListOutput = z.object({
580
+ data: z.array(webhookItemOutput),
581
+ total: z.number(),
582
+ _demo: z.literal(true).optional(),
583
+ _demoNotice: z.string().optional(),
584
+ }).strict();
388
585
  /* --- CRM subcollection item schemas -------------------------------- */
389
586
  export const contactItemOutput = z.object({
390
587
  id: z.string(),
@@ -401,10 +598,14 @@ export const activityItemOutput = z.object({
401
598
  type: z.string(),
402
599
  title: z.string(),
403
600
  description: z.string().optional(),
404
- date: z.string().optional(),
601
+ metadata: z.record(z.string(), z.string()).optional(),
602
+ timestamp: z.string(),
603
+ createdBy: z.enum(["system", "user"]).optional(),
405
604
  createdAt: z.string().optional(),
406
605
  updatedAt: z.string().optional(),
407
- }).passthrough();
606
+ _demo: z.literal(true).optional(),
607
+ _demoNotice: z.string().optional(),
608
+ }).strict();
408
609
  export const noteItemOutput = z.object({
409
610
  id: z.string(),
410
611
  content: z.string(),
@@ -507,7 +708,9 @@ export const posTerminalItemOutput = z.object({
507
708
  label: z.string().optional(),
508
709
  deviceType: z.string().optional(),
509
710
  locationId: z.string().optional(),
510
- status: z.enum(["online", "offline", "unknown"]).optional(),
711
+ // Raw stored value: Frihet-ERP apps/erp/modules/pos/schema/collections.ts
712
+ // (posTerminals.status), returned verbatim by publicApi/families/pos.ts.
713
+ status: z.enum(["active", "paused", "retired"]).optional(),
511
714
  stripeReaderId: z.string().optional(),
512
715
  createdAt: z.string().optional(),
513
716
  updatedAt: z.string().optional(),
@@ -543,6 +746,7 @@ export const actionResultOutput = z.object({
543
746
  success: z.boolean().optional(),
544
747
  id: z.string().optional(),
545
748
  message: z.string().optional(),
749
+ messageId: z.string().optional(),
546
750
  // Anti-envelope tripwire: with everything optional + passthrough, a raw
547
751
  // {data, meta} API envelope would otherwise VALIDATE — silently re-hiding the
548
752
  // exact bug this PR fixes if a client method ever regresses to raw request().
@@ -568,6 +772,9 @@ export const creditNoteResultOutput = z.object({
568
772
  originalInvoiceId: z.string().optional(),
569
773
  reason: z.string().optional(),
570
774
  fullCredit: z.boolean().optional(),
775
+ status: z.string().optional(),
776
+ rectificationMethod: z.string().optional(),
777
+ totalCredited: z.number().optional(),
571
778
  }).passthrough().optional(),
572
779
  // Anti-envelope tripwire — see actionResultOutput.
573
780
  data: z.never().optional(),
@@ -729,11 +936,32 @@ export const recurringInvoiceItemOutput = z.object({
729
936
  createdAt: z.string().optional(),
730
937
  updatedAt: z.string().optional(),
731
938
  }).passthrough();
732
- /** Schema for PDF results */
939
+ /**
940
+ * Schema for the MIME-discriminated `get_invoice_einvoice` artifact.
941
+ *
942
+ * Stored UBL/Facturae artifacts return strict UTF-8 `xml`; Factur-X artifacts
943
+ * return PDF bytes as `base64`. Identity, MIME and byte size are always present.
944
+ */
945
+ export const einvoiceResultOutput = z.object({
946
+ id: z.string(),
947
+ contentType: z.string(),
948
+ sizeBytes: z.number().int().nonnegative(),
949
+ xml: z.string().optional(),
950
+ base64: z.string().optional(),
951
+ filename: z.string().optional(),
952
+ }).passthrough().refine((value) => (typeof value.xml === "string") !== (typeof value.base64 === "string"), { message: "Exactly one of xml or base64 is required" });
953
+ /**
954
+ * Schema for `get_invoice_pdf` results (#1393 — content-type-aware).
955
+ *
956
+ * The backend serves raw `application/pdf` bytes. MCP `structuredContent` is
957
+ * JSON-only, so callers receive bounded base64 plus request identity and size.
958
+ */
733
959
  export const pdfResultOutput = z.object({
734
960
  id: z.string(),
735
- url: z.string().optional(),
736
- contentType: z.string().optional(),
961
+ contentType: z.string(),
962
+ sizeBytes: z.number().int().nonnegative(),
963
+ base64: z.string(),
964
+ filename: z.string().optional(),
737
965
  }).passthrough();
738
966
  /* --- Time summary schema --------------------------------------------------- */
739
967
  const timeSummaryGroupItemSchema = z.object({
@@ -756,14 +984,18 @@ export const timeSummaryOutput = z.object({
756
984
  /* --- Team member item schema ----------------------------------------------- */
757
985
  export const teamMemberItemOutput = z.object({
758
986
  id: z.string(),
759
- name: z.string().optional(),
760
- email: z.string(),
761
- role: z.enum(["owner", "admin", "member", "viewer"]).optional(),
762
- 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(),
987
+ name: z.string().nullable().optional(),
988
+ email: z.string().nullable().optional(),
989
+ // Frihet-ERP functions/src/publicApi.ts team listing: invitation rows whose
990
+ // stored role is not assignable are rendered with `role: null` and
991
+ // `status: 'invalid'`; past-expiry invitations surface as `expired`.
992
+ role: z.enum(["owner", "admin", "editor", "accountant", "viewer"]).nullable().optional(),
993
+ status: z.enum(["active", "pending", "expired", "invalid"]).optional(),
994
+ invitedAt: z.string().nullable().optional(),
995
+ joinedAt: z.string().nullable().optional(),
996
+ createdAt: z.string().nullable().optional(),
997
+ updatedAt: z.string().nullable().optional(),
998
+ expiresAt: z.string().nullable().optional(),
767
999
  }).passthrough();
768
1000
  /* --- Gestoria item schemas ------------------------------------------------- */
769
1001
  /**
@@ -855,7 +1087,13 @@ export const gestoriaAgingConsolidatedOutput = z.object({
855
1087
  }).passthrough()),
856
1088
  generatedAt: z.string().optional(),
857
1089
  }).passthrough();
858
- /* --- D4-B: HR / Webhook test / Payroll / Onboarding / Permissions / Period close --- */
1090
+ /* --- D4-B: HR / Webhook test / Payroll / Permissions / Period close ---
1091
+ * Onboarding (onboarding_status, onboarding_persona_set) was withdrawn via
1092
+ * issue #124 because Frihet-ERP has no /v1/onboarding/* family. The schemas
1093
+ * lived here previously; the registration in register-all.ts and the file
1094
+ * src/tools/onboarding.ts are also gone. If a future Frihet-ERP release
1095
+ * ships the family, restore src/tools/onboarding.ts, re-add the export
1096
+ * symbols here, and re-import them in register-all.ts. */
859
1097
  /** Leave/PTO request — backend `/v1/leaves`. */
860
1098
  export const leaveRequestItemOutput = z.object({
861
1099
  id: z.string(),
@@ -885,19 +1123,40 @@ export const attendanceEntryItemOutput = z.object({
885
1123
  createdAt: z.string().optional(),
886
1124
  updatedAt: z.string().optional(),
887
1125
  }).passthrough();
888
- /** Overtime aggregated report. */
1126
+ /** Exact ERP DTO for `GET /v1/time-entries/overtime`. */
889
1127
  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(),
1128
+ period: z.string().regex(/^\d{4}(-(0[1-9]|1[0-2]))?$/),
1129
+ employeeId: z.string().nullable(),
1130
+ recordCount: z.number().int().nonnegative(),
1131
+ dailyOvertime: z.array(z.object({
1132
+ date: z.string(),
1133
+ minutes: z.number().nonnegative(),
1134
+ exceedsDaily: z.boolean(),
1135
+ })),
1136
+ weeklyOvertime: z.array(z.object({
1137
+ weekStart: z.string(),
1138
+ totalMinutes: z.number().nonnegative(),
1139
+ overtimeMinutes: z.number().nonnegative(),
1140
+ })),
1141
+ monthlyTotal: z.object({
1142
+ workedMinutes: z.number().nonnegative(),
1143
+ overtimeMinutes: z.number().nonnegative(),
1144
+ regularMinutes: z.number().nonnegative(),
1145
+ }),
1146
+ annualOvertimeHours: z.number().nonnegative(),
1147
+ alerts: z.array(z.object({
1148
+ type: z.enum(["daily_exceeded", "weekly_exceeded", "annual_approaching", "annual_exceeded", "missing_break"]),
1149
+ severity: z.enum(["warning", "critical"]),
1150
+ message: z.string(),
1151
+ date: z.string(),
1152
+ })),
1153
+ data: z.never().optional(),
1154
+ meta: z.never().optional(),
1155
+ totalRegularHours: z.never().optional(),
1156
+ totalOvertimeHours: z.never().optional(),
1157
+ estimatedCostEur: z.never().optional(),
1158
+ byEmployee: z.never().optional(),
1159
+ generatedAt: z.never().optional(),
901
1160
  }).passthrough();
902
1161
  /** Anomaly detection record — backend `/v1/anomalies`. */
903
1162
  export const anomalyItemOutput = z.object({
@@ -922,62 +1181,137 @@ export const webhookTestResultOutput = z.object({
922
1181
  attemptedAt: z.string().optional(),
923
1182
  error: z.string().optional(),
924
1183
  }).passthrough();
925
- /** Payroll export result — backend `/v1/payroll/prep/export`. */
1184
+ /** Exact normalized dataset DTO from `GET /v1/payroll/prep/export`. */
926
1185
  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(),
1186
+ month: z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/),
1187
+ format: z.enum(["a3", "contasol", "sage", "siltra"]),
1188
+ employees: z.array(z.object({
1189
+ id: z.string(),
1190
+ name: z.string(),
1191
+ nss: z.string(),
1192
+ irpfPct: z.number(),
1193
+ salaryGrossAnnual: z.number(),
1194
+ convenioColectivo: z.string().nullable(),
1195
+ categoriaProfesional: z.string().nullable(),
1196
+ prorrateoPagasExtras: z.boolean().nullable(),
1197
+ formaPago: z.enum(["transferencia", "efectivo", "cheque"]),
1198
+ iban: z.string().nullable(),
1199
+ })),
1200
+ summary: z.object({
1201
+ exportedCount: z.number().int().nonnegative(),
1202
+ skippedNotReady: z.number().int().nonnegative(),
1203
+ totalGrossAnnual: z.number(),
1204
+ }),
1205
+ data: z.never().optional(),
1206
+ meta: z.never().optional(),
1207
+ fileUrl: z.never().optional(),
1208
+ filename: z.never().optional(),
1209
+ rowCount: z.never().optional(),
1210
+ generatedAt: z.never().optional(),
933
1211
  }).passthrough();
934
- /** Payroll checklist — backend `/v1/payroll/prep/employees`. */
1212
+ /** Exact readiness DTO from `GET /v1/payroll/prep/employees`. */
935
1213
  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(),
1214
+ month: z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/),
940
1215
  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(),
947
- }).passthrough();
948
- /** Onboarding workspace state — backend `/v1/onboarding/status`. */
949
- export const onboardingStatusOutput = z.object({
950
- workspaceId: z.string().optional(),
951
- persona: z.enum(["autonomo", "empresa", "agencia", "gestoria"]).optional(),
952
- completedSteps: z.array(z.string()).optional(),
953
- pendingSteps: z.array(z.string()).optional(),
954
- percentComplete: z.number().min(0).max(100).optional(),
955
- startedAt: z.string().optional(),
956
- completedAt: z.string().optional(),
957
- }).passthrough();
958
- /** Onboarding persona update result. */
959
- export const onboardingPersonaResultOutput = z.object({
960
- workspaceId: z.string().optional(),
961
- persona: z.enum(["autonomo", "empresa", "agencia", "gestoria"]),
962
- updatedAt: z.string().optional(),
1216
+ id: z.string(),
1217
+ name: z.string(),
1218
+ status: z.enum(["active", "onLeave"]),
1219
+ hasPayrollProfile: z.boolean(),
1220
+ ready: z.boolean(),
1221
+ missingFields: z.array(z.enum(["nss", "irpfPct", "salaryGrossAnnual", "formaPago", "iban"])),
1222
+ reviewedForMonth: z.string().nullable(),
1223
+ reviewedThisMonth: z.boolean(),
1224
+ reviewedAt: z.string().nullable(),
1225
+ })),
1226
+ summary: z.object({
1227
+ total: z.number().int().nonnegative(),
1228
+ ready: z.number().int().nonnegative(),
1229
+ notReady: z.number().int().nonnegative(),
1230
+ reviewedThisMonth: z.number().int().nonnegative(),
1231
+ }),
1232
+ data: z.never().optional(),
1233
+ meta: z.never().optional(),
1234
+ totalEmployees: z.never().optional(),
1235
+ readyEmployees: z.never().optional(),
1236
+ missingEmployees: z.never().optional(),
1237
+ generatedAt: z.never().optional(),
963
1238
  }).passthrough();
964
- /** Permissions matrix — backend `/v1/permissions/matrix`. */
1239
+ const rbacRoleOutput = z.enum(["owner", "admin", "manager", "sales", "accountant", "employee", "viewer"]);
1240
+ const rbacResourceOutput = z.enum([
1241
+ "workspace", "invoices", "quotes", "expenses", "clients", "products", "accounting",
1242
+ "people", "payroll", "integrations", "banking", "settings", "audit_log", "billing",
1243
+ ]);
1244
+ const rbacActionOutput = z.enum(["read", "create", "update", "delete", "bulk_delete", "export"]);
1245
+ const rbacActionsOutput = z.array(rbacActionOutput);
1246
+ // Matrix rows intentionally omit denied resources. Keep every known resource
1247
+ // explicit and optional so sparse backend rows validate consistently.
1248
+ const rbacResourceGrantsOutput = z.object({
1249
+ workspace: rbacActionsOutput.optional(),
1250
+ invoices: rbacActionsOutput.optional(),
1251
+ quotes: rbacActionsOutput.optional(),
1252
+ expenses: rbacActionsOutput.optional(),
1253
+ clients: rbacActionsOutput.optional(),
1254
+ products: rbacActionsOutput.optional(),
1255
+ accounting: rbacActionsOutput.optional(),
1256
+ people: rbacActionsOutput.optional(),
1257
+ payroll: rbacActionsOutput.optional(),
1258
+ integrations: rbacActionsOutput.optional(),
1259
+ banking: rbacActionsOutput.optional(),
1260
+ settings: rbacActionsOutput.optional(),
1261
+ audit_log: rbacActionsOutput.optional(),
1262
+ billing: rbacActionsOutput.optional(),
1263
+ }).strict();
1264
+ const rbacMatrixOutput = z.object({
1265
+ owner: rbacResourceGrantsOutput,
1266
+ admin: rbacResourceGrantsOutput,
1267
+ manager: rbacResourceGrantsOutput,
1268
+ sales: rbacResourceGrantsOutput,
1269
+ accountant: rbacResourceGrantsOutput,
1270
+ employee: rbacResourceGrantsOutput,
1271
+ viewer: rbacResourceGrantsOutput,
1272
+ }).strict();
1273
+ /**
1274
+ * Documented RBAC-model snapshot — backend `/v1/permissions/matrix`.
1275
+ * This is not an exhaustive report of runtime authorization or Firestore rules.
1276
+ */
965
1277
  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(),
1278
+ roles: z.array(rbacRoleOutput).describe("Role names represented by this documented RBAC model"),
1279
+ resources: z.array(rbacResourceOutput).describe("Resource names represented by this documented RBAC model"),
1280
+ actions: z.array(rbacActionOutput).describe("Action names represented by this documented RBAC model"),
1281
+ legacyAliases: z.record(z.string(), rbacRoleOutput).describe("Legacy role name to canonical RBAC-model role"),
1282
+ matrix: rbacMatrixOutput
1283
+ .describe("Documented role/resource/action grants; not a runtime authorization guarantee"),
1284
+ source: z.string().describe("Backend explanation of the model's source and limitations"),
972
1285
  }).passthrough();
973
- /** Caller's own permissions — backend `/v1/permissions/me`. */
1286
+ /**
1287
+ * Caller's RBAC-model row and API-key scope state — backend `/v1/permissions/me`.
1288
+ * Known denials are intentionally non-exhaustive; a backend 403 is authoritative.
1289
+ */
974
1290
  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(),
1291
+ role: rbacRoleOutput.describe("Backwards-compatible RBAC-model role"),
1292
+ isOwner: z.boolean().describe("Backwards-compatible RBAC-model owner flag"),
1293
+ resources: rbacResourceGrantsOutput.describe("Backwards-compatible RBAC-model resource grants"),
1294
+ scopes: z.array(z.string()).describe("Backwards-compatible RBAC capabilities; not API-key scopes"),
1295
+ legacyFieldSemantics: z.object({
1296
+ resources: z.literal("rbacResources"),
1297
+ scopes: z.literal("rbacCapabilities"),
1298
+ }).describe("Explicit meaning of the backwards-compatible resources/scopes fields"),
1299
+ rbac: z.object({
1300
+ role: rbacRoleOutput,
1301
+ isOwner: z.boolean(),
1302
+ resources: rbacResourceGrantsOutput,
1303
+ capabilities: z.array(z.string()),
1304
+ }).describe("Documented RBAC-model view; not an exhaustive authorization prediction"),
1305
+ apiKeyScopes: z.array(z.string()).describe("Actual scopes stored for this API key"),
1306
+ apiKeyUnrestricted: z.boolean().describe("Whether empty/absent stored scopes use unrestricted-key semantics"),
1307
+ denied: z.object({
1308
+ einvoice: z.boolean(),
1309
+ }).describe("Known API-key-scope denials only"),
1310
+ deniedSemantics: z.string().describe("Backend limitation statement for denied"),
1311
+ notIncluded: z.array(z.enum(["featureFlags", "deployedEndpoints"]))
1312
+ .describe("Authorization surfaces this endpoint cannot truthfully report"),
979
1313
  }).passthrough();
980
- /** Accounting period state — backend `/v1/periods/*`. */
1314
+ /** Legacy demo/write result retained for period_close and period_reopen only. */
981
1315
  export const periodStatusOutput = z.object({
982
1316
  id: z.string(),
983
1317
  type: z.enum(["monthly", "quarterly"]).optional(),
@@ -991,6 +1325,43 @@ export const periodStatusOutput = z.object({
991
1325
  createdAt: z.string().optional(),
992
1326
  updatedAt: z.string().optional(),
993
1327
  }).passthrough();
1328
+ /** Exact ERP DTO for `GET /v1/periods/current` and `GET /v1/periods/YYYY`. */
1329
+ export const currentPeriodOutput = z.object({
1330
+ fiscalYear: z.string().regex(/^\d{4}$/),
1331
+ fiscalYearStart: z.string().regex(/^\d{2}-\d{2}$/),
1332
+ status: z.enum(["open", "closed"]),
1333
+ dateRange: z.object({
1334
+ from: z.string(),
1335
+ to: z.string(),
1336
+ }),
1337
+ closing: z.object({
1338
+ status: z.string(),
1339
+ closedAt: z.string().nullable(),
1340
+ netIncome: z.number().nullable(),
1341
+ totalIncome: z.number().nullable(),
1342
+ totalExpenses: z.number().nullable(),
1343
+ journalEntries: z.number().nullable(),
1344
+ }).nullable(),
1345
+ data: z.never().optional(),
1346
+ meta: z.never().optional(),
1347
+ id: z.never().optional(),
1348
+ type: z.never().optional(),
1349
+ startDate: z.never().optional(),
1350
+ endDate: z.never().optional(),
1351
+ closedAt: z.never().optional(),
1352
+ closedBy: z.never().optional(),
1353
+ reopenedAt: z.never().optional(),
1354
+ reopenReason: z.never().optional(),
1355
+ generatedAt: z.never().optional(),
1356
+ }).passthrough();
1357
+ /** Mark a local confirmation refusal without parsing or exposing provider text. */
1358
+ export function confirmationRequiredError(message) {
1359
+ return {
1360
+ content: [{ type: "text", text: message, annotations: ERROR_CONTENT_ANNOTATIONS }],
1361
+ isError: true,
1362
+ _meta: { "io.frihet/confirmationRequired": true },
1363
+ };
1364
+ }
994
1365
  /**
995
1366
  * Wraps a tool handler to automatically log execution time, success/failure,
996
1367
  * and record metrics. Catches errors and routes them through handleToolError.
@@ -1014,7 +1385,12 @@ export async function withToolLogging(toolName, fn) {
1014
1385
  }
1015
1386
  catch (error) {
1016
1387
  const durationMs = Math.round(Date.now() - startTime);
1017
- logToolCall(toolName, startTime, false, error instanceof Error ? error : new Error(String(error)));
1388
+ const errorForLog = isFrihetApiError(error) && error.statusCode === 403
1389
+ ? Object.assign(new Error("Forbidden"), { statusCode: 403, errorCode: "forbidden" })
1390
+ : error instanceof Error
1391
+ ? error
1392
+ : new Error(String(error));
1393
+ logToolCall(toolName, startTime, false, errorForLog);
1018
1394
  recordToolCall(toolName, durationMs, false);
1019
1395
  return handleToolError(error, toolName);
1020
1396
  }