@frihet/mcp-server 1.16.6 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +92 -31
  2. package/dist/agent-onboarding.d.ts +98 -0
  3. package/dist/agent-onboarding.js +275 -0
  4. package/dist/api-origin.d.ts +9 -0
  5. package/dist/api-origin.js +41 -0
  6. package/dist/canary/mcp-harness.d.ts +77 -0
  7. package/dist/canary/mcp-harness.js +549 -0
  8. package/dist/capability-truth.d.ts +29 -0
  9. package/dist/capability-truth.js +254 -0
  10. package/dist/client-interface.d.ts +22 -14
  11. package/dist/client.d.ts +114 -18
  12. package/dist/client.js +488 -56
  13. package/dist/demo-client.d.ts +16 -13
  14. package/dist/demo-client.js +171 -17
  15. package/dist/demo-fixtures.d.ts +3 -3
  16. package/dist/demo-fixtures.js +7 -7
  17. package/dist/fiscal-aliases.d.ts +44 -0
  18. package/dist/fiscal-aliases.js +62 -0
  19. package/dist/index.js +41 -56
  20. package/dist/logger.d.ts +2 -2
  21. package/dist/logger.js +140 -8
  22. package/dist/observability.d.ts +18 -29
  23. package/dist/observability.js +192 -105
  24. package/dist/openai-profile.d.ts +25 -21
  25. package/dist/openai-profile.js +1166 -156
  26. package/dist/openai-review-contract.d.ts +15 -6
  27. package/dist/openai-review-contract.js +715 -41
  28. package/dist/openai-review-oauth.d.ts +47 -0
  29. package/dist/openai-review-oauth.js +73 -3
  30. package/dist/prompts/register-all.d.ts +2 -0
  31. package/dist/prompts/register-all.js +2 -0
  32. package/dist/public-capability-contract.d.ts +39 -0
  33. package/dist/public-capability-contract.js +186 -0
  34. package/dist/redaction.d.ts +10 -0
  35. package/dist/redaction.js +74 -0
  36. package/dist/resources/register-all.js +6 -12
  37. package/dist/server-composition.d.ts +16 -0
  38. package/dist/server-composition.js +44 -0
  39. package/dist/tool-exposure.d.ts +25 -22
  40. package/dist/tool-exposure.js +199 -77
  41. package/dist/tools/accountingClose.d.ts +14 -5
  42. package/dist/tools/accountingClose.js +41 -23
  43. package/dist/tools/backend-availability.d.ts +3 -3
  44. package/dist/tools/backend-availability.js +3 -3
  45. package/dist/tools/clients.js +9 -9
  46. package/dist/tools/crm.js +22 -21
  47. package/dist/tools/deposits.js +30 -11
  48. package/dist/tools/einvoice.d.ts +1 -1
  49. package/dist/tools/expenses.d.ts +16 -0
  50. package/dist/tools/expenses.js +47 -27
  51. package/dist/tools/hr.d.ts +2 -3
  52. package/dist/tools/hr.js +8 -9
  53. package/dist/tools/intelligence.js +2 -2
  54. package/dist/tools/invoices.js +150 -47
  55. package/dist/tools/payroll.d.ts +5 -8
  56. package/dist/tools/payroll.js +19 -32
  57. package/dist/tools/permissions.d.ts +4 -7
  58. package/dist/tools/permissions.js +11 -14
  59. package/dist/tools/products.js +6 -6
  60. package/dist/tools/quotes.js +109 -24
  61. package/dist/tools/shared.d.ts +1431 -75
  62. package/dist/tools/shared.js +386 -76
  63. package/dist/tools/team.js +18 -14
  64. package/dist/tools/vendors.js +9 -9
  65. package/dist/tools/webhooks.js +35 -30
  66. package/dist/types.d.ts +28 -4
  67. package/docs/agent-onboarding.json +381 -0
  68. package/package.json +30 -7
@@ -2,25 +2,41 @@
2
2
  * Fail-closed contract capture for the ChatGPT-reviewed MCP surface.
3
3
  *
4
4
  * This intentionally uses a real McpServer, real Client, in-memory MCP
5
- * transport, and the production registration path. It therefore freezes what
5
+ * transport, and the production registration path. It therefore captures what
6
6
  * tools/list serializes, not an approximation of registration config objects.
7
7
  */
8
8
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
9
9
  import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
10
10
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
- import { applyOpenAIReviewProfiles, OPENAI_REVIEWED_TOOL_ALLOWLIST, } from "./openai-profile.js";
11
+ import { OPENAI_REVIEW_BUSINESS_CONTEXT_TOP_CLIENTS_MAX, OPENAI_REVIEW_DOCUMENT_LINE_ITEM_MAX, OPENAI_REVIEW_FREE_TEXT_WARNING, OPENAI_REVIEW_FREE_TEXT_WARNING_PATHS, OPENAI_REVIEW_LIST_OUTPUT_FIELDS, OPENAI_REVIEW_OFFSET_MAX, OPENAI_REVIEW_PAGINATION_LIMITS, OPENAI_REVIEW_TEXT_INPUT_LIMITS, OPENAI_REVIEW_CONFIRM_REQUIRED_TOOLS, OPENAI_REVIEWED_TOOL_ALLOWLIST, OPENAI_WORKSPACE_WEBHOOK_EVENT_TOOLS, } from "./openai-profile.js";
12
12
  import { SENSITIVE_FIELD_NAMES } from "./redaction.js";
13
- import { registerAllPrompts } from "./prompts/register-all.js";
14
- import { registerAllResources } from "./resources/register-all.js";
15
- import { registerAllTools } from "./tools/register-all.js";
16
- export const OPENAI_REVIEW_CONTRACT_VERSION = 1;
17
- export const OPENAI_REVIEW_BUSINESS_TOOL_COUNT = 53;
18
- export const OPENAI_REVIEW_DISCOVERY_TOOLS = [
19
- "describe_tool",
20
- "list_tool_groups",
21
- "search_tools",
13
+ import { FRIHET_CONNECTOR_SCOPE } from "./openai-review-oauth.js";
14
+ import { registerMcpSurface, remoteMcpSurfaceComposition, } from "./server-composition.js";
15
+ export const OPENAI_REVIEW_CONTRACT_VERSION = 6;
16
+ export const OPENAI_REVIEW_BUSINESS_TOOL_COUNT = 33;
17
+ export const OPENAI_REVIEW_TOTAL_TOOL_COUNT = OPENAI_REVIEW_BUSINESS_TOOL_COUNT;
18
+ const FORBIDDEN_REVIEW_TOOLS = [
19
+ "get_invoice_pdf",
20
+ "get_monthly_summary",
21
+ "apply_late_fee",
22
+ "update_invoice",
23
+ "mark_invoice_paid",
24
+ "delete_invoice",
25
+ "send_invoice",
26
+ "duplicate_invoice",
27
+ "create_credit_note",
28
+ "delete_client",
29
+ "delete_expense",
30
+ "delete_product",
31
+ "delete_vendor",
32
+ "send_quote",
33
+ "update_quote",
34
+ "list_webhooks",
35
+ "get_webhook",
36
+ "create_webhook",
37
+ "update_webhook",
38
+ "delete_webhook",
22
39
  ];
23
- export const OPENAI_REVIEW_TOTAL_TOOL_COUNT = 56;
24
40
  function makeRegistrationClient() {
25
41
  return new Proxy({}, {
26
42
  get: () => async () => ({ data: [], total: 0, limit: 0, offset: 0 }),
@@ -66,40 +82,62 @@ async function listResourcesOrEmpty(client) {
66
82
  }
67
83
  return resources;
68
84
  }
69
- /** Capture the exact OpenAI grouped surface over a real tools/list request. */
70
- export async function captureOpenAIReviewMcpSurface() {
71
- const server = new McpServer({
72
- name: "frihet-openai-review-freeze",
85
+ /** Capture the exact OpenAI full-description surface with a caller-selected SDK tree. */
86
+ export async function captureOpenAIReviewMcpSurfaceWithRuntime(sdkRuntime) {
87
+ const server = new sdkRuntime.McpServer({
88
+ name: "frihet-openai-review-contract",
73
89
  version: "1.0.0",
74
90
  });
75
- applyOpenAIReviewProfiles(server);
76
- registerAllTools(server, makeRegistrationClient());
77
- registerAllResources(server);
78
- registerAllPrompts(server);
79
- const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
80
- const client = new Client({ name: "frihet-openai-review-freeze-client", version: "1.0.0" }, { capabilities: {} });
91
+ registerMcpSurface(server, makeRegistrationClient(), remoteMcpSurfaceComposition(true, false));
92
+ const [clientTransport, serverTransport] = sdkRuntime.InMemoryTransport.createLinkedPair();
93
+ // The MCP SDK client parses tools/list through the generic MCP ToolSchema,
94
+ // which currently strips OpenAI's top-level `securitySchemes` extension.
95
+ // Capture the server's actual wire result so this contract proves what the
96
+ // review scanner receives, including both the standard OpenAI field and its
97
+ // legacy `_meta` mirror.
98
+ const wireTools = [];
99
+ const originalServerSend = serverTransport.send.bind(serverTransport);
100
+ serverTransport.send = async (message, options) => {
101
+ if (typeof message === "object"
102
+ && message !== null
103
+ && "result" in message
104
+ && typeof message.result === "object"
105
+ && message.result !== null
106
+ && "tools" in message.result
107
+ && Array.isArray(message.result.tools)) {
108
+ wireTools.push(...message.result.tools);
109
+ }
110
+ await originalServerSend(message, options);
111
+ };
112
+ const client = new sdkRuntime.Client({ name: "frihet-openai-review-contract-client", version: "1.0.0" }, { capabilities: {} });
81
113
  try {
82
114
  await Promise.all([
83
115
  server.connect(serverTransport),
84
116
  client.connect(clientTransport),
85
117
  ]);
86
- const tools = [];
87
118
  let cursor;
88
119
  do {
89
120
  const page = await client.listTools(cursor ? { cursor } : undefined);
90
- tools.push(...page.tools);
91
121
  cursor = page.nextCursor;
92
122
  } while (cursor);
93
123
  const [prompts, resources] = await Promise.all([
94
124
  listPromptsOrEmpty(client),
95
125
  listResourcesOrEmpty(client),
96
126
  ]);
97
- return { tools, prompts, resources };
127
+ return { tools: wireTools, prompts, resources };
98
128
  }
99
129
  finally {
100
130
  await Promise.allSettled([client.close(), server.close()]);
101
131
  }
102
132
  }
133
+ /** Capture the package-local surface; deploy gates inject the Worker runtime. */
134
+ export async function captureOpenAIReviewMcpSurface() {
135
+ return captureOpenAIReviewMcpSurfaceWithRuntime({
136
+ Client,
137
+ InMemoryTransport,
138
+ McpServer,
139
+ });
140
+ }
103
141
  export function buildOpenAIReviewContract(surface, oauth) {
104
142
  return {
105
143
  contractVersion: OPENAI_REVIEW_CONTRACT_VERSION,
@@ -129,14 +167,23 @@ export function serializeOpenAIReviewContract(contract) {
129
167
  return `${JSON.stringify(canonicalizeOpenAIReviewContract(contract), null, 2)}\n`;
130
168
  }
131
169
  function schemaSensitivePaths(tools) {
132
- const sensitive = new Set(SENSITIVE_FIELD_NAMES.map((field) => field.toLowerCase()));
133
- // `documentNumber` is an invoice sequence identifier on these two tools,
134
- // not the guest/customer identity-document field covered by the shared
135
- // redaction policy. Keep the exception exact so any future occurrence is
136
- // still rejected by default.
137
- const nonSensitiveBusinessPaths = new Set([
138
- "create_invoice.inputSchema.properties.documentNumber",
139
- "update_invoice.inputSchema.properties.documentNumber",
170
+ const sensitive = new Set([
171
+ ...SENSITIVE_FIELD_NAMES.map((field) => field.toLowerCase()),
172
+ // Opaque documents cannot be inspected by field-level redaction. A raw
173
+ // base64 payload on the reviewed surface can therefore smuggle regulated
174
+ // identifiers even when every surrounding JSON key is safe.
175
+ "base64",
176
+ // The reviewed connector does not request or echo precise address fields,
177
+ // nor does its monthly summary expose filing-estimate payloads.
178
+ "address",
179
+ "clientaddress",
180
+ "clientlocation",
181
+ "taxliability",
182
+ "estimatedmodel303",
183
+ "vatpayable",
184
+ "irpfretained",
185
+ "fiscalzone",
186
+ "series",
140
187
  ]);
141
188
  const paths = new Set();
142
189
  const visit = (value, path) => {
@@ -148,8 +195,7 @@ function schemaSensitivePaths(tools) {
148
195
  }
149
196
  for (const [key, child] of Object.entries(value)) {
150
197
  const childPath = `${path}.${key}`;
151
- if (sensitive.has(key.toLowerCase()) &&
152
- !nonSensitiveBusinessPaths.has(childPath)) {
198
+ if (sensitive.has(key.toLowerCase())) {
153
199
  paths.add(childPath);
154
200
  }
155
201
  visit(child, childPath);
@@ -197,6 +243,88 @@ function preview(value) {
197
243
  return "<missing>";
198
244
  return serialized.length > 180 ? `${serialized.slice(0, 177)}...` : serialized;
199
245
  }
246
+ function jsonObject(value) {
247
+ return value !== null && value !== undefined && !Array.isArray(value) && typeof value === "object"
248
+ ? value
249
+ : undefined;
250
+ }
251
+ function assertClosedReviewedOutputSchema(value, path) {
252
+ if (value === undefined || value === null || typeof value !== "object")
253
+ return;
254
+ if (Array.isArray(value)) {
255
+ value.forEach((child, index) => assertClosedReviewedOutputSchema(child, `${path}[${index}]`));
256
+ return;
257
+ }
258
+ if (value.type === "object") {
259
+ if (value.additionalProperties !== false) {
260
+ throw new Error(`${path} must set additionalProperties=false`);
261
+ }
262
+ const properties = value.properties;
263
+ if (properties === null
264
+ || Array.isArray(properties)
265
+ || typeof properties !== "object"
266
+ || Object.keys(properties).length === 0) {
267
+ throw new Error(`${path} must declare at least one reviewed property`);
268
+ }
269
+ }
270
+ for (const [key, child] of Object.entries(value)) {
271
+ assertClosedReviewedOutputSchema(child, `${path}.${key}`);
272
+ }
273
+ }
274
+ function assertClosedReviewedInputSchema(value, path) {
275
+ if (value === undefined || value === null || typeof value !== "object")
276
+ return;
277
+ if (Array.isArray(value)) {
278
+ value.forEach((child, index) => assertClosedReviewedInputSchema(child, `${path}[${index}]`));
279
+ return;
280
+ }
281
+ if (value.type === "object" && value.additionalProperties !== false) {
282
+ throw new Error(`${path} must set additionalProperties=false`);
283
+ }
284
+ for (const [key, child] of Object.entries(value)) {
285
+ assertClosedReviewedInputSchema(child, `${path}.${key}`);
286
+ }
287
+ }
288
+ function assertReviewedOAuthSecuritySchemes(value, path) {
289
+ if (!Array.isArray(value) || value.length !== 1) {
290
+ throw new Error(`${path} must contain exactly one reviewed OAuth scheme`);
291
+ }
292
+ const scheme = value[0];
293
+ if (scheme === null
294
+ || Array.isArray(scheme)
295
+ || typeof scheme !== "object"
296
+ || Object.keys(scheme).sort().join(",") !== "scopes,type"
297
+ || scheme.type !== "oauth2"
298
+ || !Array.isArray(scheme.scopes)
299
+ || scheme.scopes.length !== 1
300
+ || scheme.scopes[0] !== FRIHET_CONNECTOR_SCOPE) {
301
+ throw new Error(`${path} must require only the reviewed connector OAuth scope`);
302
+ }
303
+ }
304
+ function reviewedInputFieldSchema(tool, path) {
305
+ let current = tool?.inputSchema;
306
+ for (const rawSegment of path.split(".")) {
307
+ const arraySegment = rawSegment.endsWith("[]");
308
+ const segment = arraySegment ? rawSegment.slice(0, -2) : rawSegment;
309
+ if (current === null || Array.isArray(current) || typeof current !== "object") {
310
+ return undefined;
311
+ }
312
+ const properties = current.properties;
313
+ if (properties === null || Array.isArray(properties) || typeof properties !== "object") {
314
+ return undefined;
315
+ }
316
+ current = properties[segment];
317
+ if (arraySegment) {
318
+ if (current === null || Array.isArray(current) || typeof current !== "object") {
319
+ return undefined;
320
+ }
321
+ current = current.items;
322
+ }
323
+ }
324
+ return current !== null && !Array.isArray(current) && typeof current === "object"
325
+ ? current
326
+ : undefined;
327
+ }
200
328
  /**
201
329
  * Fail closed on any semantic drift from the reviewed descriptor snapshot.
202
330
  */
@@ -211,19 +339,553 @@ export function assertOpenAIReviewContract(actual, expected) {
211
339
  if (new Set(names).size !== names.length) {
212
340
  throw new Error("OpenAI review surface contains duplicate tool names");
213
341
  }
214
- const discovery = new Set(OPENAI_REVIEW_DISCOVERY_TOOLS);
215
- const businessNames = names.filter((name) => !discovery.has(name));
342
+ const businessNames = names;
216
343
  if (businessNames.length !== OPENAI_REVIEW_BUSINESS_TOOL_COUNT) {
217
344
  throw new Error(`OpenAI review surface must expose exactly ${OPENAI_REVIEW_BUSINESS_TOOL_COUNT} business tools; got ${businessNames.length}`);
218
345
  }
219
- const missingDiscovery = OPENAI_REVIEW_DISCOVERY_TOOLS.filter((name) => !names.includes(name));
220
- if (missingDiscovery.length > 0) {
221
- throw new Error(`Missing discovery tools: ${missingDiscovery.join(", ")}`);
346
+ const forbidden = FORBIDDEN_REVIEW_TOOLS.filter((name) => names.includes(name));
347
+ if (forbidden.length > 0) {
348
+ throw new Error(`Unsafe tools are forbidden in the OpenAI review surface: ${forbidden.join(", ")}`);
222
349
  }
223
350
  const leaks = businessNames.filter((name) => !OPENAI_REVIEWED_TOOL_ALLOWLIST.has(name));
224
351
  if (leaks.length > 0) {
225
352
  throw new Error(`Non-reviewed tools leaked into OpenAI surface: ${leaks.join(", ")}`);
226
353
  }
354
+ const byName = new Map(actual.tools.map((tool) => [tool.name, tool]));
355
+ for (const tool of actual.tools) {
356
+ const annotations = tool.annotations;
357
+ if (annotations === null ||
358
+ Array.isArray(annotations) ||
359
+ typeof annotations !== "object") {
360
+ throw new Error(`${tool.name} must declare annotations`);
361
+ }
362
+ for (const hint of [
363
+ "readOnlyHint",
364
+ "destructiveHint",
365
+ "idempotentHint",
366
+ "openWorldHint",
367
+ ]) {
368
+ if (typeof annotations[hint] !== "boolean") {
369
+ throw new Error(`${tool.name}.${hint} must be an explicit boolean`);
370
+ }
371
+ }
372
+ assertReviewedOAuthSecuritySchemes(tool.securitySchemes, `${tool.name}.securitySchemes`);
373
+ const metadata = tool._meta;
374
+ assertReviewedOAuthSecuritySchemes(metadata !== null
375
+ && !Array.isArray(metadata)
376
+ && typeof metadata === "object"
377
+ ? metadata.securitySchemes
378
+ : undefined, `${tool.name}._meta.securitySchemes`);
379
+ if (!tool.outputSchema)
380
+ throw new Error(`${tool.name} must declare an output schema`);
381
+ assertClosedReviewedOutputSchema(tool.outputSchema, `${tool.name}.outputSchema`);
382
+ const inputSchema = tool.inputSchema;
383
+ if (inputSchema === null
384
+ || Array.isArray(inputSchema)
385
+ || typeof inputSchema !== "object"
386
+ || inputSchema.type !== "object"
387
+ || inputSchema.additionalProperties !== false) {
388
+ throw new Error(`${tool.name}.inputSchema must be a closed object`);
389
+ }
390
+ assertClosedReviewedInputSchema(inputSchema, `${tool.name}.inputSchema`);
391
+ }
392
+ for (const name of [
393
+ "list_invoices",
394
+ "search_invoices",
395
+ "list_expenses",
396
+ "list_clients",
397
+ "list_products",
398
+ "list_quotes",
399
+ "list_vendors",
400
+ ]) {
401
+ const output = byName.get(name)?.outputSchema;
402
+ const item = output
403
+ && !Array.isArray(output)
404
+ && typeof output === "object"
405
+ && output.properties
406
+ && !Array.isArray(output.properties)
407
+ && typeof output.properties === "object"
408
+ ? output.properties.data
409
+ : undefined;
410
+ const row = item
411
+ && !Array.isArray(item)
412
+ && typeof item === "object"
413
+ && item.items
414
+ && !Array.isArray(item.items)
415
+ && typeof item.items === "object"
416
+ ? item.items
417
+ : undefined;
418
+ if (!row || !Array.isArray(row.required) || !row.required.includes("id")) {
419
+ throw new Error(`${name} rows must require an id after fields projection is removed`);
420
+ }
421
+ }
422
+ for (const [name, expectedFields] of Object.entries(OPENAI_REVIEW_LIST_OUTPUT_FIELDS)) {
423
+ const tool = byName.get(name);
424
+ const outputSchema = tool?.outputSchema;
425
+ const outputProperties = outputSchema !== null
426
+ && !Array.isArray(outputSchema)
427
+ && typeof outputSchema === "object"
428
+ && outputSchema.properties !== null
429
+ && !Array.isArray(outputSchema.properties)
430
+ && typeof outputSchema.properties === "object"
431
+ ? outputSchema.properties
432
+ : undefined;
433
+ const data = outputProperties?.data;
434
+ const item = data !== null
435
+ && !Array.isArray(data)
436
+ && typeof data === "object"
437
+ && data.items !== null
438
+ && !Array.isArray(data.items)
439
+ && typeof data.items === "object"
440
+ ? data.items
441
+ : undefined;
442
+ const itemProperties = item?.properties !== null
443
+ && !Array.isArray(item?.properties)
444
+ && typeof item?.properties === "object"
445
+ ? item.properties
446
+ : undefined;
447
+ const actualFields = itemProperties ? Object.keys(itemProperties).sort() : [];
448
+ if (actualFields.length !== expectedFields.length
449
+ || actualFields.some((field, index) => field !== [...expectedFields].sort()[index])) {
450
+ throw new Error(`${name} must expose only its reviewed summary fields`);
451
+ }
452
+ }
453
+ for (const [name, maximum] of Object.entries(OPENAI_REVIEW_PAGINATION_LIMITS)) {
454
+ const tool = byName.get(name);
455
+ const inputSchema = tool?.inputSchema;
456
+ const inputProperties = inputSchema !== null
457
+ && !Array.isArray(inputSchema)
458
+ && typeof inputSchema === "object"
459
+ && inputSchema.properties !== null
460
+ && !Array.isArray(inputSchema.properties)
461
+ && typeof inputSchema.properties === "object"
462
+ ? inputSchema.properties
463
+ : undefined;
464
+ const limit = inputProperties?.limit;
465
+ const offset = inputProperties?.offset;
466
+ if (limit === null
467
+ || Array.isArray(limit)
468
+ || typeof limit !== "object"
469
+ || limit.maximum !== maximum
470
+ || offset === null
471
+ || Array.isArray(offset)
472
+ || typeof offset !== "object"
473
+ || offset.maximum !== OPENAI_REVIEW_OFFSET_MAX) {
474
+ throw new Error(`${name} must cap reviewed pagination at ${maximum} rows and offset ${OPENAI_REVIEW_OFFSET_MAX}`);
475
+ }
476
+ const outputSchema = tool?.outputSchema;
477
+ const outputProperties = outputSchema !== null
478
+ && !Array.isArray(outputSchema)
479
+ && typeof outputSchema === "object"
480
+ && outputSchema.properties !== null
481
+ && !Array.isArray(outputSchema.properties)
482
+ && typeof outputSchema.properties === "object"
483
+ ? outputSchema.properties
484
+ : undefined;
485
+ const data = outputProperties?.data;
486
+ if (data === null
487
+ || Array.isArray(data)
488
+ || typeof data !== "object"
489
+ || data.maxItems !== maximum) {
490
+ throw new Error(`${name} must cap reviewed structured output at ${maximum} rows`);
491
+ }
492
+ }
493
+ for (const [name, limits] of Object.entries(OPENAI_REVIEW_TEXT_INPUT_LIMITS)) {
494
+ const inputSchema = byName.get(name)?.inputSchema;
495
+ const properties = inputSchema !== null
496
+ && !Array.isArray(inputSchema)
497
+ && typeof inputSchema === "object"
498
+ && inputSchema.properties !== null
499
+ && !Array.isArray(inputSchema.properties)
500
+ && typeof inputSchema.properties === "object"
501
+ ? inputSchema.properties
502
+ : undefined;
503
+ for (const [field, maximum] of Object.entries(limits)) {
504
+ const fieldSchema = properties?.[field];
505
+ if (fieldSchema === null
506
+ || Array.isArray(fieldSchema)
507
+ || typeof fieldSchema !== "object"
508
+ || fieldSchema.maxLength !== maximum) {
509
+ throw new Error(`${name}.${field} must be capped at ${maximum} characters`);
510
+ }
511
+ }
512
+ }
513
+ for (const [name, paths] of Object.entries(OPENAI_REVIEW_FREE_TEXT_WARNING_PATHS)) {
514
+ for (const path of paths) {
515
+ const fieldSchema = reviewedInputFieldSchema(byName.get(name), path);
516
+ if (!fieldSchema
517
+ || typeof fieldSchema.description !== "string"
518
+ || !fieldSchema.description.includes(OPENAI_REVIEW_FREE_TEXT_WARNING)) {
519
+ throw new Error(`${name}.${path} must warn against sensitive free-text input`);
520
+ }
521
+ }
522
+ }
523
+ for (const name of ["create_invoice", "create_quote"]) {
524
+ const items = reviewedInputFieldSchema(byName.get(name), "items");
525
+ if (!items
526
+ || items.minItems !== 1
527
+ || items.maxItems !== OPENAI_REVIEW_DOCUMENT_LINE_ITEM_MAX) {
528
+ throw new Error(`${name}.items must contain 1-${OPENAI_REVIEW_DOCUMENT_LINE_ITEM_MAX} reviewed line items`);
529
+ }
530
+ }
531
+ const businessContextOutput = byName.get("get_business_context")?.outputSchema;
532
+ const businessContextProperties = businessContextOutput !== null
533
+ && !Array.isArray(businessContextOutput)
534
+ && typeof businessContextOutput === "object"
535
+ && businessContextOutput.properties !== null
536
+ && !Array.isArray(businessContextOutput.properties)
537
+ && typeof businessContextOutput.properties === "object"
538
+ ? businessContextOutput.properties
539
+ : undefined;
540
+ const topClients = businessContextProperties?.topClients;
541
+ if (topClients === null
542
+ || Array.isArray(topClients)
543
+ || typeof topClients !== "object"
544
+ || topClients.maxItems !== OPENAI_REVIEW_BUSINESS_CONTEXT_TOP_CLIENTS_MAX) {
545
+ throw new Error(`get_business_context.topClients must cap reviewed output at ${OPENAI_REVIEW_BUSINESS_CONTEXT_TOP_CLIENTS_MAX}`);
546
+ }
547
+ for (const name of ["get_invoice", "create_invoice", "get_quote", "create_quote"]) {
548
+ const output = byName.get(name)?.outputSchema;
549
+ const outputProperties = output !== null
550
+ && !Array.isArray(output)
551
+ && typeof output === "object"
552
+ && output.properties !== null
553
+ && !Array.isArray(output.properties)
554
+ && typeof output.properties === "object"
555
+ ? output.properties
556
+ : undefined;
557
+ const items = outputProperties?.items;
558
+ const lineItem = items !== null
559
+ && !Array.isArray(items)
560
+ && typeof items === "object"
561
+ && items.items !== null
562
+ && !Array.isArray(items.items)
563
+ && typeof items.items === "object"
564
+ ? items.items
565
+ : undefined;
566
+ const lineItemProperties = lineItem?.properties !== null
567
+ && !Array.isArray(lineItem?.properties)
568
+ && typeof lineItem?.properties === "object"
569
+ ? lineItem.properties
570
+ : undefined;
571
+ if (!lineItemProperties
572
+ || "id" in lineItemProperties
573
+ || items === null
574
+ || Array.isArray(items)
575
+ || typeof items !== "object"
576
+ || items.maxItems !== OPENAI_REVIEW_DOCUMENT_LINE_ITEM_MAX) {
577
+ throw new Error(`${name} must omit unusable reviewed line-item ids and cap output at ${OPENAI_REVIEW_DOCUMENT_LINE_ITEM_MAX} items`);
578
+ }
579
+ }
580
+ for (const name of ["list_client_activities", "log_client_activity"]) {
581
+ const output = byName.get(name)?.outputSchema;
582
+ const outputProperties = output !== null
583
+ && !Array.isArray(output)
584
+ && typeof output === "object"
585
+ && output.properties !== null
586
+ && !Array.isArray(output.properties)
587
+ && typeof output.properties === "object"
588
+ ? output.properties
589
+ : undefined;
590
+ const record = name === "list_client_activities"
591
+ ? (() => {
592
+ const data = outputProperties?.data;
593
+ return data !== null
594
+ && !Array.isArray(data)
595
+ && typeof data === "object"
596
+ && data.items !== null
597
+ && !Array.isArray(data.items)
598
+ && typeof data.items === "object"
599
+ ? data.items
600
+ : undefined;
601
+ })()
602
+ : output;
603
+ const recordProperties = record !== null
604
+ && !Array.isArray(record)
605
+ && typeof record === "object"
606
+ && record.properties !== null
607
+ && !Array.isArray(record.properties)
608
+ && typeof record.properties === "object"
609
+ ? record.properties
610
+ : undefined;
611
+ if (!recordProperties || "id" in recordProperties || "createdBy" in recordProperties) {
612
+ throw new Error(`${name} must omit unnecessary reviewed activity identifiers and creator markers`);
613
+ }
614
+ }
615
+ for (const name of OPENAI_WORKSPACE_WEBHOOK_EVENT_TOOLS) {
616
+ const annotations = byName.get(name)?.annotations;
617
+ if (annotations?.destructiveHint !== true ||
618
+ annotations?.idempotentHint !== false ||
619
+ annotations?.openWorldHint !== true ||
620
+ annotations?.readOnlyHint !== false) {
621
+ throw new Error(`${name} may emit an irreversible workspace webhook and must be destructive, non-idempotent, open-world, and mutating`);
622
+ }
623
+ }
624
+ for (const name of OPENAI_REVIEW_CONFIRM_REQUIRED_TOOLS) {
625
+ const inputSchema = byName.get(name)?.inputSchema;
626
+ if (inputSchema === null ||
627
+ Array.isArray(inputSchema) ||
628
+ typeof inputSchema !== "object") {
629
+ throw new Error(`${name} must declare an input schema`);
630
+ }
631
+ const properties = inputSchema.properties;
632
+ const required = inputSchema.required;
633
+ const confirmSchema = properties &&
634
+ !Array.isArray(properties) &&
635
+ typeof properties === "object"
636
+ ? properties.confirm
637
+ : undefined;
638
+ if (properties === null ||
639
+ Array.isArray(properties) ||
640
+ typeof properties !== "object" ||
641
+ !("confirm" in properties) ||
642
+ !Array.isArray(required) ||
643
+ !required.includes("confirm") ||
644
+ confirmSchema === null ||
645
+ Array.isArray(confirmSchema) ||
646
+ typeof confirmSchema !== "object" ||
647
+ confirmSchema.const !== true) {
648
+ throw new Error(`${name} must expose confirm as a required literal-true reviewed input`);
649
+ }
650
+ }
651
+ for (const name of ["delete_quote"]) {
652
+ const tool = byName.get(name);
653
+ if (typeof tool?.description !== "string" || /permanently delete/i.test(tool.description)) {
654
+ throw new Error(`${name} must describe draft deletion versus non-draft cancellation truthfully`);
655
+ }
656
+ const outputSchema = jsonObject(tool.outputSchema);
657
+ const outputProperties = jsonObject(outputSchema?.properties);
658
+ const outputRequired = outputSchema?.required;
659
+ const success = jsonObject(outputProperties?.success);
660
+ const result = jsonObject(outputProperties?.result);
661
+ const branches = result?.anyOf;
662
+ const branchObjects = Array.isArray(branches)
663
+ ? branches.map((branch) => jsonObject(branch))
664
+ : [];
665
+ const branchByOutcome = new Map();
666
+ for (const branch of branchObjects) {
667
+ const properties = jsonObject(branch?.properties);
668
+ const outcome = jsonObject(properties?.outcome)?.const;
669
+ if (branch && typeof outcome === "string")
670
+ branchByOutcome.set(outcome, branch);
671
+ }
672
+ const deleted = branchByOutcome.get("deleted");
673
+ const deletedProperties = jsonObject(deleted?.properties);
674
+ const cancelled = branchByOutcome.get("cancelled");
675
+ const cancelledProperties = jsonObject(cancelled?.properties);
676
+ const deletedRequired = deleted?.required;
677
+ const cancelledRequired = cancelled?.required;
678
+ const cancelledStatus = jsonObject(cancelledProperties?.status);
679
+ const cancelledEffects = jsonObject(cancelledProperties?.externalEffects);
680
+ const cancelledEffectItems = jsonObject(cancelledEffects?.items);
681
+ if (!outputSchema ||
682
+ outputSchema.type !== "object" ||
683
+ outputSchema.additionalProperties !== false ||
684
+ !outputProperties ||
685
+ Object.keys(outputProperties).length !== 3 ||
686
+ !Array.isArray(outputRequired) ||
687
+ !["success", "id", "result"].every((field) => outputRequired.includes(field)) ||
688
+ outputRequired.length !== 3 ||
689
+ success?.const !== true ||
690
+ !result ||
691
+ branchObjects.length !== 2 ||
692
+ branchObjects.some((branch) => !branch) ||
693
+ branchByOutcome.size !== 2 ||
694
+ !deleted ||
695
+ deleted.type !== "object" ||
696
+ deleted.additionalProperties !== false ||
697
+ !deletedProperties ||
698
+ Object.keys(deletedProperties).length !== 1 ||
699
+ !Array.isArray(deletedRequired) ||
700
+ deletedRequired.length !== 1 ||
701
+ deletedRequired[0] !== "outcome" ||
702
+ !cancelled ||
703
+ cancelled.type !== "object" ||
704
+ cancelled.additionalProperties !== false ||
705
+ !cancelledProperties ||
706
+ Object.keys(cancelledProperties).some((field) => !["outcome", "status", "previousStatus", "externalEffects"].includes(field)) ||
707
+ !Array.isArray(cancelledRequired) ||
708
+ cancelledRequired.length !== 3 ||
709
+ !["outcome", "status", "externalEffects"].every((field) => cancelledRequired.includes(field)) ||
710
+ cancelledStatus?.const !== "cancelled" ||
711
+ cancelledEffects?.type !== "array" ||
712
+ cancelledEffectItems?.type !== "string") {
713
+ throw new Error(`${name} output schema must expose exactly two closed result branches: webhook-free deleted or status=cancelled with external effects`);
714
+ }
715
+ }
716
+ for (const [name, numberField] of [
717
+ ["create_invoice", "invoiceNumber"],
718
+ ["create_quote", "quoteNumber"],
719
+ ]) {
720
+ const outputSchema = byName.get(name)?.outputSchema;
721
+ const properties = outputSchema !== null
722
+ && !Array.isArray(outputSchema)
723
+ && typeof outputSchema === "object"
724
+ && outputSchema.properties !== null
725
+ && !Array.isArray(outputSchema.properties)
726
+ && typeof outputSchema.properties === "object"
727
+ ? outputSchema.properties
728
+ : undefined;
729
+ const required = outputSchema !== null
730
+ && !Array.isArray(outputSchema)
731
+ && typeof outputSchema === "object"
732
+ ? outputSchema.required
733
+ : undefined;
734
+ const status = properties?.status;
735
+ if (!properties
736
+ || !Array.isArray(required)
737
+ || !["id", numberField, "status", "externalEffects"].every((field) => required.includes(field))
738
+ || status === null
739
+ || Array.isArray(status)
740
+ || typeof status !== "object"
741
+ || status.const !== "draft") {
742
+ throw new Error(`${name} output schema must require its reserved number and literal draft status`);
743
+ }
744
+ }
745
+ const createOutputContracts = {
746
+ create_client: { required: ["id", "name", "externalEffects"], forbidden: ["stage"] },
747
+ create_expense: {
748
+ required: ["id", "description", "amount", "date", "taxDeductible", "externalEffects"],
749
+ forbidden: ["paidDate"],
750
+ },
751
+ create_invoice: {
752
+ required: ["id", "invoiceNumber", "clientId", "clientName", "items", "issueDate", "dueDate", "status", "externalEffects"],
753
+ forbidden: ["total"],
754
+ },
755
+ create_product: {
756
+ required: ["id", "name", "unitPrice", "externalEffects"],
757
+ forbidden: ["isActive"],
758
+ },
759
+ create_quote: {
760
+ required: ["id", "quoteNumber", "clientId", "clientName", "items", "issueDate", "status", "externalEffects"],
761
+ forbidden: ["total"],
762
+ },
763
+ };
764
+ for (const [name, contract] of Object.entries(createOutputContracts)) {
765
+ const output = jsonObject(byName.get(name)?.outputSchema);
766
+ const properties = jsonObject(output?.properties);
767
+ const required = output?.required;
768
+ if (!properties
769
+ || !Array.isArray(required)
770
+ || !contract.required.every((field) => required.includes(field))
771
+ || contract.forbidden.some((field) => field in properties)) {
772
+ throw new Error(`${name} output schema must require guaranteed create fields and omit impossible create-only fields`);
773
+ }
774
+ }
775
+ const createInvoice = byName.get("create_invoice");
776
+ const createInvoiceInput = createInvoice?.inputSchema;
777
+ const createInvoiceProperties = createInvoiceInput &&
778
+ !Array.isArray(createInvoiceInput) &&
779
+ typeof createInvoiceInput === "object" &&
780
+ createInvoiceInput.properties &&
781
+ !Array.isArray(createInvoiceInput.properties) &&
782
+ typeof createInvoiceInput.properties === "object"
783
+ ? createInvoiceInput.properties
784
+ : undefined;
785
+ if (!createInvoiceProperties) {
786
+ throw new Error("create_invoice must declare a reviewed input schema");
787
+ }
788
+ const forbiddenInvoiceInputs = [
789
+ "clientId",
790
+ "clientTaxId",
791
+ "clientAddress",
792
+ "clientLocation",
793
+ "status",
794
+ "irpfRate",
795
+ "equivalenceSurchargeRate",
796
+ "prepayment",
797
+ "seriesId",
798
+ "documentNumber",
799
+ "poNumber",
800
+ "operationType",
801
+ ].filter((field) => field in createInvoiceProperties);
802
+ if (forbiddenInvoiceInputs.length > 0) {
803
+ throw new Error(`create_invoice must remain draft-only and minimal; forbidden inputs: ${forbiddenInvoiceInputs.join(", ")}`);
804
+ }
805
+ if (typeof createInvoice?.description !== "string" ||
806
+ !/draft/i.test(createInvoice.description) ||
807
+ !/(reserves?|assigns?).*document number/i.test(createInvoice.description) ||
808
+ !/(does not|cannot).*(issue|email|submit|file)/i.test(createInvoice.description) ||
809
+ !/(create|link).*client/i.test(createInvoice.description)) {
810
+ throw new Error("create_invoice must disclose draft-only behavior, numbering, and possible client creation");
811
+ }
812
+ const createQuote = byName.get("create_quote");
813
+ const createQuoteInput = createQuote?.inputSchema;
814
+ const createQuoteProperties = createQuoteInput &&
815
+ !Array.isArray(createQuoteInput) &&
816
+ typeof createQuoteInput === "object" &&
817
+ createQuoteInput.properties &&
818
+ !Array.isArray(createQuoteInput.properties) &&
819
+ typeof createQuoteInput.properties === "object"
820
+ ? createQuoteInput.properties
821
+ : undefined;
822
+ if (!createQuoteProperties || "status" in createQuoteProperties) {
823
+ throw new Error("create_quote must remain draft-only and hide lifecycle status");
824
+ }
825
+ if (typeof createQuote?.description !== "string" ||
826
+ !/draft/i.test(createQuote.description) ||
827
+ !/(reserves?|assigns?).*document number/i.test(createQuote.description) ||
828
+ !/advances?.*numbering counter/i.test(createQuote.description) ||
829
+ !/(create|link).*client/i.test(createQuote.description) ||
830
+ !/(does not|cannot).*(send|accept)/i.test(createQuote.description)) {
831
+ throw new Error("create_quote must disclose draft-only behavior, numbering, and possible client creation");
832
+ }
833
+ for (const name of ["create_expense", "update_expense"]) {
834
+ const description = byName.get(name)?.description;
835
+ if (typeof description !== "string" ||
836
+ !/tax-deductible (?:classification|choice)[^.!?]{0,120}affect(?:s)? Frihet's (?:internal )?accounting/i.test(description) ||
837
+ !/(?:does not file anything|files nothing)/i.test(description)) {
838
+ throw new Error(`${name} must disclose the internal tax-classification effect without implying filing`);
839
+ }
840
+ }
841
+ const createExpenseInput = byName.get("create_expense")?.inputSchema;
842
+ const createExpenseProperties = createExpenseInput &&
843
+ !Array.isArray(createExpenseInput) &&
844
+ typeof createExpenseInput === "object" &&
845
+ createExpenseInput.properties &&
846
+ !Array.isArray(createExpenseInput.properties) &&
847
+ typeof createExpenseInput.properties === "object"
848
+ ? createExpenseInput.properties
849
+ : undefined;
850
+ const createExpenseRequired = createExpenseInput &&
851
+ !Array.isArray(createExpenseInput) &&
852
+ typeof createExpenseInput === "object" &&
853
+ Array.isArray(createExpenseInput.required)
854
+ ? createExpenseInput.required
855
+ : [];
856
+ if (!createExpenseProperties ||
857
+ "paidDate" in createExpenseProperties ||
858
+ !createExpenseRequired.includes("date") ||
859
+ !createExpenseRequired.includes("taxDeductible")) {
860
+ throw new Error("create_expense must require an explicit date and deductible choice without marking the expense paid");
861
+ }
862
+ if (typeof byName.get("create_expense")?.description !== "string" ||
863
+ !/explicit (?:expense )?date/i.test(byName.get("create_expense").description) ||
864
+ !/tax-deductible choice/i.test(byName.get("create_expense").description)) {
865
+ throw new Error("create_expense must disclose its explicit accounting choices");
866
+ }
867
+ const createExpenseConfirm = reviewedInputFieldSchema(byName.get("create_expense"), "confirm");
868
+ if (!createExpenseConfirm
869
+ || typeof createExpenseConfirm.description !== "string"
870
+ || !/separate step[^.!?]*may persist[^.!?]*expense write fails/i.test(createExpenseConfirm.description)) {
871
+ throw new Error("create_expense confirmation must disclose the separate vendor-write residual");
872
+ }
873
+ const updateExpenseInput = byName.get("update_expense")?.inputSchema;
874
+ const updateExpenseProperties = updateExpenseInput &&
875
+ !Array.isArray(updateExpenseInput) &&
876
+ typeof updateExpenseInput === "object" &&
877
+ updateExpenseInput.properties &&
878
+ !Array.isArray(updateExpenseInput.properties) &&
879
+ typeof updateExpenseInput.properties === "object"
880
+ ? updateExpenseInput.properties
881
+ : undefined;
882
+ if (!updateExpenseProperties || "vendor" in updateExpenseProperties) {
883
+ throw new Error("update_expense must not expose a supplier-name change without vendor identity re-resolution");
884
+ }
885
+ if (!("date" in updateExpenseProperties) ||
886
+ !("taxDeductible" in updateExpenseProperties)) {
887
+ throw new Error("update_expense must retain its reviewed date and deductible controls");
888
+ }
227
889
  if (actual.prompts.length !== 0 || actual.resources.length !== 0) {
228
890
  throw new Error(`OpenAI review surface must expose 0 prompts and 0 resources; got ${actual.prompts.length} prompts and ${actual.resources.length} resources`);
229
891
  }
@@ -233,7 +895,19 @@ export function assertOpenAIReviewContract(actual, expected) {
233
895
  }
234
896
  const expectedSensitivePaths = [...schemaSensitivePaths(expected.tools)];
235
897
  if (expectedSensitivePaths.length > 0) {
236
- throw new Error(`Frozen OpenAI review snapshot contains sensitive schema fields: ${expectedSensitivePaths.join(", ")}`);
898
+ throw new Error(`Reviewed OpenAI snapshot contains sensitive schema fields: ${expectedSensitivePaths.join(", ")}`);
899
+ }
900
+ const reviewedText = JSON.stringify(actual.tools);
901
+ const forbiddenText = [
902
+ /\btaxId\b/u,
903
+ /\bModelos?\s+\d+/iu,
904
+ /quarterly\s+tax/iu,
905
+ /gestor[ií]a/iu,
906
+ /recurring\s+invoices?/iu,
907
+ /webhook\s+(?:configuration|administration)/iu,
908
+ ].find((pattern) => pattern.test(reviewedText));
909
+ if (forbiddenText) {
910
+ throw new Error(`Reviewed descriptor contains excluded-surface prose: ${forbiddenText.source}`);
237
911
  }
238
912
  const canonicalExpected = canonicalizeOpenAIReviewContract(expected);
239
913
  const canonicalActual = canonicalizeOpenAIReviewContract(actual);