@frihet/mcp-server 1.16.6 → 2.0.0

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