@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.
- package/README.md +168 -82
- package/dist/agent-onboarding.d.ts +98 -0
- package/dist/agent-onboarding.js +275 -0
- package/dist/api-origin.d.ts +9 -0
- package/dist/api-origin.js +41 -0
- package/dist/canary/mcp-harness.d.ts +77 -0
- package/dist/canary/mcp-harness.js +552 -0
- package/dist/capability-truth.d.ts +29 -0
- package/dist/capability-truth.js +262 -0
- package/dist/claude-candidate-contract.d.ts +482 -0
- package/dist/claude-candidate-contract.js +87 -0
- package/dist/claude-output-privacy.d.ts +17 -0
- package/dist/claude-output-privacy.js +240 -0
- package/dist/claude-profile.d.ts +18 -0
- package/dist/claude-profile.js +260 -0
- package/dist/client-interface.d.ts +47 -33
- package/dist/client.d.ts +172 -40
- package/dist/client.js +708 -165
- package/dist/demo-client.d.ts +41 -32
- package/dist/demo-client.js +202 -31
- package/dist/demo-fixtures.d.ts +3 -3
- package/dist/demo-fixtures.js +7 -7
- package/dist/deposit-contract.d.ts +29 -0
- package/dist/deposit-contract.js +76 -0
- package/dist/fetch-no-redirect.d.ts +3 -0
- package/dist/fetch-no-redirect.js +16 -0
- package/dist/fiscal-aliases.d.ts +44 -0
- package/dist/fiscal-aliases.js +62 -0
- package/dist/fiscal-period.d.ts +26 -0
- package/dist/fiscal-period.js +31 -0
- package/dist/google-profile.d.ts +19 -0
- package/dist/google-profile.js +122 -0
- package/dist/index.js +68 -59
- package/dist/logger.d.ts +2 -2
- package/dist/logger.js +140 -8
- package/dist/muse-profile.d.ts +19 -0
- package/dist/muse-profile.js +122 -0
- package/dist/observability.d.ts +18 -29
- package/dist/observability.js +194 -106
- package/dist/openai-profile.d.ts +26 -22
- package/dist/openai-profile.js +1193 -157
- package/dist/openai-review-contract.d.ts +15 -6
- package/dist/openai-review-contract.js +714 -41
- package/dist/openai-review-oauth.d.ts +149 -2
- package/dist/openai-review-oauth.js +220 -6
- package/dist/prompts/register-all.d.ts +2 -0
- package/dist/prompts/register-all.js +34 -78
- package/dist/public-capability-contract.d.ts +39 -0
- package/dist/public-capability-contract.js +186 -0
- package/dist/redaction.d.ts +15 -0
- package/dist/redaction.js +83 -0
- package/dist/resources/register-all.js +154 -64
- package/dist/retry-after.d.ts +19 -0
- package/dist/retry-after.js +84 -0
- package/dist/server-composition.d.ts +19 -0
- package/dist/server-composition.js +74 -0
- package/dist/tool-exposure.d.ts +31 -27
- package/dist/tool-exposure.js +211 -87
- package/dist/tools/accountingClose.d.ts +14 -5
- package/dist/tools/accountingClose.js +41 -23
- package/dist/tools/audit_gl.js +8 -8
- package/dist/tools/backend-availability.d.ts +8 -29
- package/dist/tools/backend-availability.js +31 -30
- package/dist/tools/bank_rules.js +66 -15
- package/dist/tools/banking.js +5 -13
- package/dist/tools/clients.js +261 -15
- package/dist/tools/crm.js +22 -21
- package/dist/tools/deposits.js +114 -85
- package/dist/tools/einvoice.d.ts +16 -2
- package/dist/tools/einvoice.js +59 -12
- package/dist/tools/expenses.d.ts +16 -0
- package/dist/tools/expenses.js +47 -27
- package/dist/tools/fiscal.d.ts +3 -4
- package/dist/tools/fiscal.js +93 -60
- package/dist/tools/gestoria.js +15 -7
- package/dist/tools/hr.d.ts +2 -3
- package/dist/tools/hr.js +49 -20
- package/dist/tools/igic.d.ts +9 -8
- package/dist/tools/igic.js +34 -64
- package/dist/tools/impuesto_sociedades.d.ts +4 -5
- package/dist/tools/impuesto_sociedades.js +16 -37
- package/dist/tools/intelligence.js +166 -12
- package/dist/tools/invoices.js +224 -54
- package/dist/tools/kitchen.js +19 -9
- package/dist/tools/payroll.d.ts +5 -8
- package/dist/tools/payroll.js +19 -32
- package/dist/tools/permissions.d.ts +4 -7
- package/dist/tools/permissions.js +11 -14
- package/dist/tools/products.js +6 -6
- package/dist/tools/quotes.js +172 -26
- package/dist/tools/recurring.js +26 -25
- package/dist/tools/register-all.d.ts +2 -2
- package/dist/tools/register-all.js +9 -6
- package/dist/tools/search.d.ts +19 -0
- package/dist/tools/search.js +84 -0
- package/dist/tools/shared.d.ts +1445 -109
- package/dist/tools/shared.js +478 -102
- package/dist/tools/team.js +24 -28
- package/dist/tools/time.js +4 -12
- package/dist/tools/vendors.js +249 -15
- package/dist/tools/webhooks.js +35 -30
- package/dist/types.d.ts +31 -5
- package/dist/workflows/daily-operational-brief.d.ts +163 -0
- package/dist/workflows/daily-operational-brief.js +881 -0
- package/docs/agent-onboarding.json +391 -0
- package/package.json +49 -12
- package/dist/tools/onboarding.d.ts +0 -19
- package/dist/tools/onboarding.js +0 -64
package/dist/tools/shared.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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} ${
|
|
88
|
+
message: `API error: ${error.statusCode} ${loggedCode}: ${loggedMessage}`,
|
|
68
89
|
tool: toolName,
|
|
69
90
|
operation: "tool_error",
|
|
70
91
|
error: {
|
|
71
|
-
message:
|
|
72
|
-
code:
|
|
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.
|
|
79
|
-
403:
|
|
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
|
|
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: [{
|
|
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
|
-
|
|
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 —
|
|
269
|
+
/* Contextual enrichment — text appended to `content`, not structuredContent */
|
|
161
270
|
/* ------------------------------------------------------------------ */
|
|
162
271
|
/**
|
|
163
|
-
*
|
|
164
|
-
*
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
384
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
736
|
-
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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 /
|
|
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
|
-
/**
|
|
1126
|
+
/** Exact ERP DTO for `GET /v1/time-entries/overtime`. */
|
|
889
1127
|
export const overtimeReportOutput = z.object({
|
|
890
|
-
period: z.string(),
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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
|
-
/**
|
|
1184
|
+
/** Exact normalized dataset DTO from `GET /v1/payroll/prep/export`. */
|
|
926
1185
|
export const payrollExportOutput = z.object({
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
942
|
-
|
|
943
|
-
status: z.enum(["
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
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
|
-
|
|
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(
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
}
|