@frihet/mcp-server 1.16.6 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +92 -31
  2. package/dist/agent-onboarding.d.ts +98 -0
  3. package/dist/agent-onboarding.js +275 -0
  4. package/dist/api-origin.d.ts +9 -0
  5. package/dist/api-origin.js +41 -0
  6. package/dist/canary/mcp-harness.d.ts +77 -0
  7. package/dist/canary/mcp-harness.js +549 -0
  8. package/dist/capability-truth.d.ts +29 -0
  9. package/dist/capability-truth.js +254 -0
  10. package/dist/client-interface.d.ts +22 -14
  11. package/dist/client.d.ts +114 -18
  12. package/dist/client.js +488 -56
  13. package/dist/demo-client.d.ts +16 -13
  14. package/dist/demo-client.js +171 -17
  15. package/dist/demo-fixtures.d.ts +3 -3
  16. package/dist/demo-fixtures.js +7 -7
  17. package/dist/fiscal-aliases.d.ts +44 -0
  18. package/dist/fiscal-aliases.js +62 -0
  19. package/dist/index.js +41 -56
  20. package/dist/logger.d.ts +2 -2
  21. package/dist/logger.js +140 -8
  22. package/dist/observability.d.ts +18 -29
  23. package/dist/observability.js +192 -105
  24. package/dist/openai-profile.d.ts +25 -21
  25. package/dist/openai-profile.js +1166 -156
  26. package/dist/openai-review-contract.d.ts +15 -6
  27. package/dist/openai-review-contract.js +715 -41
  28. package/dist/openai-review-oauth.d.ts +47 -0
  29. package/dist/openai-review-oauth.js +73 -3
  30. package/dist/prompts/register-all.d.ts +2 -0
  31. package/dist/prompts/register-all.js +2 -0
  32. package/dist/public-capability-contract.d.ts +39 -0
  33. package/dist/public-capability-contract.js +186 -0
  34. package/dist/redaction.d.ts +10 -0
  35. package/dist/redaction.js +74 -0
  36. package/dist/resources/register-all.js +6 -12
  37. package/dist/server-composition.d.ts +16 -0
  38. package/dist/server-composition.js +44 -0
  39. package/dist/tool-exposure.d.ts +25 -22
  40. package/dist/tool-exposure.js +199 -77
  41. package/dist/tools/accountingClose.d.ts +14 -5
  42. package/dist/tools/accountingClose.js +41 -23
  43. package/dist/tools/backend-availability.d.ts +3 -3
  44. package/dist/tools/backend-availability.js +3 -3
  45. package/dist/tools/clients.js +9 -9
  46. package/dist/tools/crm.js +22 -21
  47. package/dist/tools/deposits.js +30 -11
  48. package/dist/tools/einvoice.d.ts +1 -1
  49. package/dist/tools/expenses.d.ts +16 -0
  50. package/dist/tools/expenses.js +47 -27
  51. package/dist/tools/hr.d.ts +2 -3
  52. package/dist/tools/hr.js +8 -9
  53. package/dist/tools/intelligence.js +2 -2
  54. package/dist/tools/invoices.js +150 -47
  55. package/dist/tools/payroll.d.ts +5 -8
  56. package/dist/tools/payroll.js +19 -32
  57. package/dist/tools/permissions.d.ts +4 -7
  58. package/dist/tools/permissions.js +11 -14
  59. package/dist/tools/products.js +6 -6
  60. package/dist/tools/quotes.js +109 -24
  61. package/dist/tools/shared.d.ts +1431 -75
  62. package/dist/tools/shared.js +386 -76
  63. package/dist/tools/team.js +18 -14
  64. package/dist/tools/vendors.js +9 -9
  65. package/dist/tools/webhooks.js +35 -30
  66. package/dist/types.d.ts +28 -4
  67. package/docs/agent-onboarding.json +381 -0
  68. package/package.json +30 -7
@@ -15,15 +15,18 @@
15
15
  * The full MCP server (157 business tools + MCP extras) remains available for Claude, Cursor,
16
16
  * Windsurf, Cline, Codex, and all other MCP clients.
17
17
  *
18
- * OpenAI-safe mode: 53 reviewed business tools, 0 prompts, 0 resources, 0 government IDs in I/O.
18
+ * OpenAI-safe mode: 33 reviewed business tools, 0 prompts, 0 resources, and no
19
+ * dedicated government-ID fields in I/O.
19
20
  * The full MCP surface remains available outside FRIHET_OPENAI_MODE.
20
21
  *
21
22
  * @see https://developers.openai.com/apps-sdk/app-submission-guidelines
22
23
  */
24
+ import { getLiteralValue, getObjectShape, } from "@modelcontextprotocol/sdk/server/zod-compat.js";
23
25
  import { z } from "zod/v4";
26
+ import { correctToolAnnotations } from "./capability-truth.js";
24
27
  import { MCP_RESOURCE_COUNT } from "./resources/register-all.js";
25
28
  import { SENSITIVE_FIELD_NAMES, deepRedact, redactText } from "./redaction.js";
26
- import { applyToolExposureProfile } from "./tool-exposure.js";
29
+ import { FRIHET_CONNECTOR_SCOPE } from "./openai-review-oauth.js";
27
30
  const PROFILE = {
28
31
  // -- OpenAI-reviewed core surface ----------------------------------------
29
32
  //
@@ -34,11 +37,9 @@ const PROFILE = {
34
37
  includeTools: new Set([
35
38
  // Read-only tools
36
39
  "get_business_context",
37
- "get_monthly_summary",
38
40
  "list_invoices",
39
41
  "get_invoice",
40
42
  "search_invoices",
41
- "get_invoice_pdf",
42
43
  "list_expenses",
43
44
  "get_expense",
44
45
  "list_clients",
@@ -52,13 +53,8 @@ const PROFILE = {
52
53
  "get_quote",
53
54
  "list_vendors",
54
55
  "get_vendor",
55
- "list_webhooks",
56
- "get_webhook",
57
56
  // Create tools
58
57
  "create_invoice",
59
- "duplicate_invoice",
60
- "create_credit_note",
61
- "apply_late_fee",
62
58
  "create_expense",
63
59
  "create_client",
64
60
  "create_client_contact",
@@ -68,34 +64,46 @@ const PROFILE = {
68
64
  "create_quote",
69
65
  "create_vendor",
70
66
  // Update tools
71
- "update_invoice",
72
- "mark_invoice_paid",
73
67
  "update_expense",
74
68
  "update_client",
75
69
  "update_product",
76
- "update_quote",
77
70
  "update_vendor",
78
71
  // Delete tools
79
- "delete_invoice",
80
- "delete_expense",
81
- "delete_client",
82
72
  "delete_client_contact",
83
73
  "delete_client_note",
84
- "delete_product",
85
74
  "delete_quote",
86
- "delete_vendor",
87
- "delete_webhook",
88
- // Open-world tools with explicit justifications
89
- "send_invoice",
90
- "send_quote",
91
- "create_webhook",
92
- "update_webhook",
93
75
  ]),
94
76
  // ── Tools excluded entirely ─────────────────────────────────────────
95
77
  // Return restricted data categories that cannot be adequately redacted.
96
78
  excludeTools: new Set([
97
79
  "get_quarterly_taxes", // Modelo 303/130 tax filing data — sensitive fiscal PII
98
80
  "get_invoice_einvoice", // EN16931 XML mandatorily contains seller+buyer NIF/CIF
81
+ "get_invoice_pdf", // Opaque PDF bytes can contain tax IDs and cannot be redacted safely
82
+ "list_webhooks", // URLs/metadata can embed credentials or external routing data
83
+ "get_webhook",
84
+ "create_webhook",
85
+ "update_webhook",
86
+ "delete_webhook",
87
+ // A draft invoice can cross into a regulated issuance/filing lifecycle
88
+ // through these endpoints when workspace automation is enabled. ChatGPT's
89
+ // reviewed profile is deliberately draft-only for invoices.
90
+ "update_invoice",
91
+ "mark_invoice_paid",
92
+ "delete_invoice",
93
+ "send_invoice",
94
+ "duplicate_invoice",
95
+ "create_credit_note",
96
+ // The backend deletes the accounting record without proving cleanup of
97
+ // linked attachment metadata/objects. Do not imply complete expense-data
98
+ // erasure through the reviewed connector.
99
+ "delete_expense",
100
+ // The backend deletes only the client parent document and leaves its
101
+ // contacts, notes and activities stored but unreachable. Do not imply that
102
+ // this connector performs complete client-data erasure.
103
+ "delete_client",
104
+ // Email delivery is excluded until the backend can bind the previewed
105
+ // recipient atomically and make delivery retry-safe.
106
+ "send_quote",
99
107
  ]),
100
108
  // MCP prompts/resources can reference tools/fields/modules that are
101
109
  // intentionally hidden from OpenAI mode (for example tax IDs, fiscal filing
@@ -106,60 +114,74 @@ const PROFILE = {
106
114
  // ── Annotation corrections ──────────────────────────────────────────
107
115
  // openWorldHint MUST be true for tools that cause external side effects.
108
116
  annotationOverrides: {
109
- send_invoice: { openWorldHint: true },
110
- send_quote: { openWorldHint: true },
111
- create_webhook: { openWorldHint: true },
112
- update_webhook: { openWorldHint: true },
117
+ send_invoice: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
113
118
  },
114
119
  // ── Description overrides ───────────────────────────────────────────
115
120
  // Remove references to stripped fields (taxId, secret, to) and include
116
121
  // openWorldHint justifications as required by OpenAI review guidelines.
117
122
  descriptionOverrides: {
118
- list_clients: "List all clients/customers with optional pagination. " +
119
- "Returns contact info and addresses. " +
120
- "/ Lista todos los clientes con paginacion opcional. " +
121
- "Devuelve informacion de contacto y direcciones.",
123
+ get_business_context: "Get a concise workspace context with business defaults, plan usage, recent activity, " +
124
+ "top clients, and current-month totals. Government identifiers, precise address fields, " +
125
+ "and regulated filing data are omitted. " +
126
+ "/ Obtiene un contexto conciso del espacio de trabajo sin identificadores oficiales, " +
127
+ "direcciones precisas ni datos de declaraciones reguladas.",
128
+ get_invoice: "Get one invoice by ID with its stored line items, linked-client context, dates, and status. " +
129
+ "The response contains only stored document fields; a calculated total may be absent. " +
130
+ "/ Obtiene una factura por ID con sus campos almacenados; el total calculado puede no estar presente.",
131
+ get_quote: "Get one quote by ID with its stored line items, linked-client context, dates, and status. " +
132
+ "The response contains only stored document fields; a calculated total may be absent. " +
133
+ "/ Obtiene un presupuesto por ID con sus campos almacenados; el total calculado puede no estar presente.",
134
+ create_invoice: "Prepare a draft invoice that reserves a document number, advances the numbering counter, and may create/link a client; it cannot issue, email, or file it. " +
135
+ "It requires a client name and at least one line item and always forces draft status. It cannot mark paid, cancel, hash, or submit the invoice to an external filing service. " +
136
+ "This invoice counts toward the workspace's monthly invoice usage and may emit invoice-creation analytics to PostHog's EU-hosted analytics service. Active owner-configured webhooks may receive the resulting full business events. Frihet may create in-app and Novu notifications for eligible workspace admins or accountants whose preferences allow them. For a referred workspace's first invoice, Frihet may award activation credits to the referring Frihet account. " +
137
+ "/ Prepara una nueva factura en borrador. Este conector siempre fuerza el estado borrador " +
138
+ "y reserva un numero interno; puede crear y vincular el cliente si no existe una coincidencia exacta, pero no emite, envia, marca como pagada, cancela, firma ni presenta la factura.",
139
+ create_expense: "Record an expense on an explicit date; its tax-deductible choice affects Frihet's accounting, files nothing, and a new vendor may persist if the expense fails. " +
140
+ "A supplied vendor name may create and link a vendor record when no exact match exists. Vendor creation is a separate backend step, so a newly created vendor may remain if the later expense write fails. " +
141
+ "Active owner-configured webhooks may receive the resulting full business events. Frihet may create in-app and Novu notifications for eligible workspace admins or accountants whose preferences allow them. For a referred workspace's first expense, Frihet may award activation credits to the referring Frihet account. " +
142
+ "/ Registra un gasto tras confirmacion explicita. La creacion del proveedor es un paso separado y el proveedor nuevo puede permanecer si despues falla la creacion del gasto; la clasificacion deducible afecta los calculos internos pero no presenta declaraciones.",
143
+ update_expense: "Update an expense description, category, date, or tax-deductible classification, which affects Frihet's internal accounting; this tool files nothing. " +
144
+ "A date change moves the future tax-report period. Only supplied reviewed fields change; amount and supplier identity cannot be changed here. " +
145
+ "Active owner-configured webhooks may receive the resulting full business event. " +
146
+ "/ Actualiza solo los campos indicados de un gasto. La clasificacion deducible afecta los calculos internos, pero no presenta declaraciones.",
147
+ create_quote: "Prepare a draft quote that reserves a document number, advances the numbering counter, and may create/link a client; it cannot send or accept it. " +
148
+ "It always forces draft status. Active owner-configured webhooks may receive the resulting full business events. " +
149
+ "/ Prepara un presupuesto en borrador, reserva un numero y avanza el contador. Puede crear y vincular el cliente si no existe una coincidencia exacta; no envia ni acepta el presupuesto.",
150
+ list_clients: "List all clients/customers with optional pagination and CRM stage filtering. Free-text search is excluded because the backend search also matches government tax identifiers. " +
151
+ "Returns only summary record identifiers, names, and stages; contact details, government identifiers, and precise addresses are omitted. " +
152
+ "/ Lista todos los clientes con paginacion opcional y filtro por etapa CRM; se excluye la busqueda de texto libre porque tambien coincide con identificadores fiscales oficiales. " +
153
+ "Devuelve solo identificadores, nombres y etapas de resumen; se omiten los datos de contacto, identificadores fiscales y direcciones precisas.",
154
+ get_client: "Get one client/customer by ID through the reviewed contact projection; government identifiers and precise addresses are omitted. " +
155
+ "/ Obtiene un cliente por ID mediante la proyeccion revisada; se omiten los identificadores fiscales y las direcciones precisas.",
122
156
  create_client: "Create a new client/customer. Requires at minimum a name. " +
123
157
  "Clients are used when creating invoices and quotes. " +
124
- "Example: name='Acme Corp', email='billing@acme.com', " +
125
- "address={street:'Main St 1', city:'Madrid', country:'ES'} " +
158
+ "Example: name='Acme Corp', email='billing@acme.com'. " +
126
159
  "/ Crea un nuevo cliente. Requiere como minimo un nombre.",
127
160
  update_client: "Update an existing client using PATCH semantics. Only the provided fields will be changed. " +
128
161
  "Example: id='abc123', email='new@acme.com', phone='+34600123456' " +
129
162
  "/ Actualiza un cliente existente. Solo se modifican los campos proporcionados.",
130
- list_vendors: "List all vendors/suppliers with optional pagination and search. " +
131
- "Returns contact info and addresses. " +
132
- "/ Lista todos los proveedores con paginacion y busqueda opcional. " +
133
- "Devuelve informacion de contacto y direcciones.",
163
+ log_client_activity: "Log a call, meeting, email, or task entry on one client timeline. For call, meeting, or email entries, Frihet also updates the parent client's latest-activity fields, so active owner-configured webhooks may receive the resulting full client.updated event; task entries do not update the parent client. " +
164
+ "/ Registra una llamada, reunion, correo o tarea en el historial de un cliente. Las llamadas, reuniones y correos tambien actualizan la actividad reciente del cliente y pueden generar un evento client.updated completo para webhooks configurados por el propietario; las tareas no actualizan el cliente padre.",
165
+ list_vendors: "List all vendors/suppliers with optional pagination. Free-text search is excluded because the backend search also matches government tax identifiers. " +
166
+ "Returns only summary record identifiers and names; contact details, government identifiers, and precise addresses are omitted. " +
167
+ "/ Lista todos los proveedores con paginacion opcional; se excluye la busqueda de texto libre porque tambien coincide con identificadores fiscales oficiales. " +
168
+ "Devuelve solo identificadores y nombres de resumen; se omiten los datos de contacto, identificadores fiscales y direcciones precisas.",
169
+ get_vendor: "Get one vendor/supplier by ID through the reviewed contact projection; government identifiers and precise addresses are omitted. " +
170
+ "/ Obtiene un proveedor por ID mediante la proyeccion revisada; se omiten los identificadores fiscales y las direcciones precisas.",
134
171
  create_vendor: "Create a new vendor/supplier. Requires at minimum a name. " +
135
172
  "Vendors are used when tracking expenses and purchase orders. " +
136
- "Example: name='Office Supplies Ltd', email='billing@office.com', " +
137
- "address={street:'Gran Via 1', city:'Madrid', country:'ES'} " +
173
+ "Example: name='Office Supplies Ltd', email='billing@office.com'. " +
138
174
  "/ Crea un nuevo proveedor. Requiere como minimo un nombre.",
139
175
  update_vendor: "Update an existing vendor using PATCH semantics. Only the provided fields will be changed. " +
140
176
  "Example: id='abc123', email='new@supplier.com', phone='+34600123456' " +
141
177
  "/ Actualiza un proveedor existente. Solo se modifican los campos proporcionados.",
142
- send_invoice: "Send an invoice to the client via email using the client's stored email address. " +
143
- "The invoice must exist and should not already be cancelled. " +
144
- "[openWorldHint: true — triggers email delivery to the client's external email address " +
145
- "via Frihet's transactional email service] " +
146
- "/ Envia una factura al cliente por email usando el email almacenado del cliente.",
147
- send_quote: "Send a quote to the client via email using the client's stored email address. " +
148
- "The quote must exist and should not already be expired or rejected. " +
149
- "[openWorldHint: true — triggers email delivery to the client's external email address " +
150
- "via Frihet's transactional email service] " +
151
- "/ Envia un presupuesto al cliente por email usando el email almacenado del cliente.",
152
- create_webhook: "Register a new webhook endpoint. Specify the URL and events to subscribe to. " +
153
- "Available events: invoice.created, invoice.updated, invoice.paid, invoice.deleted, " +
154
- "expense.created, expense.updated, expense.deleted, client.created, client.updated, " +
155
- "quote.created, quote.updated, quote.accepted. " +
156
- "Example: url='https://example.com/webhook', events=['invoice.created','invoice.paid'] " +
157
- "[openWorldHint: true — configures Frihet to POST event data to the specified external URL] " +
158
- "/ Registra un nuevo endpoint de webhook.",
159
- update_webhook: "Update an existing webhook configuration using PATCH semantics. " +
160
- "Example: id='abc123', active=false to disable a webhook. " +
161
- "[openWorldHint: true — can modify the external URL that receives webhook notifications] " +
162
- "/ Actualiza la configuracion de un webhook.",
178
+ delete_quote: "Delete a clean draft quote or cancel a non-draft quote by its ID. Requires confirm=true. " +
179
+ "A draft is removed permanently only when it has no delivery, response, attachment, or conversion evidence; a protected draft is refused and left unchanged. A quote that has already been " +
180
+ "sent/accepted/rejected/expired is NOT destroyed: the backend cancels it " +
181
+ "(status=cancelled) and it remains readable via get_quote. The response states which " +
182
+ "of the two happened — report that to the user rather than assuming the record is gone. " +
183
+ "/ Elimina un borrador limpio o cancela uno no borrador. Requiere confirm=true. " +
184
+ "Un borrador con evidencia de entrega, respuesta, adjuntos o conversion se rechaza y queda sin cambios; uno no borrador se cancela y sigue consultable con get_quote.",
163
185
  },
164
186
  // ── Input fields stripped ──────────────────────────────────────────
165
187
  // Government IDs (NIF/CIF/VAT), auth credentials, and unsolicited
@@ -172,24 +194,259 @@ const PROFILE = {
172
194
  list_invoices: ["fields"],
173
195
  search_invoices: ["fields"],
174
196
  list_expenses: ["fields"],
175
- list_clients: ["fields"],
197
+ // The public API's `q` search includes taxId/searchTokens. Omit it entirely
198
+ // so a generic free-text field cannot become a government-identifier input
199
+ // path or leak such a value through a GET URL.
200
+ list_clients: ["fields", "q"],
176
201
  list_products: ["fields"],
177
202
  list_quotes: ["fields"],
178
- list_vendors: ["fields"],
179
- create_client: ["taxId"], // NIF/CIF/VAT — government-issued identifier
180
- update_client: ["taxId"],
181
- create_vendor: ["taxId"],
182
- update_vendor: ["taxId"],
183
- send_invoice: ["to"], // Don't solicit email — use client's stored email
184
- send_quote: ["to"],
185
- create_webhook: ["secret"], // Signing credential — manage via Frihet web app
186
- update_webhook: ["secret"],
203
+ list_vendors: ["fields", "q"],
204
+ create_invoice: [
205
+ "clientId",
206
+ "clientTaxId",
207
+ "clientAddress",
208
+ "clientLocation",
209
+ "status",
210
+ "irpfRate",
211
+ "equivalenceSurchargeRate",
212
+ "prepayment",
213
+ "seriesId",
214
+ "documentNumber",
215
+ "poNumber",
216
+ "operationType",
217
+ ],
218
+ create_quote: [
219
+ "clientId",
220
+ "clientTaxId",
221
+ "clientAddress",
222
+ "clientLocation",
223
+ "status",
224
+ "irpfRate",
225
+ "equivalenceSurchargeRate",
226
+ ],
227
+ // paidDate drives cash-basis tax-period allocation in Frihet. The reviewed
228
+ // ChatGPT surface may record an expense, but must not mark it paid or choose
229
+ // the fiscal period in which that payment is recognised.
230
+ create_expense: ["paidDate"],
231
+ // The generic ERP PATCH route does not re-resolve vendorId when a vendor
232
+ // display name changes. Hiding and deleting this field prevents a later
233
+ // fiscal export from pairing a new name with the old vendor identity.
234
+ update_expense: ["vendor", "amount"],
235
+ create_client: ["taxId", "address"], // government ID + raw location
236
+ update_client: ["taxId", "address"],
237
+ create_vendor: ["taxId", "address"],
238
+ update_vendor: ["taxId", "address"],
239
+ },
240
+ requireInputFields: {
241
+ // The base API has legacy defaults for these accounting choices. ChatGPT
242
+ // must receive an explicit user choice instead of silently accepting them.
243
+ create_expense: ["date", "taxDeductible"],
187
244
  },
188
245
  // ── Output fields redacted ─────────────────────────────────────────
189
246
  // Stripped from structuredContent and text in ALL tool responses.
190
247
  // Single source of truth lives in redaction.ts (shared with observability.ts
191
248
  // so Langfuse traces redact the EXACT same field set).
192
- redactOutputFields: SENSITIVE_FIELD_NAMES,
249
+ redactOutputFields: [
250
+ ...SENSITIVE_FIELD_NAMES,
251
+ // The reviewed connector neither requests nor echoes precise locations.
252
+ "address",
253
+ "clientAddress",
254
+ "clientLocation",
255
+ // Monthly summaries remain useful without becoming a filing surface.
256
+ "taxLiability",
257
+ "estimatedModel303",
258
+ "vatPayable",
259
+ "irpfRetained",
260
+ "fiscalZone",
261
+ "irpfRate",
262
+ "series",
263
+ // CRM activity metadata is an open-ended backend map. It is useful to the
264
+ // full MCP server, but it cannot be proven free of restricted data for the
265
+ // reviewed connector, so omit it from both descriptor and runtime output.
266
+ "metadata",
267
+ // Demo-mode markers are internal implementation metadata. The dedicated
268
+ // OAuth host never uses DemoFrihetClient, so advertising them would expose
269
+ // impossible fields and make the reviewed app look like a trial/demo app.
270
+ "_demo",
271
+ "_demoNotice",
272
+ // Generic persistence timestamps are reviewer-noise and are not needed to
273
+ // address, relate, or reconcile any reviewed operation. Business dates
274
+ // such as issueDate, dueDate, date, validUntil, and activity timestamp stay.
275
+ "createdAt",
276
+ "updatedAt",
277
+ // Internal backend-path marker; the reviewed result already exposes the
278
+ // user-relevant deleted/cancelled outcome.
279
+ "cancelledVia",
280
+ ],
281
+ };
282
+ /** OAuth requirement advertised on every reviewed business tool. */
283
+ export const OPENAI_OAUTH_SECURITY_SCHEMES = [
284
+ { type: "oauth2", scopes: [FRIHET_CONNECTOR_SCOPE] },
285
+ ];
286
+ /**
287
+ * @modelcontextprotocol/sdk 1.30 accepts unknown registration config fields,
288
+ * but its McpServer currently drops top-level `securitySchemes` when it builds
289
+ * the actual tools/list response. OpenAI requires that standard field and only
290
+ * treats `_meta.securitySchemes` as a backwards-compatibility mirror.
291
+ *
292
+ * Install a narrow response projection before the SDK registers its first
293
+ * tools/list handler. It copies the already-reviewed mirror to the standard
294
+ * top-level field and fails closed if any visible tool lacks the exact OAuth
295
+ * requirement. The normal (non-OpenAI) MCP surface is never patched.
296
+ */
297
+ const OPENAI_TOOLS_LIST_PATCHED_SERVERS = new WeakSet();
298
+ function requestSchemaMethod(requestSchema) {
299
+ const shape = getObjectShape(requestSchema);
300
+ return shape?.method ? getLiteralValue(shape.method) : undefined;
301
+ }
302
+ function reviewedSchemaDescriptionSource(schema, location) {
303
+ if (schema === undefined)
304
+ return undefined;
305
+ if (!(schema instanceof z.ZodType)) {
306
+ throw new Error(`${location} must be a finalized Zod schema`);
307
+ }
308
+ return z.toJSONSchema(schema);
309
+ }
310
+ function restoreReviewedSchemaDescriptions(wireValue, sourceValue) {
311
+ if (Array.isArray(wireValue) && Array.isArray(sourceValue)) {
312
+ return wireValue.map((item, index) => restoreReviewedSchemaDescriptions(item, sourceValue[index]));
313
+ }
314
+ if (!isRecord(wireValue) || !isRecord(sourceValue))
315
+ return wireValue;
316
+ const restored = { ...wireValue };
317
+ const hasMetadataDescription = typeof sourceValue.description === "string";
318
+ if (hasMetadataDescription) {
319
+ restored.description = sourceValue.description;
320
+ }
321
+ for (const [key, child] of Object.entries(wireValue)) {
322
+ if ((key === "description" && hasMetadataDescription) || !(key in sourceValue)) {
323
+ continue;
324
+ }
325
+ restored[key] = restoreReviewedSchemaDescriptions(child, sourceValue[key]);
326
+ }
327
+ return restored;
328
+ }
329
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
330
+ function installOpenAIToolsListSecurityProjection(server) {
331
+ const protocol = server?.server;
332
+ if (protocol === null
333
+ || typeof protocol !== "object"
334
+ || typeof protocol.setRequestHandler !== "function"
335
+ || typeof server?.registerTool !== "function"
336
+ || OPENAI_TOOLS_LIST_PATCHED_SERVERS.has(protocol)) {
337
+ return;
338
+ }
339
+ OPENAI_TOOLS_LIST_PATCHED_SERVERS.add(protocol);
340
+ const schemaDescriptions = new Map();
341
+ const originalRegisterTool = server.registerTool.bind(server);
342
+ // Capture finalized reviewed schemas before the Worker SDK serializes them.
343
+ // Zod stores `.describe()` metadata in a module-local registry; without this
344
+ // projection, the Worker's separate Zod copy drops every field description.
345
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
346
+ server.registerTool = (name, config, handler) => {
347
+ if (typeof name !== "string" || !isRecord(config)) {
348
+ throw new Error("OpenAI tool registration is invalid");
349
+ }
350
+ schemaDescriptions.set(name, {
351
+ inputSchema: reviewedSchemaDescriptionSource(config.inputSchema, `${name}.inputSchema`),
352
+ outputSchema: reviewedSchemaDescriptionSource(config.outputSchema, `${name}.outputSchema`),
353
+ });
354
+ return originalRegisterTool(name, config, handler);
355
+ };
356
+ const originalSetRequestHandler = protocol.setRequestHandler.bind(protocol);
357
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
358
+ protocol.setRequestHandler = (requestSchema, handler) => {
359
+ // Match the protocol method structurally. The deployed Worker has its own
360
+ // @modelcontextprotocol/sdk installation, so schema object identity differs
361
+ // from the root package even when both resolve the same locked version.
362
+ if (requestSchemaMethod(requestSchema) !== "tools/list") {
363
+ return originalSetRequestHandler(requestSchema, handler);
364
+ }
365
+ return originalSetRequestHandler(requestSchema,
366
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
367
+ async (request, extra) => {
368
+ const result = await handler(request, extra);
369
+ if (!isRecord(result) || !Array.isArray(result.tools)) {
370
+ throw new Error("OpenAI tools/list returned an invalid tool collection");
371
+ }
372
+ const tools = result.tools.map((tool) => {
373
+ if (!isRecord(tool) || !isRecord(tool._meta)) {
374
+ throw new Error("OpenAI tools/list tool is missing OAuth metadata");
375
+ }
376
+ if (typeof tool.name !== "string") {
377
+ throw new Error("OpenAI tools/list tool is missing its name");
378
+ }
379
+ const schemes = tool._meta.securitySchemes;
380
+ if (!Array.isArray(schemes)
381
+ || schemes.length !== 1
382
+ || !isRecord(schemes[0])
383
+ || schemes[0].type !== "oauth2"
384
+ || !Array.isArray(schemes[0].scopes)
385
+ || schemes[0].scopes.length !== 1
386
+ || schemes[0].scopes[0] !== FRIHET_CONNECTOR_SCOPE) {
387
+ throw new Error(`OpenAI tool ${String(tool.name)} is missing the reviewed OAuth scope`);
388
+ }
389
+ const securitySchemes = [{
390
+ type: "oauth2",
391
+ scopes: [FRIHET_CONNECTOR_SCOPE],
392
+ }];
393
+ const descriptionSources = schemaDescriptions.get(tool.name);
394
+ if (!descriptionSources) {
395
+ throw new Error(`OpenAI tool ${tool.name} is missing its reviewed schema projection`);
396
+ }
397
+ return {
398
+ ...tool,
399
+ inputSchema: restoreReviewedSchemaDescriptions(tool.inputSchema, descriptionSources.inputSchema),
400
+ ...(tool.outputSchema === undefined
401
+ ? {}
402
+ : {
403
+ outputSchema: restoreReviewedSchemaDescriptions(tool.outputSchema, descriptionSources.outputSchema),
404
+ }),
405
+ securitySchemes,
406
+ };
407
+ });
408
+ return { ...result, tools };
409
+ });
410
+ };
411
+ }
412
+ export const OPENAI_WORKSPACE_WEBHOOK_EVENT_TOOLS = new Set([
413
+ "create_invoice",
414
+ "create_expense",
415
+ "update_expense",
416
+ "create_quote",
417
+ "delete_quote",
418
+ "create_client",
419
+ "update_client",
420
+ "log_client_activity",
421
+ "create_product",
422
+ "update_product",
423
+ ]);
424
+ const REVIEWED_PERMANENT_DELETE_TOOLS = new Set([
425
+ "delete_client_contact",
426
+ "delete_client_note",
427
+ ]);
428
+ const REVIEWED_CLOSED_WORLD_WRITE_TOOLS = new Set([
429
+ "create_client_contact",
430
+ "create_client_note",
431
+ "create_vendor",
432
+ "update_vendor",
433
+ ]);
434
+ const BASE_CONFIRM_TOOLS = new Set(["delete_quote"]);
435
+ export const OPENAI_REVIEW_CONFIRM_REQUIRED_TOOLS = new Set([
436
+ ...OPENAI_WORKSPACE_WEBHOOK_EVENT_TOOLS,
437
+ ...REVIEWED_PERMANENT_DELETE_TOOLS,
438
+ ...REVIEWED_CLOSED_WORLD_WRITE_TOOLS,
439
+ ]);
440
+ const PROFILE_INJECTED_CONFIRM_TOOLS = new Set([...OPENAI_REVIEW_CONFIRM_REQUIRED_TOOLS].filter((name) => !BASE_CONFIRM_TOOLS.has(name)));
441
+ const REVIEWED_NON_EMPTY_PATCH_TOOLS = new Set([
442
+ "update_client",
443
+ "update_expense",
444
+ "update_product",
445
+ "update_vendor",
446
+ ]);
447
+ const PROFILE_FORCED_INPUT_VALUES = {
448
+ create_invoice: { status: "draft" },
449
+ create_quote: { status: "draft" },
193
450
  };
194
451
  /* ------------------------------------------------------------------ */
195
452
  /* Deep field redaction — shared policy in redaction.ts */
@@ -201,59 +458,42 @@ const PROFILE = {
201
458
  /* Output SCHEMA stripping (descriptor-level) */
202
459
  /* ------------------------------------------------------------------ */
203
460
  /**
204
- * Returns a Zod output schema with sensitive fields removed at EVERY depth.
461
+ * Returns a closed Zod output schema with sensitive fields removed at EVERY
462
+ * depth.
205
463
  *
206
464
  * Runtime output redaction (deepRedact in the handler wrapper) hides the
207
465
  * VALUES, but the advertised `outputSchema` descriptor still DECLARES taxId /
208
466
  * secret etc. at tools/list — which OpenAI's submission review auto-detects as
209
467
  * exposed government IDs / credentials. This strips them from the descriptor too.
210
468
  *
211
- * Surgical: only the object/array nodes on the path to a sensitive field are
212
- * rebuilt; untouched branches are returned BY REFERENCE so their `.describe()`
213
- * metadata and `.passthrough()` behavior are preserved. A schema with no
214
- * sensitive field anywhere is returned unchanged (identity).
469
+ * Every object is deliberately rebuilt in strip mode, including objects whose
470
+ * base schema is `.passthrough()`. The Frihet API returns raw Firestore records,
471
+ * so a blacklist alone is insufficient: a newly added backend field could
472
+ * otherwise escape the reviewed contract without appearing in this file. The
473
+ * rebuilt schema is therefore also the runtime output projection allowlist.
215
474
  */
216
- function stripSensitiveOutputSchema(schema, fields) {
475
+ function buildReviewedOutputSchema(schema, fields) {
217
476
  if (schema instanceof z.ZodObject) {
218
477
  const shape = schema.shape;
219
478
  const newShape = {};
220
- let changed = false;
221
479
  for (const [key, value] of Object.entries(shape)) {
222
- if (fields.includes(key)) {
223
- changed = true;
224
- continue; // drop the sensitive field entirely from the descriptor
225
- }
226
- const stripped = stripSensitiveOutputSchema(value, fields);
227
- if (stripped !== value)
228
- changed = true;
229
- newShape[key] = stripped;
480
+ const unwrapped = value instanceof z.ZodOptional || value instanceof z.ZodNullable
481
+ ? value.unwrap()
482
+ : value;
483
+ if (fields.includes(key) || unwrapped instanceof z.ZodNever)
484
+ continue;
485
+ newShape[key] = buildReviewedOutputSchema(value, fields);
230
486
  }
231
- if (!changed)
232
- return schema;
233
- const base = z.object(newShape);
234
- // Zod v4 stores passthrough/strict behavior in `catchall`. Preserve it so
235
- // redacting one declared field never changes validation of unrelated API
236
- // fields. The previous implementation inspected Zod v3's `_def.typeName`,
237
- // which is absent in v4 and silently returned the original schema.
238
- const catchall = schema._def.catchall;
239
- let rebuilt;
240
- if (catchall instanceof z.ZodUnknown)
241
- rebuilt = base.passthrough();
242
- else if (catchall instanceof z.ZodNever)
243
- rebuilt = base.strict();
244
- else if (catchall)
245
- rebuilt = base.catchall(catchall);
246
- else
247
- rebuilt = base;
487
+ // Default Zod object behavior strips unknown keys. Do not preserve a base
488
+ // `.passthrough()` or `.catchall()`: this is the reviewed output boundary.
489
+ let rebuilt = z.object(newShape);
248
490
  if (typeof schema.description === "string")
249
491
  rebuilt = rebuilt.describe(schema.description);
250
492
  return rebuilt;
251
493
  }
252
494
  if (schema instanceof z.ZodArray) {
253
495
  const inner = schema.element;
254
- const stripped = stripSensitiveOutputSchema(inner, fields);
255
- if (stripped === inner)
256
- return schema;
496
+ const stripped = buildReviewedOutputSchema(inner, fields);
257
497
  let rebuilt = z.array(stripped);
258
498
  if (typeof schema.description === "string")
259
499
  rebuilt = rebuilt.describe(schema.description);
@@ -261,15 +501,604 @@ function stripSensitiveOutputSchema(schema, fields) {
261
501
  }
262
502
  if (schema instanceof z.ZodOptional) {
263
503
  const inner = schema.unwrap();
264
- const stripped = stripSensitiveOutputSchema(inner, fields);
265
- return stripped === inner ? schema : z.optional(stripped);
504
+ let rebuilt = z.optional(buildReviewedOutputSchema(inner, fields));
505
+ if (typeof schema.description === "string")
506
+ rebuilt = rebuilt.describe(schema.description);
507
+ return rebuilt;
266
508
  }
267
509
  if (schema instanceof z.ZodNullable) {
268
510
  const inner = schema.unwrap();
269
- const stripped = stripSensitiveOutputSchema(inner, fields);
270
- return stripped === inner ? schema : z.nullable(stripped);
511
+ let rebuilt = z.nullable(buildReviewedOutputSchema(inner, fields));
512
+ if (typeof schema.description === "string")
513
+ rebuilt = rebuilt.describe(schema.description);
514
+ return rebuilt;
515
+ }
516
+ if (schema instanceof z.ZodUnion) {
517
+ const options = schema.options.map((option) => buildReviewedOutputSchema(option, fields));
518
+ let rebuilt = z.union(options);
519
+ if (typeof schema.description === "string")
520
+ rebuilt = rebuilt.describe(schema.description);
521
+ return rebuilt;
522
+ }
523
+ if (schema instanceof z.ZodRecord) {
524
+ let rebuilt = z.record(schema.keyType, buildReviewedOutputSchema(schema.valueType, fields));
525
+ if (typeof schema.description === "string")
526
+ rebuilt = rebuilt.describe(schema.description);
527
+ return rebuilt;
528
+ }
529
+ // Only JSON-safe primitive leaves are accepted. Unknown/open schema kinds
530
+ // must fail registration instead of silently bypassing this security
531
+ // boundary when a future tool introduces a tuple, intersection, lazy,
532
+ // transform, any, or unknown output.
533
+ if (schema instanceof z.ZodString
534
+ || schema instanceof z.ZodNumber
535
+ || schema instanceof z.ZodBoolean
536
+ || schema instanceof z.ZodLiteral
537
+ || schema instanceof z.ZodEnum
538
+ || schema instanceof z.ZodNull) {
539
+ return schema;
540
+ }
541
+ const typeName = schema instanceof z.ZodType
542
+ ? String(schema._def.type ?? schema.constructor.name)
543
+ : typeof schema;
544
+ throw new Error(`Unsupported reviewed output schema node: ${typeName}`);
545
+ }
546
+ const REVIEWED_PAGINATED_REQUIRED_ITEM_FIELDS = {
547
+ list_invoices: ["id"],
548
+ search_invoices: ["id"],
549
+ list_expenses: ["id", "description", "amount"],
550
+ list_clients: ["id", "name"],
551
+ list_products: ["id", "name", "unitPrice"],
552
+ list_quotes: ["id"],
553
+ list_vendors: ["id", "name"],
554
+ };
555
+ /** Strictly necessary row fields for reviewed collection/search responses. */
556
+ export const OPENAI_REVIEW_LIST_OUTPUT_FIELDS = {
557
+ list_invoices: [
558
+ "id",
559
+ "invoiceNumber",
560
+ "clientName",
561
+ "issueDate",
562
+ "dueDate",
563
+ "status",
564
+ "total",
565
+ ],
566
+ search_invoices: [
567
+ "id",
568
+ "invoiceNumber",
569
+ "clientName",
570
+ "issueDate",
571
+ "dueDate",
572
+ "status",
573
+ "total",
574
+ ],
575
+ list_expenses: [
576
+ "id",
577
+ "description",
578
+ "amount",
579
+ "category",
580
+ "date",
581
+ "vendor",
582
+ "taxDeductible",
583
+ "paidDate",
584
+ ],
585
+ list_clients: ["id", "name", "stage"],
586
+ list_products: ["id", "name", "unitPrice", "taxRate", "isActive"],
587
+ list_quotes: [
588
+ "id",
589
+ "quoteNumber",
590
+ "clientName",
591
+ "issueDate",
592
+ "dueDate",
593
+ "validUntil",
594
+ "status",
595
+ "total",
596
+ ],
597
+ list_vendors: ["id", "name"],
598
+ };
599
+ export const OPENAI_REVIEW_LIST_LIMIT_MAX = 50;
600
+ export const OPENAI_REVIEW_OFFSET_MAX = 10_000;
601
+ export const OPENAI_REVIEW_PAGINATION_DEFAULT = 20;
602
+ export const OPENAI_REVIEW_DOCUMENT_LINE_ITEM_MAX = 100;
603
+ export const OPENAI_REVIEW_BUSINESS_CONTEXT_TOP_CLIENTS_MAX = 5;
604
+ export const OPENAI_REVIEW_FREE_TEXT_WARNING = "Do not include passwords, credentials, payment-card data, health data, or official/government identifiers in this free-text field / No incluyas contrasenas, credenciales, datos de tarjetas, datos de salud ni identificadores oficiales en este campo de texto libre";
605
+ /** Dot paths whose persisted or query text needs an explicit model-facing warning. */
606
+ export const OPENAI_REVIEW_FREE_TEXT_WARNING_PATHS = {
607
+ search_invoices: ["query"],
608
+ list_expenses: ["category"],
609
+ list_products: ["q"],
610
+ create_invoice: ["clientName", "items[].description", "notes"],
611
+ create_quote: ["clientName", "items[].description", "notes"],
612
+ create_expense: ["description", "category", "vendor"],
613
+ update_expense: ["description", "category"],
614
+ create_client: ["name"],
615
+ update_client: ["name"],
616
+ create_client_contact: ["name", "role"],
617
+ create_client_note: ["content"],
618
+ create_product: ["name", "description"],
619
+ update_product: ["name", "description"],
620
+ create_vendor: ["name"],
621
+ update_vendor: ["name"],
622
+ log_client_activity: ["title", "description"],
623
+ };
624
+ export const OPENAI_REVIEW_PAGINATION_LIMITS = {
625
+ list_invoices: OPENAI_REVIEW_LIST_LIMIT_MAX,
626
+ search_invoices: OPENAI_REVIEW_LIST_LIMIT_MAX,
627
+ list_expenses: OPENAI_REVIEW_LIST_LIMIT_MAX,
628
+ list_clients: OPENAI_REVIEW_LIST_LIMIT_MAX,
629
+ list_products: OPENAI_REVIEW_LIST_LIMIT_MAX,
630
+ list_quotes: OPENAI_REVIEW_LIST_LIMIT_MAX,
631
+ list_vendors: OPENAI_REVIEW_LIST_LIMIT_MAX,
632
+ list_client_contacts: OPENAI_REVIEW_LIST_LIMIT_MAX,
633
+ list_client_activities: 20,
634
+ list_client_notes: 20,
635
+ };
636
+ export const OPENAI_REVIEW_TEXT_INPUT_LIMITS = {
637
+ create_expense: { category: 100 },
638
+ update_expense: { description: 1_000, category: 100 },
639
+ };
640
+ const REVIEWED_INVOICE_OUTPUT_TOOLS = new Set([
641
+ "list_invoices",
642
+ "search_invoices",
643
+ "get_invoice",
644
+ "create_invoice",
645
+ ]);
646
+ const REVIEWED_QUOTE_OUTPUT_TOOLS = new Set([
647
+ "list_quotes",
648
+ "get_quote",
649
+ "create_quote",
650
+ ]);
651
+ const REVIEWED_DOCUMENT_DETAIL_OUTPUT_TOOLS = new Set([
652
+ "get_invoice",
653
+ "create_invoice",
654
+ "get_quote",
655
+ "create_quote",
656
+ ]);
657
+ function requireObjectFields(schema, fields) {
658
+ const shape = { ...schema.shape };
659
+ for (const field of fields) {
660
+ const value = shape[field];
661
+ if (!value)
662
+ throw new Error(`Reviewed output is missing required field ${field}`);
663
+ if (value instanceof z.ZodOptional) {
664
+ shape[field] = value.unwrap();
665
+ }
666
+ }
667
+ return z.object(shape);
668
+ }
669
+ function addDocumentAlias(schema, alias, required = false) {
670
+ const shape = schema.shape;
671
+ const data = shape.data;
672
+ if (data instanceof z.ZodArray && data.element instanceof z.ZodObject) {
673
+ return schema.extend({
674
+ data: z.array(data.element.extend({ [alias]: z.string().optional() })),
675
+ });
676
+ }
677
+ return schema.extend({ [alias]: required ? z.string() : z.string().optional() });
678
+ }
679
+ function selectReviewedObjectFields(schema, fields) {
680
+ const shape = schema.shape;
681
+ const selected = {};
682
+ for (const field of fields) {
683
+ const value = shape[field];
684
+ if (!value)
685
+ throw new Error(`Reviewed summary output is missing field ${field}`);
686
+ selected[field] = value;
687
+ }
688
+ return z.object(selected);
689
+ }
690
+ function omitReviewedLineItemIds(schema) {
691
+ const shape = schema.shape;
692
+ const items = shape.items;
693
+ const array = items instanceof z.ZodOptional ? items.unwrap() : items;
694
+ if (!(array instanceof z.ZodArray) || !(array.element instanceof z.ZodObject)) {
695
+ throw new Error("Reviewed document detail output must expose an item-object array");
696
+ }
697
+ let projectedItems = z
698
+ .array(array.element.omit({ id: true }))
699
+ .max(OPENAI_REVIEW_DOCUMENT_LINE_ITEM_MAX);
700
+ if (typeof array.description === "string") {
701
+ projectedItems = projectedItems.describe(array.description);
702
+ }
703
+ return schema.extend({
704
+ items: items instanceof z.ZodOptional ? projectedItems.optional() : projectedItems,
705
+ });
706
+ }
707
+ function omitReviewedActivityMetadata(name, schema) {
708
+ if (name === "log_client_activity")
709
+ return schema.omit({ id: true, createdBy: true });
710
+ if (name !== "list_client_activities")
711
+ return schema;
712
+ const data = schema.shape.data;
713
+ if (!(data instanceof z.ZodArray) || !(data.element instanceof z.ZodObject)) {
714
+ throw new Error("Reviewed activity list output must expose an object array");
715
+ }
716
+ return schema.extend({ data: z.array(data.element.omit({ id: true, createdBy: true })) });
717
+ }
718
+ function capReviewedBusinessContext(name, schema) {
719
+ if (name !== "get_business_context")
720
+ return schema;
721
+ const topClients = schema.shape.topClients;
722
+ if (!(topClients instanceof z.ZodArray)) {
723
+ throw new Error("Reviewed business context must expose a topClients array");
724
+ }
725
+ let capped = z.array(topClients.element).max(OPENAI_REVIEW_BUSINESS_CONTEXT_TOP_CLIENTS_MAX);
726
+ if (typeof topClients.description === "string") {
727
+ capped = capped.describe(topClients.description);
728
+ }
729
+ return schema.extend({ topClients: capped });
730
+ }
731
+ /** Contextual reviewed DTO refinements after the generic closed projection. */
732
+ function customizeReviewedOutputSchema(name, schema) {
733
+ if (!(schema instanceof z.ZodObject)) {
734
+ throw new Error(`${name} must expose a top-level object output schema`);
735
+ }
736
+ let reviewed = schema;
737
+ const required = REVIEWED_PAGINATED_REQUIRED_ITEM_FIELDS[name];
738
+ if (required) {
739
+ const data = reviewed.shape.data;
740
+ if (!(data instanceof z.ZodArray) || !(data.element instanceof z.ZodObject)) {
741
+ throw new Error(`${name} must expose a paginated object array`);
742
+ }
743
+ reviewed = reviewed.extend({
744
+ data: z.array(requireObjectFields(data.element, required)),
745
+ });
746
+ }
747
+ if (REVIEWED_INVOICE_OUTPUT_TOOLS.has(name)) {
748
+ reviewed = addDocumentAlias(reviewed, "invoiceNumber", name === "create_invoice");
749
+ }
750
+ else if (REVIEWED_QUOTE_OUTPUT_TOOLS.has(name)) {
751
+ reviewed = addDocumentAlias(reviewed, "quoteNumber", name === "create_quote");
752
+ }
753
+ if (REVIEWED_DOCUMENT_DETAIL_OUTPUT_TOOLS.has(name)) {
754
+ reviewed = omitReviewedLineItemIds(reviewed);
755
+ }
756
+ // Create handlers have stronger invariants than the shared read/update
757
+ // schemas. Publish only fields that can occur on their successful wire
758
+ // result and require the values the backend always materializes.
759
+ if (name === "create_client") {
760
+ reviewed = reviewed.omit({ stage: true });
761
+ }
762
+ else if (name === "create_expense") {
763
+ reviewed = requireObjectFields(reviewed.omit({ paidDate: true }), ["date", "taxDeductible"]);
764
+ }
765
+ else if (name === "create_invoice") {
766
+ reviewed = requireObjectFields(reviewed.omit({ total: true }), ["clientId", "clientName", "items", "issueDate", "dueDate"]);
767
+ }
768
+ else if (name === "create_quote") {
769
+ reviewed = requireObjectFields(reviewed.omit({ total: true }), ["clientId", "clientName", "items", "issueDate"]);
770
+ }
771
+ else if (name === "create_product") {
772
+ reviewed = reviewed.omit({ isActive: true });
773
+ }
774
+ reviewed = omitReviewedActivityMetadata(name, reviewed);
775
+ reviewed = capReviewedBusinessContext(name, reviewed);
776
+ const summaryFields = OPENAI_REVIEW_LIST_OUTPUT_FIELDS[name];
777
+ if (summaryFields) {
778
+ const data = reviewed.shape.data;
779
+ if (!(data instanceof z.ZodArray) || !(data.element instanceof z.ZodObject)) {
780
+ throw new Error(`${name} must expose a paginated object array`);
781
+ }
782
+ reviewed = reviewed.extend({
783
+ data: z.array(selectReviewedObjectFields(data.element, summaryFields)),
784
+ });
785
+ }
786
+ const paginationLimit = OPENAI_REVIEW_PAGINATION_LIMITS[name];
787
+ if (paginationLimit !== undefined) {
788
+ const data = reviewed.shape.data;
789
+ if (!(data instanceof z.ZodArray)) {
790
+ throw new Error(`${name} must expose a paginated array for reviewed output capping`);
791
+ }
792
+ reviewed = reviewed.extend({ data: z.array(data.element).max(paginationLimit) });
793
+ }
794
+ // These two reviewed operations force status=draft before they call the
795
+ // backend. The create transaction always reserves and returns a document
796
+ // number, so advertise those successful-result invariants as required rather
797
+ // than a loose optional string contract.
798
+ if (name === "create_invoice" || name === "create_quote") {
799
+ reviewed = reviewed.extend({ status: z.literal("draft") });
800
+ }
801
+ if (OPENAI_WORKSPACE_WEBHOOK_EVENT_TOOLS.has(name) && name !== "delete_quote") {
802
+ reviewed = reviewed.extend({ externalEffects: z.array(z.string()) });
803
+ }
804
+ // delete_quote has two mutually exclusive wire results. Keep this a strict
805
+ // discriminated union so a deleted row can never claim a cancellation status
806
+ // and a retained/cancelled row can never omit status=cancelled.
807
+ if (name === "delete_quote") {
808
+ const { outcome: _outcome, status: _status, previousStatus: _previousStatus, cancelledVia: _cancelledVia, externalEffects: _externalEffects, ...shared } = reviewed.shape;
809
+ const deleted = z.object({
810
+ outcome: z.literal("deleted"),
811
+ }).strict();
812
+ const cancelled = z.object({
813
+ outcome: z.literal("cancelled"),
814
+ status: z.literal("cancelled"),
815
+ previousStatus: z.string().nullable().optional(),
816
+ externalEffects: z.array(z.string()),
817
+ }).strict();
818
+ return z.object({
819
+ ...shared,
820
+ success: z.literal(true),
821
+ result: z.discriminatedUnion("outcome", [deleted, cancelled]),
822
+ }).strict();
823
+ }
824
+ return reviewed;
825
+ }
826
+ function isRecord(value) {
827
+ return value !== null && typeof value === "object" && !Array.isArray(value);
828
+ }
829
+ /** Convert internal names/maps into explicit reviewed DTO fields before redaction. */
830
+ function customizeReviewedOutputValue(name, value) {
831
+ if (!isRecord(value))
832
+ return value;
833
+ if (name === "delete_quote") {
834
+ const details = value.outcome === "cancelled"
835
+ ? {
836
+ outcome: "cancelled",
837
+ status: "cancelled",
838
+ ...(typeof value.previousStatus === "string"
839
+ ? { previousStatus: value.previousStatus }
840
+ : value.previousStatus === null
841
+ ? { previousStatus: null }
842
+ : {}),
843
+ externalEffects: [
844
+ "One or more active webhook endpoints previously configured by the workspace owner may receive the full resulting quote.updated event.",
845
+ ],
846
+ }
847
+ : { outcome: "deleted" };
848
+ delete value.outcome;
849
+ delete value.status;
850
+ delete value.previousStatus;
851
+ delete value.cancelledVia;
852
+ delete value.externalEffects;
853
+ value.result = details;
854
+ return value;
855
+ }
856
+ const alias = REVIEWED_INVOICE_OUTPUT_TOOLS.has(name)
857
+ ? "invoiceNumber"
858
+ : REVIEWED_QUOTE_OUTPUT_TOOLS.has(name)
859
+ ? "quoteNumber"
860
+ : undefined;
861
+ if (alias) {
862
+ const records = Array.isArray(value.data)
863
+ ? value.data.filter(isRecord)
864
+ : [value];
865
+ for (const record of records) {
866
+ if (typeof record.documentNumber === "string") {
867
+ record[alias] = record.documentNumber;
868
+ }
869
+ }
870
+ }
871
+ if (OPENAI_WORKSPACE_WEBHOOK_EVENT_TOOLS.has(name)
872
+ && name !== "delete_quote") {
873
+ const effects = [name === "log_client_activity"
874
+ ? "For call, meeting, or email entries, active webhook endpoints previously configured by the workspace owner may receive the full resulting client.updated event; task entries do not update the parent client."
875
+ : "One or more active webhook endpoints previously configured by the workspace owner may receive the full resulting business event."];
876
+ if (name === "create_invoice" || name === "create_quote") {
877
+ effects.unshift("Frihet reserved a document number and advanced the workspace numbering counter.");
878
+ effects.push("If no exact client match existed, Frihet may have created and linked a client record from the supplied name.");
879
+ }
880
+ if (name === "create_expense") {
881
+ if (typeof value.vendorId === "string" || typeof value.vendor === "string") {
882
+ effects.push("If no exact vendor match existed, Frihet may have created and linked a vendor record from the supplied name.");
883
+ }
884
+ }
885
+ if (name === "update_expense") {
886
+ effects.push("A changed date moves the expense's accounting and future tax-report period; a changed tax-deductible choice affects Frihet's internal accounting. This tool files nothing and cannot change the amount or linked supplier identity.");
887
+ }
888
+ if (name === "create_invoice") {
889
+ effects.push("The draft counts toward the workspace's monthly invoice usage.");
890
+ effects.push("Frihet may have sent invoice-creation usage and activation analytics to PostHog's EU-hosted analytics service.");
891
+ effects.push("Frihet may create in-app and Novu notifications for eligible workspace admins or accountants whose preferences allow them.");
892
+ effects.push("If this was a referred workspace's first invoice, Frihet may have awarded activation credits to the referring Frihet account.");
893
+ }
894
+ if (name === "create_expense") {
895
+ effects.push("Frihet may create in-app and Novu notifications for eligible workspace admins or accountants whose preferences allow them.");
896
+ effects.push("If this was a referred workspace's first expense, Frihet may have awarded activation credits to the referring Frihet account.");
897
+ }
898
+ value.externalEffects = effects;
899
+ }
900
+ return value;
901
+ }
902
+ function reviewedStructuredContentSummary(name, value) {
903
+ if (isRecord(value) && Array.isArray(value.data)) {
904
+ return `Frihet returned ${value.data.length} reviewed summary record(s) for ${name}. The declared fields are available in structuredContent.`;
905
+ }
906
+ if (OPENAI_REVIEW_CONFIRM_REQUIRED_TOOLS.has(name)) {
907
+ return `Frihet completed ${name}. The reviewed result and any disclosed external effects are available in structuredContent.`;
908
+ }
909
+ return `Frihet returned the reviewed ${name} result in structuredContent.`;
910
+ }
911
+ /** Remove reviewed input fields while preserving a Zod object's strictness. */
912
+ function stripReviewedInputFields(schema, fields) {
913
+ if (schema instanceof z.ZodObject) {
914
+ const shape = schema.shape;
915
+ const filtered = Object.fromEntries(Object.entries(shape).filter(([key]) => !fields.includes(key)));
916
+ const base = z.object(filtered);
917
+ const catchall = schema._def.catchall;
918
+ if (catchall instanceof z.ZodUnknown)
919
+ return base.passthrough();
920
+ if (catchall instanceof z.ZodNever)
921
+ return base.strict();
922
+ if (catchall)
923
+ return base.catchall(catchall);
924
+ return base;
925
+ }
926
+ if (schema !== null && typeof schema === "object" && !Array.isArray(schema)) {
927
+ const filtered = { ...schema };
928
+ for (const field of fields)
929
+ delete filtered[field];
930
+ return filtered;
931
+ }
932
+ return schema;
933
+ }
934
+ /** Make legacy-optional accounting choices explicit on the reviewed surface. */
935
+ function requireReviewedInputFields(schema, fields) {
936
+ if (!(schema instanceof z.ZodObject)) {
937
+ throw new Error("Reviewed required input fields need a Zod object schema");
938
+ }
939
+ const shape = schema.shape;
940
+ const required = {};
941
+ for (const field of fields) {
942
+ const value = shape[field];
943
+ if (!value)
944
+ throw new Error(`Reviewed required input field is missing: ${field}`);
945
+ required[field] = value.nonoptional();
946
+ }
947
+ return schema.extend(required);
948
+ }
949
+ /** Advertise and enforce a closed input object for every reviewed operation. */
950
+ function closeReviewedInputSchema(schema) {
951
+ if (schema instanceof z.ZodObject)
952
+ return schema.strict();
953
+ if (schema !== null && typeof schema === "object" && !Array.isArray(schema)) {
954
+ return z.object(schema).strict();
955
+ }
956
+ throw new Error("Reviewed tool input schema must be an object");
957
+ }
958
+ function constrainReviewedPagination(schema, maximum) {
959
+ const limit = z
960
+ .number()
961
+ .int()
962
+ .min(1)
963
+ .max(maximum)
964
+ .optional()
965
+ .describe(`Maximum ${maximum} rows per reviewed response`);
966
+ const offset = z
967
+ .number()
968
+ .int()
969
+ .min(0)
970
+ .max(OPENAI_REVIEW_OFFSET_MAX)
971
+ .optional()
972
+ .describe(`Reviewed pagination offset, maximum ${OPENAI_REVIEW_OFFSET_MAX}`);
973
+ if (schema instanceof z.ZodObject) {
974
+ const shape = schema.shape;
975
+ if (!shape.limit || !shape.offset) {
976
+ throw new Error("Reviewed paginated input must expose limit and offset");
977
+ }
978
+ return schema.extend({ limit, offset });
979
+ }
980
+ if (schema !== null && typeof schema === "object" && !Array.isArray(schema)) {
981
+ const shape = schema;
982
+ if (!shape.limit || !shape.offset) {
983
+ throw new Error("Reviewed paginated input must expose limit and offset");
984
+ }
985
+ return { ...shape, limit, offset };
986
+ }
987
+ throw new Error("Reviewed list input schema must be an object");
988
+ }
989
+ function constrainReviewedTextInputs(schema, limits) {
990
+ const replacements = Object.fromEntries(Object.entries(limits).map(([field, maximum]) => [
991
+ field,
992
+ z
993
+ .string()
994
+ .trim()
995
+ .min(1)
996
+ .max(maximum)
997
+ .optional()
998
+ .describe(`Reviewed ${field} text, maximum ${maximum} characters`),
999
+ ]));
1000
+ if (schema instanceof z.ZodObject)
1001
+ return schema.extend(replacements);
1002
+ if (schema !== null && typeof schema === "object" && !Array.isArray(schema)) {
1003
+ return { ...schema, ...replacements };
1004
+ }
1005
+ throw new Error("Reviewed text input schema must be an object");
1006
+ }
1007
+ function appendReviewedFreeTextWarning(schema) {
1008
+ const prefix = typeof schema.description === "string" && schema.description.length > 0
1009
+ ? `${schema.description} — `
1010
+ : "";
1011
+ return schema.describe(`${prefix}${OPENAI_REVIEW_FREE_TEXT_WARNING}`);
1012
+ }
1013
+ function warnReviewedRootTextFields(schema, name, fields) {
1014
+ const shape = schema instanceof z.ZodObject
1015
+ ? schema.shape
1016
+ : schema !== null && typeof schema === "object" && !Array.isArray(schema)
1017
+ ? schema
1018
+ : undefined;
1019
+ if (!shape) {
1020
+ throw new Error(`${name} reviewed free-text warnings require a Zod object`);
1021
+ }
1022
+ const warned = {};
1023
+ for (const field of fields) {
1024
+ const value = shape[field];
1025
+ if (!value)
1026
+ throw new Error(`${name} reviewed free-text field is missing: ${field}`);
1027
+ warned[field] = appendReviewedFreeTextWarning(value);
1028
+ }
1029
+ return schema instanceof z.ZodObject
1030
+ ? schema.extend(warned)
1031
+ : { ...shape, ...warned };
1032
+ }
1033
+ function constrainReviewedDocumentLineItems(schema, name) {
1034
+ const shape = schema instanceof z.ZodObject
1035
+ ? schema.shape
1036
+ : schema !== null && typeof schema === "object" && !Array.isArray(schema)
1037
+ ? schema
1038
+ : undefined;
1039
+ if (!shape) {
1040
+ throw new Error(`${name} reviewed line items require a Zod object`);
1041
+ }
1042
+ const items = shape.items;
1043
+ if (!(items instanceof z.ZodArray) || !(items.element instanceof z.ZodObject)) {
1044
+ throw new Error(`${name} reviewed line items must be an object array`);
1045
+ }
1046
+ const itemShape = items.element.shape;
1047
+ const description = itemShape.description;
1048
+ if (!description) {
1049
+ throw new Error(`${name} reviewed line-item description is missing`);
1050
+ }
1051
+ const warnedItem = items.element.extend({
1052
+ description: appendReviewedFreeTextWarning(description),
1053
+ });
1054
+ let constrained = z
1055
+ .array(warnedItem)
1056
+ .min(1)
1057
+ .max(OPENAI_REVIEW_DOCUMENT_LINE_ITEM_MAX);
1058
+ if (typeof items.description === "string") {
1059
+ constrained = constrained.describe(items.description);
1060
+ }
1061
+ return schema instanceof z.ZodObject
1062
+ ? schema.extend({ items: constrained })
1063
+ : { ...shape, items: constrained };
1064
+ }
1065
+ function reviewedConfirmationDescription(name) {
1066
+ if (name === "create_invoice") {
1067
+ return "Set true only after the user authorizes creating a numbered draft, advancing the numbering counter, consuming monthly invoice usage, sending invoice-creation analytics to PostHog's EU-hosted analytics service, possibly creating/linking the client, delivering owner-configured webhooks, notifying eligible workspace admins/accountants, and possibly awarding first-use referral credits.";
1068
+ }
1069
+ if (name === "create_quote") {
1070
+ return "Set true only after the user authorizes creating a numbered draft, advancing the numbering counter, possibly creating/linking the client, and delivering owner-configured webhooks.";
1071
+ }
1072
+ if (name === "create_expense") {
1073
+ return "Set true only after the user authorizes recording the expense on the supplied date and using the explicit deductible classification, possibly creating/linking the vendor in a separate step that may persist if the later expense write fails, delivering owner-configured webhooks, notifying eligible workspace admins/accountants, and possibly awarding first-use referral credits.";
1074
+ }
1075
+ if (name === "update_expense") {
1076
+ return "Set true only after the user authorizes changing the supplied description, category, date, or deductible classification and any accounting or future tax-report effect; amount and supplier identity cannot be changed here, and owner-configured webhooks may receive the resulting business event.";
1077
+ }
1078
+ if (name === "log_client_activity") {
1079
+ return "Set true only after the user authorizes adding this client activity; call, meeting, or email entries update the parent client's latest-activity fields and may deliver a full client.updated event to active owner-configured webhooks, while task entries do not update the parent client.";
1080
+ }
1081
+ if (name === "delete_quote") {
1082
+ return "Set true only after the user authorizes Frihet to permanently delete the quote if it is a clean draft with no delivery, response, attachment, or conversion evidence, or to retain and cancel it when non-draft; a protected draft is refused, and cancellation may deliver a quote-updated event to active owner-configured webhooks.";
1083
+ }
1084
+ if (REVIEWED_PERMANENT_DELETE_TOOLS.has(name)) {
1085
+ return "Set true only after the user authorizes this permanent deletion; it cannot be undone.";
1086
+ }
1087
+ if (OPENAI_WORKSPACE_WEBHOOK_EVENT_TOOLS.has(name)) {
1088
+ return "Set true only after the user authorizes this write; one or more active workspace webhooks may receive the resulting business event.";
1089
+ }
1090
+ return "Set true only after the user authorizes this change to a Frihet record.";
1091
+ }
1092
+ /** Add a required literal confirmation to OpenAI-only mutating schemas. */
1093
+ function addReviewedConfirmation(schema, name) {
1094
+ const confirm = z
1095
+ .literal(true)
1096
+ .describe(reviewedConfirmationDescription(name));
1097
+ if (schema instanceof z.ZodObject)
1098
+ return schema.extend({ confirm });
1099
+ if (schema !== null && typeof schema === "object" && !Array.isArray(schema)) {
1100
+ return { ...schema, confirm };
271
1101
  }
272
- // Primitives, unions, records, and anything else: leave untouched.
273
1102
  return schema;
274
1103
  }
275
1104
  /* ------------------------------------------------------------------ */
@@ -287,13 +1116,16 @@ const EXCLUDE_RESOURCES = new Set([
287
1116
  * OpenAI requires CSP specifying the exact domains the app fetches from.
288
1117
  */
289
1118
  export const OPENAI_CSP = "default-src 'self'; " +
1119
+ "base-uri 'none'; " +
1120
+ "object-src 'none'; " +
1121
+ "frame-ancestors 'none'; " +
290
1122
  "script-src 'self' 'unsafe-inline' https://www.gstatic.com https://apis.google.com; " +
291
1123
  "style-src 'self' 'unsafe-inline'; " +
292
- "connect-src 'self' https://api.frihet.io https://europe-west1-gen-lang-client-0335716041.cloudfunctions.net " +
293
- "https://identitytoolkit.googleapis.com https://securetoken.googleapis.com https://www.googleapis.com https://www.gstatic.com; " +
294
- "frame-src https://accounts.google.com https://github.com https://login.microsoftonline.com; " +
295
- "img-src 'self' data: https:; " +
296
- "font-src 'self' https://www.frihet.io";
1124
+ "connect-src 'self' https://auth.frihet.io https://identitytoolkit.googleapis.com " +
1125
+ "https://securetoken.googleapis.com https://www.googleapis.com; " +
1126
+ "frame-src https://auth.frihet.io https://accounts.google.com https://github.com https://login.microsoftonline.com; " +
1127
+ "img-src 'self' data:; " +
1128
+ "font-src 'self'";
297
1129
  /* ------------------------------------------------------------------ */
298
1130
  /* Profile applicator */
299
1131
  /* ------------------------------------------------------------------ */
@@ -316,6 +1148,7 @@ export const OPENAI_CSP = "default-src 'self'; " +
316
1148
  */
317
1149
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
318
1150
  export function applyOpenAIProfile(server) {
1151
+ installOpenAIToolsListSecurityProjection(server);
319
1152
  const fieldsToRedact = PROFILE.redactOutputFields;
320
1153
  /* ── Intercept registerTool ─────────────────────────────────────── */
321
1154
  const originalRegisterTool = server.registerTool.bind(server);
@@ -327,11 +1160,22 @@ export function applyOpenAIProfile(server) {
327
1160
  // 1. Skip excluded tools entirely
328
1161
  if (PROFILE.excludeTools.has(name))
329
1162
  return;
330
- // 2. Merge annotation overrides
1163
+ // 2. Apply the repository-wide capability truth, then merge any
1164
+ // OpenAI-specific override. This keeps destructive/idempotent/open-world
1165
+ // hints identical across public MCP surfaces.
1166
+ config.annotations = correctToolAnnotations(name, config.annotations);
331
1167
  const annOverrides = PROFILE.annotationOverrides[name];
332
1168
  if (annOverrides) {
333
1169
  config.annotations = { ...config.annotations, ...annOverrides };
334
1170
  }
1171
+ config.securitySchemes = OPENAI_OAUTH_SECURITY_SCHEMES.map((scheme) => ({
1172
+ type: scheme.type,
1173
+ scopes: [...scheme.scopes],
1174
+ }));
1175
+ config._meta = {
1176
+ ...(config._meta ?? {}),
1177
+ securitySchemes: config.securitySchemes,
1178
+ };
335
1179
  // 3. Replace descriptions
336
1180
  const descOverride = PROFILE.descriptionOverrides[name];
337
1181
  if (descOverride) {
@@ -339,7 +1183,7 @@ export function applyOpenAIProfile(server) {
339
1183
  }
340
1184
  // 3b. Ensure EVERY reviewed tool states an explicit openWorldHint rationale.
341
1185
  // OpenAI review requires openWorldHint to be explicitly true/false (never null)
342
- // with a clear justification per tool. The 4 open-world tools already embed a
1186
+ // with a clear justification per tool. The open-world tools already embed a
343
1187
  // bespoke "[openWorldHint: true — …]" rationale via descriptionOverrides; this
344
1188
  // appends the closed-world rationale to the remaining reviewed tools so the
345
1189
  // justification is present for all of them at tools/list. Only mutates the
@@ -348,38 +1192,223 @@ export function applyOpenAIProfile(server) {
348
1192
  if (typeof config.description === "string" &&
349
1193
  !config.description.includes("openWorldHint")) {
350
1194
  const ow = config.annotations?.openWorldHint;
1195
+ const openWorldRationale = name === "create_invoice"
1196
+ ? "may send invoice analytics to PostHog's EU-hosted analytics service, notify eligible admins/accountants through Novu, award referral credits, and deliver full business events to owner-configured webhook endpoints"
1197
+ : name === "create_expense"
1198
+ ? "may notify eligible admins/accountants through Novu, may award referral credits, and may deliver full business events to owner-configured webhook endpoints"
1199
+ : name === "delete_quote"
1200
+ ? "cancelling a non-draft quote may deliver a quote-updated event to owner-configured webhook endpoints"
1201
+ : "may deliver one or more resulting business events to owner-configured webhook endpoints";
1202
+ const closedWorldRationale = config.annotations?.readOnlyHint === true
1203
+ ? " [openWorldHint: false — it only reads the authenticated Frihet workspace.]"
1204
+ : " [openWorldHint: false — it only changes data inside the authenticated Frihet workspace.]";
351
1205
  config.description +=
352
1206
  ow === true
353
- ? " [openWorldHint: true — contacts an entity outside Frihet (an email recipient or an external webhook URL).]"
354
- : " [openWorldHint: false — operates only against the Frihet API (api.frihet.io); no third-party/external calls.]";
1207
+ ? OPENAI_WORKSPACE_WEBHOOK_EVENT_TOOLS.has(name)
1208
+ ? ` Requires confirm=true. [openWorldHint: true — ${openWorldRationale}.]`
1209
+ : " [openWorldHint: true — sends email to a recipient outside Frihet.]"
1210
+ : closedWorldRationale;
1211
+ }
1212
+ if (PROFILE_INJECTED_CONFIRM_TOOLS.has(name) &&
1213
+ typeof config.description === "string" &&
1214
+ !/confirm=true/i.test(config.description)) {
1215
+ config.description += REVIEWED_PERMANENT_DELETE_TOOLS.has(name)
1216
+ ? " Requires confirm=true because the deletion is permanent and cannot be undone."
1217
+ : OPENAI_WORKSPACE_WEBHOOK_EVENT_TOOLS.has(name)
1218
+ ? " Requires confirm=true before the requested write can run."
1219
+ : " Requires confirm=true before this Frihet record change can run.";
355
1220
  }
356
1221
  // 4. Strip sensitive input fields
357
1222
  const inputStrip = PROFILE.stripInputFields[name];
358
1223
  if (inputStrip && config.inputSchema) {
359
- for (const field of inputStrip) {
360
- delete config.inputSchema[field];
361
- }
1224
+ config.inputSchema = stripReviewedInputFields(config.inputSchema, inputStrip);
1225
+ }
1226
+ const requiredInput = PROFILE.requireInputFields[name];
1227
+ if (requiredInput && config.inputSchema) {
1228
+ config.inputSchema = requireReviewedInputFields(config.inputSchema, requiredInput);
1229
+ }
1230
+ if (OPENAI_REVIEW_CONFIRM_REQUIRED_TOOLS.has(name) && config.inputSchema) {
1231
+ config.inputSchema = addReviewedConfirmation(config.inputSchema, name);
1232
+ }
1233
+ const paginationLimit = OPENAI_REVIEW_PAGINATION_LIMITS[name];
1234
+ if (paginationLimit !== undefined && config.inputSchema) {
1235
+ config.inputSchema = constrainReviewedPagination(config.inputSchema, paginationLimit);
1236
+ }
1237
+ const textInputLimits = OPENAI_REVIEW_TEXT_INPUT_LIMITS[name];
1238
+ if (textInputLimits && config.inputSchema) {
1239
+ config.inputSchema = constrainReviewedTextInputs(config.inputSchema, textInputLimits);
1240
+ }
1241
+ const warningPaths = OPENAI_REVIEW_FREE_TEXT_WARNING_PATHS[name] ?? [];
1242
+ const rootWarningFields = warningPaths.filter((path) => !path.includes("[]"));
1243
+ if (rootWarningFields.length > 0) {
1244
+ config.inputSchema = warnReviewedRootTextFields(config.inputSchema, name, rootWarningFields);
1245
+ }
1246
+ if (name === "create_invoice" || name === "create_quote") {
1247
+ config.inputSchema = constrainReviewedDocumentLineItems(config.inputSchema, name);
362
1248
  }
363
- // 4b. Strip sensitive fields from the OUTPUT schema descriptor too.
1249
+ config.inputSchema = closeReviewedInputSchema(config.inputSchema ?? {});
1250
+ // 4a. Strip sensitive fields from the OUTPUT schema descriptor too.
364
1251
  // The handler wrapper (step 5) redacts VALUES at runtime; this removes the
365
1252
  // field DECLARATIONS (taxId/secret/iban/…) from the outputSchema advertised
366
1253
  // at tools/list, so OpenAI review never sees a gov-ID/credential field.
367
- if (config.outputSchema) {
368
- config.outputSchema = stripSensitiveOutputSchema(config.outputSchema, fieldsToRedact);
369
- }
370
- // 5. Wrap handler to redact sensitive output fields
1254
+ const reviewedOutputSchema = config.outputSchema
1255
+ ? customizeReviewedOutputSchema(name, buildReviewedOutputSchema(config.outputSchema, fieldsToRedact))
1256
+ : undefined;
1257
+ if (reviewedOutputSchema)
1258
+ config.outputSchema = reviewedOutputSchema;
1259
+ // 5. Wrap the handler to enforce reviewed confirmation, force safe input
1260
+ // values, and redact sensitive output fields. Schema validation is not the
1261
+ // authorization boundary: the wrapper itself refuses missing/false consent
1262
+ // before the underlying handler or API client can run.
371
1263
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
372
1264
  const wrappedHandler = async (input) => {
373
- const result = await handler(input);
374
- // Redact structuredContent (programmatic output)
1265
+ if (OPENAI_REVIEW_CONFIRM_REQUIRED_TOOLS.has(name) &&
1266
+ input?.confirm !== true) {
1267
+ const consequence = name === "delete_quote"
1268
+ ? "A clean draft with no delivery, response, attachment, or conversion evidence is permanently deleted; a protected draft is refused; a non-draft quote is retained and cancelled, and cancellation may deliver a quote-updated event to active endpoints configured by the workspace owner."
1269
+ : REVIEWED_PERMANENT_DELETE_TOOLS.has(name)
1270
+ ? "This permanently deletes a Frihet record and cannot be undone."
1271
+ : OPENAI_WORKSPACE_WEBHOOK_EVENT_TOOLS.has(name)
1272
+ ? "This write may deliver one or more business events to active endpoints configured by the workspace owner."
1273
+ : "This changes a record in the Frihet workspace.";
1274
+ return {
1275
+ content: [{
1276
+ type: "text",
1277
+ text: `Error: confirm=true is required. ${consequence}`,
1278
+ }],
1279
+ isError: true,
1280
+ };
1281
+ }
1282
+ const missingRequiredInput = (requiredInput ?? []).find((field) => !Object.hasOwn(input ?? {}, field) || input?.[field] === undefined);
1283
+ if (missingRequiredInput) {
1284
+ return {
1285
+ content: [{
1286
+ type: "text",
1287
+ text: `Error: ${missingRequiredInput} is required on the reviewed connector so its accounting consequence is explicit.`,
1288
+ }],
1289
+ isError: true,
1290
+ };
1291
+ }
1292
+ // The real MCP SDK validates against config.inputSchema before calling a
1293
+ // handler. Enforce the same contract again here so direct/internal calls
1294
+ // cannot bypass min/max/format constraints or smuggle undeclared fields.
1295
+ // Known stripped fields are removed first as defense-in-depth; they are
1296
+ // never forwarded to the ERP client.
1297
+ const validationInput = { ...(input ?? {}) };
1298
+ for (const field of inputStrip ?? [])
1299
+ delete validationInput[field];
1300
+ const validatedInput = config.inputSchema instanceof z.ZodObject
1301
+ ? await config.inputSchema.safeParseAsync(validationInput)
1302
+ : { success: true, data: validationInput };
1303
+ if (!validatedInput.success) {
1304
+ const issue = validatedInput.error.issues[0];
1305
+ const path = issue?.path.length ? issue.path.join(".") : "input";
1306
+ return {
1307
+ content: [{
1308
+ type: "text",
1309
+ text: `Error: invalid reviewed input at ${path}: ${issue?.message ?? "validation failed"}.`,
1310
+ }],
1311
+ isError: true,
1312
+ };
1313
+ }
1314
+ const handlerInput = { ...validatedInput.data };
1315
+ if (paginationLimit !== undefined && handlerInput.limit === undefined) {
1316
+ handlerInput.limit = Math.min(OPENAI_REVIEW_PAGINATION_DEFAULT, paginationLimit);
1317
+ }
1318
+ if (PROFILE_INJECTED_CONFIRM_TOOLS.has(name))
1319
+ delete handlerInput.confirm;
1320
+ Object.assign(handlerInput, PROFILE_FORCED_INPUT_VALUES[name] ?? {});
1321
+ if (REVIEWED_NON_EMPTY_PATCH_TOOLS.has(name) &&
1322
+ Object.keys(handlerInput).every((field) => field === "id")) {
1323
+ return {
1324
+ content: [{
1325
+ type: "text",
1326
+ text: "Error: provide at least one reviewed field to update.",
1327
+ }],
1328
+ isError: true,
1329
+ };
1330
+ }
1331
+ const result = await handler(handlerInput);
1332
+ // A transport/body failure, a 5xx, or an idempotency-in-progress response
1333
+ // after a write was sent is ambiguous: the backend may have committed it
1334
+ // even though the caller did not receive a usable success response.
1335
+ // Convert that internal marker into explicit, fail-closed model guidance.
1336
+ // Retrying with a fresh invocation-level idempotency key could otherwise
1337
+ // duplicate a record, advance numbering twice, or emit a webhook twice.
1338
+ if (result.isError === true &&
1339
+ config.annotations?.readOnlyHint === false &&
1340
+ (result._meta?.["io.frihet/operationOutcomeUnknown"] === true
1341
+ || result._meta?.["io.frihet/transportOutcomeUnknown"] === true)) {
1342
+ const vendorResidual = name === "create_expense"
1343
+ ? " A vendor created while handling this request may remain even if the expense itself was not created."
1344
+ : "";
1345
+ return {
1346
+ content: [{
1347
+ type: "text",
1348
+ text: `Frihet could not confirm the result of this write. It may already have completed.${vendorResidual} Do not retry automatically; first verify the record with the corresponding read or list tool.`,
1349
+ }],
1350
+ isError: true,
1351
+ _meta: {
1352
+ ...result._meta,
1353
+ "io.frihet/retryable": false,
1354
+ "io.frihet/operationMayHaveCompleted": true,
1355
+ },
1356
+ };
1357
+ }
1358
+ if (result.isError === true && name === "create_expense") {
1359
+ for (const block of result.content ?? []) {
1360
+ if (block.type === "text" && typeof block.text === "string") {
1361
+ block.text += " A vendor created while handling this request may remain even if the expense itself was not created.";
1362
+ }
1363
+ }
1364
+ }
1365
+ // Redact structuredContent, then project it through the closed reviewed
1366
+ // schema. This allowlist drops every undeclared backend field (including
1367
+ // future fields) rather than trying to maintain an exhaustive blacklist.
375
1368
  if (result.structuredContent) {
1369
+ customizeReviewedOutputValue(name, result.structuredContent);
376
1370
  deepRedact(result.structuredContent, fieldsToRedact);
1371
+ if (reviewedOutputSchema instanceof z.ZodType) {
1372
+ const projected = await reviewedOutputSchema.safeParseAsync(result.structuredContent);
1373
+ if (!projected.success) {
1374
+ if (config.annotations?.readOnlyHint === false) {
1375
+ const operationId = isRecord(result.structuredContent)
1376
+ && typeof result.structuredContent.id === "string"
1377
+ ? ` Result identifier: ${redactText(result.structuredContent.id, fieldsToRedact)}.`
1378
+ : "";
1379
+ return {
1380
+ content: [{
1381
+ type: "text",
1382
+ text: `Frihet may already have completed this write, but its response did not match the reviewed output contract.${operationId}${name === "create_expense" ? " A vendor created while handling this request may remain even if the expense itself was not created." : ""} Do not retry automatically; verify the record with the corresponding read or list tool.`,
1383
+ }],
1384
+ isError: true,
1385
+ _meta: {
1386
+ "io.frihet/retryable": false,
1387
+ "io.frihet/operationMayHaveCompleted": true,
1388
+ },
1389
+ };
1390
+ }
1391
+ return {
1392
+ content: [{
1393
+ type: "text",
1394
+ text: "Frihet could not return this result because it did not match the reviewed output contract.",
1395
+ }],
1396
+ isError: true,
1397
+ };
1398
+ }
1399
+ result.structuredContent = projected.data;
1400
+ }
377
1401
  }
378
- // Best-effort redact text content (display output)
1402
+ // Do not duplicate the full structured payload into the display block.
1403
+ // That doubled PII/free text and response size without adding model value.
1404
+ // Text-only errors still use the conservative redaction fallback.
379
1405
  if (Array.isArray(result.content)) {
1406
+ const safeStructuredText = result.structuredContent
1407
+ ? reviewedStructuredContentSummary(name, result.structuredContent)
1408
+ : undefined;
380
1409
  for (const block of result.content) {
381
1410
  if (block.type === "text" && typeof block.text === "string") {
382
- block.text = redactText(block.text, fieldsToRedact);
1411
+ block.text = safeStructuredText ?? redactText(block.text, fieldsToRedact);
383
1412
  }
384
1413
  }
385
1414
  }
@@ -394,7 +1423,7 @@ export function applyOpenAIProfile(server) {
394
1423
  server.registerResource = (name, ...rest) => {
395
1424
  // Public ChatGPT Apps do not need MCP resources. Several full-server
396
1425
  // resources contain broad fiscal/compliance reference material or raw
397
- // workspace lists that are outside the reviewed 53-tool business surface.
1426
+ // workspace lists that are outside the reviewed 33-tool MCP surface.
398
1427
  if (PROFILE.excludeResources)
399
1428
  return;
400
1429
  // Skip resources that expose too much raw PII
@@ -438,34 +1467,15 @@ export function applyOpenAIProfile(server) {
438
1467
  export const OPENAI_EXCLUDED_COUNT = PROFILE.excludeTools.size;
439
1468
  /** Number of tools explicitly allowed in OpenAI mode. */
440
1469
  export const OPENAI_ALLOWED_TOOL_COUNT = PROFILE.includeTools.size;
441
- /**
442
- * The reviewed OpenAI tool allow-list (the 53 business tools), exposed read-only
443
- * so the grouped tool-exposure profile can pin its catalog to EXACTLY this set
444
- * when the two profiles are composed on openai-mcp.frihet.io. The progressive-
445
- * disclosure meta-tools (search_tools / describe_tool / list_tool_groups) must
446
- * never surface a tool outside this allow-list; passing it as the grouped
447
- * `allowlist` enforces that statically. This is the SAME object used to gate
448
- * registerTool, so the two can never drift apart.
449
- *
450
- * NOTE: this is the 53 BUSINESS tools only. It deliberately does NOT contain the
451
- * 3 grouped meta-tools — those are registered directly on the real server
452
- * (bypassing the OpenAI gate) by applyToolExposureProfile when it runs first, so
453
- * OPENAI_ALLOWED_TOOL_COUNT (and every advertised "53 reviewed tools" doc) stays
454
- * correct while the live tools/list still materialises 53 + 3 = 56 tools.
455
- */
1470
+ /** The exact 33-tool allow-list reviewed for the OpenAI connector. */
456
1471
  export const OPENAI_REVIEWED_TOOL_ALLOWLIST = PROFILE.includeTools;
457
1472
  /**
458
- * Apply the exact profile composition used by the ChatGPT review Worker.
459
- *
460
- * Keep this helper as the single source for the security-sensitive ordering:
461
- * grouped exposure first (with the frozen allow-list), OpenAI profile second.
462
- * Tool registration happens afterwards through registerAllTools().
1473
+ * Backwards-compatible helper for tests and gates that capture the exact
1474
+ * ChatGPT review profile. The reviewed host deliberately uses full tool
1475
+ * descriptions and exposes no grouped discovery meta-tools.
463
1476
  */
464
1477
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
465
1478
  export function applyOpenAIReviewProfiles(server) {
466
- applyToolExposureProfile(server, {
467
- allowlist: OPENAI_REVIEWED_TOOL_ALLOWLIST,
468
- });
469
1479
  applyOpenAIProfile(server);
470
1480
  }
471
1481
  /** Number of resources excluded in OpenAI mode (for logging). */