@frihet/mcp-server 1.16.6 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +168 -82
- package/dist/agent-onboarding.d.ts +98 -0
- package/dist/agent-onboarding.js +275 -0
- package/dist/api-origin.d.ts +9 -0
- package/dist/api-origin.js +41 -0
- package/dist/canary/mcp-harness.d.ts +77 -0
- package/dist/canary/mcp-harness.js +552 -0
- package/dist/capability-truth.d.ts +29 -0
- package/dist/capability-truth.js +262 -0
- package/dist/claude-candidate-contract.d.ts +482 -0
- package/dist/claude-candidate-contract.js +87 -0
- package/dist/claude-output-privacy.d.ts +17 -0
- package/dist/claude-output-privacy.js +240 -0
- package/dist/claude-profile.d.ts +18 -0
- package/dist/claude-profile.js +260 -0
- package/dist/client-interface.d.ts +47 -33
- package/dist/client.d.ts +172 -40
- package/dist/client.js +708 -165
- package/dist/demo-client.d.ts +41 -32
- package/dist/demo-client.js +202 -31
- package/dist/demo-fixtures.d.ts +3 -3
- package/dist/demo-fixtures.js +7 -7
- package/dist/deposit-contract.d.ts +29 -0
- package/dist/deposit-contract.js +76 -0
- package/dist/fetch-no-redirect.d.ts +3 -0
- package/dist/fetch-no-redirect.js +16 -0
- package/dist/fiscal-aliases.d.ts +44 -0
- package/dist/fiscal-aliases.js +62 -0
- package/dist/fiscal-period.d.ts +26 -0
- package/dist/fiscal-period.js +31 -0
- package/dist/google-profile.d.ts +19 -0
- package/dist/google-profile.js +122 -0
- package/dist/index.js +68 -59
- package/dist/logger.d.ts +2 -2
- package/dist/logger.js +140 -8
- package/dist/muse-profile.d.ts +19 -0
- package/dist/muse-profile.js +122 -0
- package/dist/observability.d.ts +18 -29
- package/dist/observability.js +194 -106
- package/dist/openai-profile.d.ts +26 -22
- package/dist/openai-profile.js +1193 -157
- package/dist/openai-review-contract.d.ts +15 -6
- package/dist/openai-review-contract.js +714 -41
- package/dist/openai-review-oauth.d.ts +149 -2
- package/dist/openai-review-oauth.js +220 -6
- package/dist/prompts/register-all.d.ts +2 -0
- package/dist/prompts/register-all.js +34 -78
- package/dist/public-capability-contract.d.ts +39 -0
- package/dist/public-capability-contract.js +186 -0
- package/dist/redaction.d.ts +15 -0
- package/dist/redaction.js +83 -0
- package/dist/resources/register-all.js +154 -64
- package/dist/retry-after.d.ts +19 -0
- package/dist/retry-after.js +84 -0
- package/dist/server-composition.d.ts +19 -0
- package/dist/server-composition.js +74 -0
- package/dist/tool-exposure.d.ts +31 -27
- package/dist/tool-exposure.js +211 -87
- package/dist/tools/accountingClose.d.ts +14 -5
- package/dist/tools/accountingClose.js +41 -23
- package/dist/tools/audit_gl.js +8 -8
- package/dist/tools/backend-availability.d.ts +8 -29
- package/dist/tools/backend-availability.js +31 -30
- package/dist/tools/bank_rules.js +66 -15
- package/dist/tools/banking.js +5 -13
- package/dist/tools/clients.js +261 -15
- package/dist/tools/crm.js +22 -21
- package/dist/tools/deposits.js +114 -85
- package/dist/tools/einvoice.d.ts +16 -2
- package/dist/tools/einvoice.js +59 -12
- package/dist/tools/expenses.d.ts +16 -0
- package/dist/tools/expenses.js +47 -27
- package/dist/tools/fiscal.d.ts +3 -4
- package/dist/tools/fiscal.js +93 -60
- package/dist/tools/gestoria.js +15 -7
- package/dist/tools/hr.d.ts +2 -3
- package/dist/tools/hr.js +49 -20
- package/dist/tools/igic.d.ts +9 -8
- package/dist/tools/igic.js +34 -64
- package/dist/tools/impuesto_sociedades.d.ts +4 -5
- package/dist/tools/impuesto_sociedades.js +16 -37
- package/dist/tools/intelligence.js +166 -12
- package/dist/tools/invoices.js +224 -54
- package/dist/tools/kitchen.js +19 -9
- package/dist/tools/payroll.d.ts +5 -8
- package/dist/tools/payroll.js +19 -32
- package/dist/tools/permissions.d.ts +4 -7
- package/dist/tools/permissions.js +11 -14
- package/dist/tools/products.js +6 -6
- package/dist/tools/quotes.js +172 -26
- package/dist/tools/recurring.js +26 -25
- package/dist/tools/register-all.d.ts +2 -2
- package/dist/tools/register-all.js +9 -6
- package/dist/tools/search.d.ts +19 -0
- package/dist/tools/search.js +84 -0
- package/dist/tools/shared.d.ts +1445 -109
- package/dist/tools/shared.js +478 -102
- package/dist/tools/team.js +24 -28
- package/dist/tools/time.js +4 -12
- package/dist/tools/vendors.js +249 -15
- package/dist/tools/webhooks.js +35 -30
- package/dist/types.d.ts +31 -5
- package/dist/workflows/daily-operational-brief.d.ts +163 -0
- package/dist/workflows/daily-operational-brief.js +881 -0
- package/docs/agent-onboarding.json +391 -0
- package/package.json +49 -12
- package/dist/tools/onboarding.d.ts +0 -19
- package/dist/tools/onboarding.js +0 -64
package/dist/openai-profile.js
CHANGED
|
@@ -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 (
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
119
|
-
"
|
|
120
|
-
"
|
|
121
|
-
"
|
|
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
|
-
|
|
131
|
-
"
|
|
132
|
-
|
|
133
|
-
"
|
|
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
|
-
|
|
143
|
-
"
|
|
144
|
-
"
|
|
145
|
-
"via
|
|
146
|
-
"
|
|
147
|
-
|
|
148
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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:
|
|
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
|
|
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
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
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
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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 =
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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://
|
|
293
|
-
"https://
|
|
294
|
-
"frame-src https://accounts.google.com https://github.com https://login.microsoftonline.com; " +
|
|
295
|
-
"img-src 'self' data
|
|
296
|
-
"font-src 'self'
|
|
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.
|
|
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
|
|
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
|
-
?
|
|
354
|
-
|
|
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
|
-
|
|
360
|
-
delete config.inputSchema[field];
|
|
361
|
-
}
|
|
1244
|
+
config.inputSchema = stripReviewedInputFields(config.inputSchema, inputStrip);
|
|
362
1245
|
}
|
|
363
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
374
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
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). */
|