@frihet/mcp-server 1.16.5 → 1.16.6

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 CHANGED
@@ -47,7 +47,7 @@
47
47
  | **ChatGPT Apps** | Coming soon | [chatgpt.com](https://chatgpt.com) |
48
48
  | **Anthropic Claude Directory** | Coming soon | [claude.ai/settings/connectors](https://claude.ai/settings/connectors) |
49
49
 
50
- > **Tool count:** the package (1.16.5) ships all 157 tools, same as the remote endpoint (`mcp.frihet.io`).
50
+ > **Tool count:** the package (1.16.6) ships all 157 tools, same as the remote endpoint (`mcp.frihet.io`).
51
51
 
52
52
  ---
53
53
 
package/dist/client.js CHANGED
@@ -24,6 +24,34 @@ const REQUEST_TIMEOUT_MS = 30000;
24
24
  * of the time — see src/__tests__/idempotency-key-contract.test.ts.
25
25
  */
26
26
  const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
27
+ /**
28
+ * Origin marker sent on every request so the backend can tell an MCP-driven
29
+ * create apart from a direct-API one.
30
+ *
31
+ * The backend classifier (`detectApiInvoiceSource`, Frihet-ERP
32
+ * functions/src/publicApi.ts) matches `mcp` / `frihet-mcp` / `@frihet/mcp`
33
+ * across three headers: `x-frihet-source`, `x-frihet-client` and `user-agent`.
34
+ * We send TWO markers because they survive different network paths:
35
+ *
36
+ * - `X-Frihet-Source: mcp` is the explicit, documented marker. It reaches the
37
+ * backend when the client talks to the Cloud Function directly (custom
38
+ * `baseUrl`, self-hosted proxy).
39
+ * - `User-Agent` is what actually reaches the backend on the DEFAULT baseUrl:
40
+ * `api.frihet.io` is fronted by workers/api-proxy/worker.js, whose
41
+ * `ALLOWED_REQUEST_HEADERS` allowlist forwards `user-agent` but drops
42
+ * `x-frihet-source` (verified live: /agents.json answers 200 at
43
+ * api.frihet.io and 401 at the Cloud Function, so the Worker is in path).
44
+ * Without the UA the source header is a phantom — set, then stripped at the
45
+ * edge, and every MCP invoice still lands as `source: 'api'`.
46
+ *
47
+ * Deliberately carries no version: the version lives in package.json only
48
+ * (see src/index.ts PKG_VERSION) and a second hardcoded copy is exactly the
49
+ * drift `scripts/audit-mcp-refs.mjs` exists to catch.
50
+ *
51
+ * Pinned on the wire by src/__tests__/source-header-contract.test.ts.
52
+ */
53
+ const SOURCE_MARKER = "mcp";
54
+ const SOURCE_USER_AGENT = "frihet-mcp-server";
27
55
  /**
28
56
  * Fresh idempotency key, always a syntactically valid UUID v4.
29
57
  *
@@ -102,6 +130,8 @@ export class FrihetClient {
102
130
  "X-API-Key": this.apiKey,
103
131
  "Content-Type": "application/json",
104
132
  Accept: "application/json",
133
+ "X-Frihet-Source": SOURCE_MARKER,
134
+ "User-Agent": SOURCE_USER_AGENT,
105
135
  };
106
136
  if (resolvedIdempotencyKey) {
107
137
  headers["Idempotency-Key"] = resolvedIdempotencyKey;
@@ -663,10 +693,10 @@ export class FrihetClient {
663
693
  // ---------------------------------------------------------------- Intelligence
664
694
  // ----------------------------------------------------------------
665
695
  async getBusinessContext() {
666
- return this.request("GET", "/context");
696
+ return this.requestUnwrapped("GET", "/context");
667
697
  }
668
698
  async getMonthlySummary(month) {
669
- return this.request("GET", "/monthly", undefined, {
699
+ return this.requestUnwrapped("GET", "/monthly", undefined, {
670
700
  month,
671
701
  });
672
702
  }
@@ -257,23 +257,49 @@ export class DemoFrihetClient {
257
257
  // ---------------------------------------------------------------- Intelligence
258
258
  async getBusinessContext() {
259
259
  return {
260
- businessName: "Demo Studio SL",
261
- fiscalRegime: "autonomo",
262
- currency: "EUR",
263
- country: "ES",
264
- metrics: { openInvoices: 4, overdueInvoices: 1, totalClients: demoClients.length, totalProducts: demoProducts.length },
260
+ business: {
261
+ name: "Demo Studio SL",
262
+ fiscalZone: "IVA",
263
+ currency: "EUR",
264
+ language: "es",
265
+ country: "ES",
266
+ },
267
+ defaults: { taxRate: 21, irpfRate: 15, dueDays: 30, currency: "EUR" },
268
+ plan: {
269
+ name: "free",
270
+ invoices: { used: 5, limit: 999 },
271
+ expenses: { used: demoExpenses.length, limit: 5 },
272
+ aiMessages: { used: 2, limit: 30 },
273
+ },
274
+ series: [{ id: "default", prefix: "F", current: 5, year: 2026 }],
275
+ recentActivity: {
276
+ lastInvoice: { number: "F-2026-005", date: "2026-07-18", client: "Acme Studio" },
277
+ lastExpense: { date: "2026-07-17", vendor: "Demo Supplies", amount: 405.6 },
278
+ overdueCount: 1,
279
+ overdueAmount: 640,
280
+ unpaidCount: 2,
281
+ },
282
+ topClients: [{ name: "Acme Studio", totalRevenue: 2200, invoiceCount: 2 }],
283
+ currentMonth: {
284
+ revenue: 3960.4,
285
+ expenses: 405.6,
286
+ profit: 3554.8,
287
+ invoiceCount: 5,
288
+ expenseCount: demoExpenses.length,
289
+ },
265
290
  ...READ_STAMP,
266
291
  };
267
292
  }
268
293
  async getMonthlySummary(month) {
269
294
  return {
270
- month: month ?? "2026-07",
271
- revenue: 3960.4,
272
- expenses: 405.6,
273
- net: 3554.8,
274
- invoiceCount: 5,
275
- expenseCount: demoExpenses.length,
276
- currency: "EUR",
295
+ period: month ?? "2026-07",
296
+ revenue: { total: 3960.4, taxBase: 3273.06, tax: 687.34, irpf: 0 },
297
+ expenses: { total: 405.6, deductible: 405.6, tax: 70.4 },
298
+ profit: { gross: 3554.8, net: 2937.86 },
299
+ invoices: { created: 5, sent: 1, paid: 3, overdue: 1 },
300
+ topClients: [{ name: "Acme Studio", totalRevenue: 2200, invoiceCount: 2 }],
301
+ byCategory: { supplies: 405.6 },
302
+ taxLiability: { vatPayable: 616.94, irpfRetained: 0, estimatedModel303: 616.94 },
277
303
  ...READ_STAMP,
278
304
  };
279
305
  }
@@ -63,6 +63,14 @@ export declare const OPENAI_ALLOWED_TOOL_COUNT: number;
63
63
  * correct while the live tools/list still materialises 53 + 3 = 56 tools.
64
64
  */
65
65
  export declare const OPENAI_REVIEWED_TOOL_ALLOWLIST: ReadonlySet<string>;
66
+ /**
67
+ * Apply the exact profile composition used by the ChatGPT review Worker.
68
+ *
69
+ * Keep this helper as the single source for the security-sensitive ordering:
70
+ * grouped exposure first (with the frozen allow-list), OpenAI profile second.
71
+ * Tool registration happens afterwards through registerAllTools().
72
+ */
73
+ export declare function applyOpenAIReviewProfiles(server: any): void;
66
74
  /** Number of resources excluded in OpenAI mode (for logging). */
67
75
  export declare const OPENAI_EXCLUDED_RESOURCE_COUNT: number;
68
76
  //# sourceMappingURL=openai-profile.d.ts.map
@@ -20,9 +20,10 @@
20
20
  *
21
21
  * @see https://developers.openai.com/apps-sdk/app-submission-guidelines
22
22
  */
23
- import { z } from "zod";
23
+ import { z } from "zod/v4";
24
24
  import { MCP_RESOURCE_COUNT } from "./resources/register-all.js";
25
25
  import { SENSITIVE_FIELD_NAMES, deepRedact, redactText } from "./redaction.js";
26
+ import { applyToolExposureProfile } from "./tool-exposure.js";
26
27
  const PROFILE = {
27
28
  // -- OpenAI-reviewed core surface ----------------------------------------
28
29
  //
@@ -164,6 +165,17 @@ const PROFILE = {
164
165
  // Government IDs (NIF/CIF/VAT), auth credentials, and unsolicited
165
166
  // email address collection removed from input schemas.
166
167
  stripInputFields: {
168
+ // Projection is useful to direct MCP clients, but unnecessary in the
169
+ // reviewed ChatGPT surface. OpenAI's scanner treats a comma-delimited
170
+ // free-form string as ambiguous (array vs JSON vs CSV), so omit it and
171
+ // return the full, redacted record shape instead.
172
+ list_invoices: ["fields"],
173
+ search_invoices: ["fields"],
174
+ list_expenses: ["fields"],
175
+ list_clients: ["fields"],
176
+ list_products: ["fields"],
177
+ list_quotes: ["fields"],
178
+ list_vendors: ["fields"],
167
179
  create_client: ["taxId"], // NIF/CIF/VAT — government-issued identifier
168
180
  update_client: ["taxId"],
169
181
  create_vendor: ["taxId"],
@@ -202,11 +214,7 @@ const PROFILE = {
202
214
  * sensitive field anywhere is returned unchanged (identity).
203
215
  */
204
216
  function stripSensitiveOutputSchema(schema, fields) {
205
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
206
- const def = schema?._def;
207
- const typeName = def?.typeName;
208
- if (typeName === "ZodObject") {
209
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
217
+ if (schema instanceof z.ZodObject) {
210
218
  const shape = schema.shape;
211
219
  const newShape = {};
212
220
  let changed = false;
@@ -222,28 +230,42 @@ function stripSensitiveOutputSchema(schema, fields) {
222
230
  }
223
231
  if (!changed)
224
232
  return schema;
225
- let rebuilt = z.object(newShape);
226
- if (typeof def.description === "string")
227
- rebuilt = rebuilt.describe(def.description);
233
+ const base = z.object(newShape);
234
+ // Zod v4 stores passthrough/strict behavior in `catchall`. Preserve it so
235
+ // redacting one declared field never changes validation of unrelated API
236
+ // fields. The previous implementation inspected Zod v3's `_def.typeName`,
237
+ // which is absent in v4 and silently returned the original schema.
238
+ const catchall = schema._def.catchall;
239
+ let rebuilt;
240
+ if (catchall instanceof z.ZodUnknown)
241
+ rebuilt = base.passthrough();
242
+ else if (catchall instanceof z.ZodNever)
243
+ rebuilt = base.strict();
244
+ else if (catchall)
245
+ rebuilt = base.catchall(catchall);
246
+ else
247
+ rebuilt = base;
248
+ if (typeof schema.description === "string")
249
+ rebuilt = rebuilt.describe(schema.description);
228
250
  return rebuilt;
229
251
  }
230
- if (typeName === "ZodArray") {
231
- const inner = def.type;
252
+ if (schema instanceof z.ZodArray) {
253
+ const inner = schema.element;
232
254
  const stripped = stripSensitiveOutputSchema(inner, fields);
233
255
  if (stripped === inner)
234
256
  return schema;
235
257
  let rebuilt = z.array(stripped);
236
- if (typeof def.description === "string")
237
- rebuilt = rebuilt.describe(def.description);
258
+ if (typeof schema.description === "string")
259
+ rebuilt = rebuilt.describe(schema.description);
238
260
  return rebuilt;
239
261
  }
240
- if (typeName === "ZodOptional") {
241
- const inner = def.innerType;
262
+ if (schema instanceof z.ZodOptional) {
263
+ const inner = schema.unwrap();
242
264
  const stripped = stripSensitiveOutputSchema(inner, fields);
243
265
  return stripped === inner ? schema : z.optional(stripped);
244
266
  }
245
- if (typeName === "ZodNullable") {
246
- const inner = def.innerType;
267
+ if (schema instanceof z.ZodNullable) {
268
+ const inner = schema.unwrap();
247
269
  const stripped = stripSensitiveOutputSchema(inner, fields);
248
270
  return stripped === inner ? schema : z.nullable(stripped);
249
271
  }
@@ -432,6 +454,20 @@ export const OPENAI_ALLOWED_TOOL_COUNT = PROFILE.includeTools.size;
432
454
  * correct while the live tools/list still materialises 53 + 3 = 56 tools.
433
455
  */
434
456
  export const OPENAI_REVIEWED_TOOL_ALLOWLIST = PROFILE.includeTools;
457
+ /**
458
+ * Apply the exact profile composition used by the ChatGPT review Worker.
459
+ *
460
+ * Keep this helper as the single source for the security-sensitive ordering:
461
+ * grouped exposure first (with the frozen allow-list), OpenAI profile second.
462
+ * Tool registration happens afterwards through registerAllTools().
463
+ */
464
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
465
+ export function applyOpenAIReviewProfiles(server) {
466
+ applyToolExposureProfile(server, {
467
+ allowlist: OPENAI_REVIEWED_TOOL_ALLOWLIST,
468
+ });
469
+ applyOpenAIProfile(server);
470
+ }
435
471
  /** Number of resources excluded in OpenAI mode (for logging). */
436
472
  export const OPENAI_EXCLUDED_RESOURCE_COUNT = PROFILE.excludeResources
437
473
  ? MCP_RESOURCE_COUNT
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Fail-closed contract capture for the ChatGPT-reviewed MCP surface.
3
+ *
4
+ * This intentionally uses a real McpServer, real Client, in-memory MCP
5
+ * transport, and the production registration path. It therefore freezes what
6
+ * tools/list serializes, not an approximation of registration config objects.
7
+ */
8
+ export declare const OPENAI_REVIEW_CONTRACT_VERSION = 1;
9
+ export declare const OPENAI_REVIEW_BUSINESS_TOOL_COUNT = 53;
10
+ export declare const OPENAI_REVIEW_DISCOVERY_TOOLS: readonly ["describe_tool", "list_tool_groups", "search_tools"];
11
+ export declare const OPENAI_REVIEW_TOTAL_TOOL_COUNT = 56;
12
+ export type JsonValue = null | boolean | number | string | JsonValue[] | {
13
+ [key: string]: JsonValue;
14
+ };
15
+ export interface OpenAIReviewTool extends Record<string, JsonValue> {
16
+ name: string;
17
+ }
18
+ export interface OpenAIReviewContract {
19
+ contractVersion: number;
20
+ tools: OpenAIReviewTool[];
21
+ prompts: JsonValue[];
22
+ resources: JsonValue[];
23
+ oauth: Record<string, JsonValue>;
24
+ }
25
+ export interface OpenAIReviewMcpSurface {
26
+ tools: OpenAIReviewTool[];
27
+ prompts: JsonValue[];
28
+ resources: JsonValue[];
29
+ }
30
+ /** Capture the exact OpenAI grouped surface over a real tools/list request. */
31
+ export declare function captureOpenAIReviewMcpSurface(): Promise<OpenAIReviewMcpSurface>;
32
+ export declare function buildOpenAIReviewContract(surface: OpenAIReviewMcpSurface, oauth: Record<string, JsonValue>): OpenAIReviewContract;
33
+ /** Canonicalize object-key and tools/list order only; arrays remain semantic. */
34
+ export declare function canonicalizeOpenAIReviewContract(contract: OpenAIReviewContract): OpenAIReviewContract;
35
+ export declare function serializeOpenAIReviewContract(contract: OpenAIReviewContract): string;
36
+ /**
37
+ * Fail closed on any semantic drift from the reviewed descriptor snapshot.
38
+ */
39
+ export declare function assertOpenAIReviewContract(actual: OpenAIReviewContract, expected: OpenAIReviewContract): void;
40
+ //# sourceMappingURL=openai-review-contract.d.ts.map
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Fail-closed contract capture for the ChatGPT-reviewed MCP surface.
3
+ *
4
+ * This intentionally uses a real McpServer, real Client, in-memory MCP
5
+ * transport, and the production registration path. It therefore freezes what
6
+ * tools/list serializes, not an approximation of registration config objects.
7
+ */
8
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
9
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
10
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
+ import { applyOpenAIReviewProfiles, OPENAI_REVIEWED_TOOL_ALLOWLIST, } from "./openai-profile.js";
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",
22
+ ];
23
+ export const OPENAI_REVIEW_TOTAL_TOOL_COUNT = 56;
24
+ function makeRegistrationClient() {
25
+ return new Proxy({}, {
26
+ get: () => async () => ({ data: [], total: 0, limit: 0, offset: 0 }),
27
+ });
28
+ }
29
+ function isMethodNotFound(error) {
30
+ return (typeof error === "object" &&
31
+ error !== null &&
32
+ "code" in error &&
33
+ error.code === -32601);
34
+ }
35
+ async function listPromptsOrEmpty(client) {
36
+ const prompts = [];
37
+ let cursor;
38
+ try {
39
+ do {
40
+ const page = await client.listPrompts(cursor ? { cursor } : undefined);
41
+ prompts.push(...page.prompts);
42
+ cursor = page.nextCursor;
43
+ } while (cursor);
44
+ }
45
+ catch (error) {
46
+ if (isMethodNotFound(error))
47
+ return [];
48
+ throw error;
49
+ }
50
+ return prompts;
51
+ }
52
+ async function listResourcesOrEmpty(client) {
53
+ const resources = [];
54
+ let cursor;
55
+ try {
56
+ do {
57
+ const page = await client.listResources(cursor ? { cursor } : undefined);
58
+ resources.push(...page.resources);
59
+ cursor = page.nextCursor;
60
+ } while (cursor);
61
+ }
62
+ catch (error) {
63
+ if (isMethodNotFound(error))
64
+ return [];
65
+ throw error;
66
+ }
67
+ return resources;
68
+ }
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",
73
+ version: "1.0.0",
74
+ });
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: {} });
81
+ try {
82
+ await Promise.all([
83
+ server.connect(serverTransport),
84
+ client.connect(clientTransport),
85
+ ]);
86
+ const tools = [];
87
+ let cursor;
88
+ do {
89
+ const page = await client.listTools(cursor ? { cursor } : undefined);
90
+ tools.push(...page.tools);
91
+ cursor = page.nextCursor;
92
+ } while (cursor);
93
+ const [prompts, resources] = await Promise.all([
94
+ listPromptsOrEmpty(client),
95
+ listResourcesOrEmpty(client),
96
+ ]);
97
+ return { tools, prompts, resources };
98
+ }
99
+ finally {
100
+ await Promise.allSettled([client.close(), server.close()]);
101
+ }
102
+ }
103
+ export function buildOpenAIReviewContract(surface, oauth) {
104
+ return {
105
+ contractVersion: OPENAI_REVIEW_CONTRACT_VERSION,
106
+ tools: surface.tools,
107
+ prompts: surface.prompts,
108
+ resources: surface.resources,
109
+ oauth,
110
+ };
111
+ }
112
+ function canonicalizeValue(value) {
113
+ if (Array.isArray(value))
114
+ return value.map(canonicalizeValue);
115
+ if (value !== null && typeof value === "object") {
116
+ return Object.fromEntries(Object.entries(value)
117
+ .sort(([left], [right]) => left.localeCompare(right))
118
+ .map(([key, child]) => [key, canonicalizeValue(child)]));
119
+ }
120
+ return value;
121
+ }
122
+ /** Canonicalize object-key and tools/list order only; arrays remain semantic. */
123
+ export function canonicalizeOpenAIReviewContract(contract) {
124
+ const canonical = canonicalizeValue(contract);
125
+ canonical.tools.sort((left, right) => left.name.localeCompare(right.name));
126
+ return canonical;
127
+ }
128
+ export function serializeOpenAIReviewContract(contract) {
129
+ return `${JSON.stringify(canonicalizeOpenAIReviewContract(contract), null, 2)}\n`;
130
+ }
131
+ 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",
140
+ ]);
141
+ const paths = new Set();
142
+ const visit = (value, path) => {
143
+ if (value === undefined || value === null || typeof value !== "object")
144
+ return;
145
+ if (Array.isArray(value)) {
146
+ value.forEach((child, index) => visit(child, `${path}[${index}]`));
147
+ return;
148
+ }
149
+ for (const [key, child] of Object.entries(value)) {
150
+ const childPath = `${path}.${key}`;
151
+ if (sensitive.has(key.toLowerCase()) &&
152
+ !nonSensitiveBusinessPaths.has(childPath)) {
153
+ paths.add(childPath);
154
+ }
155
+ visit(child, childPath);
156
+ }
157
+ };
158
+ for (const tool of tools) {
159
+ visit(tool.inputSchema, `${tool.name}.inputSchema`);
160
+ visit(tool.outputSchema, `${tool.name}.outputSchema`);
161
+ }
162
+ return paths;
163
+ }
164
+ function firstDifference(expected, actual, path = "$") {
165
+ if (Object.is(expected, actual))
166
+ return undefined;
167
+ if (Array.isArray(expected) || Array.isArray(actual)) {
168
+ if (!Array.isArray(expected) || !Array.isArray(actual)) {
169
+ return { path, expected, actual };
170
+ }
171
+ if (expected.length !== actual.length)
172
+ return { path: `${path}.length`, expected: expected.length, actual: actual.length };
173
+ for (let index = 0; index < expected.length; index += 1) {
174
+ const difference = firstDifference(expected[index], actual[index], `${path}[${index}]`);
175
+ if (difference)
176
+ return difference;
177
+ }
178
+ return undefined;
179
+ }
180
+ if (expected !== null &&
181
+ actual !== null &&
182
+ typeof expected === "object" &&
183
+ typeof actual === "object") {
184
+ const keys = [...new Set([...Object.keys(expected), ...Object.keys(actual)])].sort();
185
+ for (const key of keys) {
186
+ const difference = firstDifference(expected[key], actual[key], `${path}.${key}`);
187
+ if (difference)
188
+ return difference;
189
+ }
190
+ return undefined;
191
+ }
192
+ return { path, expected, actual };
193
+ }
194
+ function preview(value) {
195
+ const serialized = JSON.stringify(value);
196
+ if (serialized === undefined)
197
+ return "<missing>";
198
+ return serialized.length > 180 ? `${serialized.slice(0, 177)}...` : serialized;
199
+ }
200
+ /**
201
+ * Fail closed on any semantic drift from the reviewed descriptor snapshot.
202
+ */
203
+ export function assertOpenAIReviewContract(actual, expected) {
204
+ if (actual.contractVersion !== OPENAI_REVIEW_CONTRACT_VERSION) {
205
+ throw new Error(`Unsupported OpenAI review contract version: ${actual.contractVersion}`);
206
+ }
207
+ if (actual.tools.length !== OPENAI_REVIEW_TOTAL_TOOL_COUNT) {
208
+ throw new Error(`OpenAI review surface must expose exactly ${OPENAI_REVIEW_TOTAL_TOOL_COUNT} tools; got ${actual.tools.length}`);
209
+ }
210
+ const names = actual.tools.map((tool) => tool.name);
211
+ if (new Set(names).size !== names.length) {
212
+ throw new Error("OpenAI review surface contains duplicate tool names");
213
+ }
214
+ const discovery = new Set(OPENAI_REVIEW_DISCOVERY_TOOLS);
215
+ const businessNames = names.filter((name) => !discovery.has(name));
216
+ if (businessNames.length !== OPENAI_REVIEW_BUSINESS_TOOL_COUNT) {
217
+ throw new Error(`OpenAI review surface must expose exactly ${OPENAI_REVIEW_BUSINESS_TOOL_COUNT} business tools; got ${businessNames.length}`);
218
+ }
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(", ")}`);
222
+ }
223
+ const leaks = businessNames.filter((name) => !OPENAI_REVIEWED_TOOL_ALLOWLIST.has(name));
224
+ if (leaks.length > 0) {
225
+ throw new Error(`Non-reviewed tools leaked into OpenAI surface: ${leaks.join(", ")}`);
226
+ }
227
+ if (actual.prompts.length !== 0 || actual.resources.length !== 0) {
228
+ throw new Error(`OpenAI review surface must expose 0 prompts and 0 resources; got ${actual.prompts.length} prompts and ${actual.resources.length} resources`);
229
+ }
230
+ const sensitivePaths = [...schemaSensitivePaths(actual.tools)];
231
+ if (sensitivePaths.length > 0) {
232
+ throw new Error(`Sensitive schema fields are forbidden in the OpenAI review surface: ${sensitivePaths.join(", ")}`);
233
+ }
234
+ const expectedSensitivePaths = [...schemaSensitivePaths(expected.tools)];
235
+ if (expectedSensitivePaths.length > 0) {
236
+ throw new Error(`Frozen OpenAI review snapshot contains sensitive schema fields: ${expectedSensitivePaths.join(", ")}`);
237
+ }
238
+ const canonicalExpected = canonicalizeOpenAIReviewContract(expected);
239
+ const canonicalActual = canonicalizeOpenAIReviewContract(actual);
240
+ const difference = firstDifference(canonicalExpected, canonicalActual);
241
+ if (difference) {
242
+ throw new Error(`OpenAI review descriptor drift at ${difference.path}: expected ${preview(difference.expected)}, got ${preview(difference.actual)}`);
243
+ }
244
+ }
245
+ //# sourceMappingURL=openai-review-contract.js.map
@@ -0,0 +1,51 @@
1
+ /**
2
+ * OAuth contract inputs shared by the real Worker and the OpenAI descriptor
3
+ * freeze gate. Values in this module are public protocol metadata, not secrets.
4
+ */
5
+ export declare const OPENAI_REVIEW_ORIGIN = "https://openai-mcp.frihet.io";
6
+ /**
7
+ * Byte-compatible extraction of the options previously inlined in the Worker.
8
+ * index.ts spreads this object into the real OAuthProvider constructor.
9
+ */
10
+ export declare const OAUTH_PROVIDER_REVIEW_OPTIONS: {
11
+ apiRoute: string;
12
+ authorizeEndpoint: string;
13
+ tokenEndpoint: string;
14
+ clientRegistrationEndpoint: string;
15
+ scopesSupported: string[];
16
+ accessTokenTTL: number;
17
+ refreshTokenTTL: number;
18
+ allowPlainPKCE: false;
19
+ };
20
+ /**
21
+ * Materialize the public metadata generated by workers-oauth-provider 0.3.0
22
+ * from the same options used by the Worker. The dependency version is pinned
23
+ * separately in the canonical descriptor snapshot.
24
+ */
25
+ export declare function buildOpenAIReviewOAuthContract(origin?: string): {
26
+ authorizationServer: {
27
+ issuer: string;
28
+ authorization_endpoint: string;
29
+ token_endpoint: string;
30
+ registration_endpoint: string;
31
+ scopes_supported: string[];
32
+ response_types_supported: string[];
33
+ response_modes_supported: string[];
34
+ grant_types_supported: string[];
35
+ token_endpoint_auth_methods_supported: string[];
36
+ revocation_endpoint: string;
37
+ code_challenge_methods_supported: string[];
38
+ client_id_metadata_document_supported: boolean;
39
+ };
40
+ protectedResource: {
41
+ resource: string;
42
+ authorization_servers: string[];
43
+ scopes_supported: string[];
44
+ bearer_methods_supported: string[];
45
+ };
46
+ wwwAuthenticate: {
47
+ resourceMetadataUrl: string;
48
+ missingTokenHeader: string;
49
+ };
50
+ };
51
+ //# sourceMappingURL=openai-review-oauth.d.ts.map
@@ -0,0 +1,66 @@
1
+ /**
2
+ * OAuth contract inputs shared by the real Worker and the OpenAI descriptor
3
+ * freeze gate. Values in this module are public protocol metadata, not secrets.
4
+ */
5
+ export const OPENAI_REVIEW_ORIGIN = "https://openai-mcp.frihet.io";
6
+ /**
7
+ * Byte-compatible extraction of the options previously inlined in the Worker.
8
+ * index.ts spreads this object into the real OAuthProvider constructor.
9
+ */
10
+ export const OAUTH_PROVIDER_REVIEW_OPTIONS = {
11
+ apiRoute: "/mcp",
12
+ authorizeEndpoint: "/authorize",
13
+ tokenEndpoint: "/token",
14
+ clientRegistrationEndpoint: "/register",
15
+ scopesSupported: ["read", "write"],
16
+ accessTokenTTL: 3600,
17
+ refreshTokenTTL: 2592000,
18
+ allowPlainPKCE: false,
19
+ };
20
+ function endpoint(origin, path) {
21
+ return new URL(path, new URL(origin).origin).toString();
22
+ }
23
+ /**
24
+ * Materialize the public metadata generated by workers-oauth-provider 0.3.0
25
+ * from the same options used by the Worker. The dependency version is pinned
26
+ * separately in the canonical descriptor snapshot.
27
+ */
28
+ export function buildOpenAIReviewOAuthContract(origin = OPENAI_REVIEW_ORIGIN) {
29
+ const normalizedOrigin = new URL(origin).origin;
30
+ const authorizationEndpoint = endpoint(normalizedOrigin, OAUTH_PROVIDER_REVIEW_OPTIONS.authorizeEndpoint);
31
+ const tokenEndpoint = endpoint(normalizedOrigin, OAUTH_PROVIDER_REVIEW_OPTIONS.tokenEndpoint);
32
+ const registrationEndpoint = endpoint(normalizedOrigin, OAUTH_PROVIDER_REVIEW_OPTIONS.clientRegistrationEndpoint);
33
+ const resourceMetadataUrl = endpoint(normalizedOrigin, "/.well-known/oauth-protected-resource");
34
+ return {
35
+ authorizationServer: {
36
+ issuer: normalizedOrigin,
37
+ authorization_endpoint: authorizationEndpoint,
38
+ token_endpoint: tokenEndpoint,
39
+ registration_endpoint: registrationEndpoint,
40
+ scopes_supported: [...OAUTH_PROVIDER_REVIEW_OPTIONS.scopesSupported],
41
+ response_types_supported: ["code"],
42
+ response_modes_supported: ["query"],
43
+ grant_types_supported: ["authorization_code", "refresh_token"],
44
+ token_endpoint_auth_methods_supported: [
45
+ "client_secret_basic",
46
+ "client_secret_post",
47
+ "none",
48
+ ],
49
+ revocation_endpoint: tokenEndpoint,
50
+ code_challenge_methods_supported: ["S256"],
51
+ client_id_metadata_document_supported: false,
52
+ },
53
+ protectedResource: {
54
+ resource: normalizedOrigin,
55
+ authorization_servers: [normalizedOrigin],
56
+ scopes_supported: [...OAUTH_PROVIDER_REVIEW_OPTIONS.scopesSupported],
57
+ bearer_methods_supported: ["header"],
58
+ },
59
+ wwwAuthenticate: {
60
+ resourceMetadataUrl,
61
+ missingTokenHeader: `Bearer realm="OAuth", resource_metadata="${resourceMetadataUrl}", ` +
62
+ 'error="invalid_token", error_description="Missing or invalid access token"',
63
+ },
64
+ };
65
+ }
66
+ //# sourceMappingURL=openai-review-oauth.js.map
@@ -10,7 +10,98 @@
10
10
  * - duplicate_invoice: Clone an invoice for a new period
11
11
  */
12
12
  import { z } from "zod/v4";
13
- import { withToolLogging, formatRecord, getContent, mutateContent, openObjectOutput, READ_ONLY_ANNOTATIONS, CREATE_ANNOTATIONS, } from "./shared.js";
13
+ import { withToolLogging, formatRecord, getContent, mutateContent, invoiceItemOutput, openObjectOutput, READ_ONLY_ANNOTATIONS, CREATE_ANNOTATIONS, } from "./shared.js";
14
+ const usageLimitOutput = z.union([z.number(), z.literal("unlimited")]);
15
+ const businessContextOutput = z.object({
16
+ business: z.object({
17
+ name: z.string(),
18
+ taxId: z.string().optional(),
19
+ fiscalZone: z.string(),
20
+ currency: z.string(),
21
+ language: z.string(),
22
+ country: z.string(),
23
+ }).passthrough(),
24
+ defaults: z.object({
25
+ taxRate: z.number(),
26
+ irpfRate: z.number(),
27
+ dueDays: z.number(),
28
+ currency: z.string(),
29
+ }).passthrough(),
30
+ plan: z.object({
31
+ name: z.string(),
32
+ invoices: z.object({ used: z.number(), limit: usageLimitOutput }),
33
+ expenses: z.object({ used: z.number(), limit: usageLimitOutput }),
34
+ aiMessages: z.object({ used: z.number(), limit: usageLimitOutput }),
35
+ }).passthrough(),
36
+ series: z.array(z.object({
37
+ id: z.string().optional(),
38
+ prefix: z.string().optional(),
39
+ current: z.number().optional(),
40
+ year: z.number().optional(),
41
+ }).passthrough()),
42
+ recentActivity: z.object({
43
+ lastInvoice: z.object({
44
+ number: z.string().optional(),
45
+ date: z.string().optional(),
46
+ client: z.string().optional(),
47
+ }).passthrough().nullable(),
48
+ lastExpense: z.object({
49
+ date: z.string().optional(),
50
+ vendor: z.string().optional(),
51
+ amount: z.number(),
52
+ }).passthrough().nullable(),
53
+ overdueCount: z.number(),
54
+ overdueAmount: z.number(),
55
+ unpaidCount: z.number(),
56
+ }).passthrough(),
57
+ topClients: z.array(z.object({
58
+ name: z.string(),
59
+ totalRevenue: z.number(),
60
+ invoiceCount: z.number(),
61
+ }).passthrough()),
62
+ currentMonth: z.object({
63
+ revenue: z.number(),
64
+ expenses: z.number(),
65
+ profit: z.number(),
66
+ invoiceCount: z.number(),
67
+ expenseCount: z.number(),
68
+ }).passthrough(),
69
+ }).passthrough().describe("Business context snapshot: workspace profile, fiscal setup, plan usage, recent activity and current-month performance");
70
+ const monthlySummaryOutput = z.object({
71
+ period: z.string(),
72
+ revenue: z.object({
73
+ total: z.number(),
74
+ taxBase: z.number(),
75
+ tax: z.number(),
76
+ irpf: z.number(),
77
+ }).passthrough(),
78
+ expenses: z.object({
79
+ total: z.number(),
80
+ deductible: z.number(),
81
+ tax: z.number(),
82
+ }).passthrough(),
83
+ profit: z.object({
84
+ gross: z.number(),
85
+ net: z.number(),
86
+ }).passthrough(),
87
+ invoices: z.object({
88
+ created: z.number(),
89
+ sent: z.number(),
90
+ paid: z.number(),
91
+ overdue: z.number(),
92
+ }).passthrough(),
93
+ topClients: z.array(z.object({
94
+ name: z.string(),
95
+ totalRevenue: z.number(),
96
+ invoiceCount: z.number(),
97
+ }).passthrough()),
98
+ byCategory: z.record(z.string(), z.number()),
99
+ taxLiability: z.object({
100
+ vatPayable: z.number(),
101
+ irpfRetained: z.number(),
102
+ estimatedModel303: z.number(),
103
+ }).passthrough(),
104
+ }).passthrough().describe("Monthly financial summary with revenue, expenses, profit, invoice status, top clients and estimated tax liability");
14
105
  export function registerIntelligenceTools(server, client) {
15
106
  // -- get_business_context --
16
107
  server.registerTool("get_business_context", {
@@ -22,7 +113,7 @@ export function registerIntelligenceTools(server, client) {
22
113
  "principales clientes y resumen del mes actual. Llama a esto PRIMERO en cualquier sesion.",
23
114
  annotations: READ_ONLY_ANNOTATIONS,
24
115
  inputSchema: {},
25
- outputSchema: openObjectOutput("Business context snapshot: workspace profile, fiscal setup, recent activity / Contexto de negocio: perfil, configuración fiscal y actividad reciente"),
116
+ outputSchema: businessContextOutput,
26
117
  }, async () => withToolLogging("get_business_context", async () => {
27
118
  const result = await client.getBusinessContext();
28
119
  return {
@@ -46,7 +137,7 @@ export function registerIntelligenceTools(server, client) {
46
137
  .describe("Month in YYYY-MM format (defaults to current month). Example: '2026-03' " +
47
138
  "/ Mes en formato YYYY-MM (por defecto el mes actual)"),
48
139
  },
49
- outputSchema: openObjectOutput("Monthly P&L summary: revenue, expenses, profit, tax liability, invoice stats / Resumen mensual: ingresos, gastos, beneficio, impuestos"),
140
+ outputSchema: monthlySummaryOutput,
50
141
  }, async ({ month }) => withToolLogging("get_monthly_summary", async () => {
51
142
  const result = await client.getMonthlySummary(month);
52
143
  const label = month ? `Monthly Summary (${month})` : "Monthly Summary (current month)";
@@ -101,7 +192,7 @@ export function registerIntelligenceTools(server, client) {
101
192
  .optional()
102
193
  .describe("Due date for the new invoice (YYYY-MM-DD) / Fecha de vencimiento de la nueva factura"),
103
194
  },
104
- outputSchema: openObjectOutput("The newly created draft invoice, cloned from the original / La nueva factura borrador, clonada de la original"),
195
+ outputSchema: invoiceItemOutput.describe("The newly created draft invoice, cloned from the original / La nueva factura borrador, clonada de la original"),
105
196
  }, async ({ id, newIssueDate, newDueDate }) => withToolLogging("duplicate_invoice", async () => {
106
197
  // 1. Fetch the original invoice (returns the FULL raw stored document —
107
198
  // payments, verifactu, eInvoice, operationType, createdBy, etc.)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frihet/mcp-server",
3
- "version": "1.16.5",
3
+ "version": "1.16.6",
4
4
  "description": "AI-native MCP server for Frihet ERP — 157 tools: invoicing, expenses, CRM, banking, POS + ES/EU fiscal compliance (VeriFactu, TicketBAI, Facturae). Zero-install at mcp.frihet.io. Works with Claude, ChatGPT, Cursor, Windsurf, Cline & any MCP client.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -9,7 +9,9 @@
9
9
  },
10
10
  "scripts": {
11
11
  "build": "tsc",
12
- "test": "npm run build && node --test dist/__tests__/openai-profile.test.js dist/__tests__/tool-exposure.test.js dist/__tests__/einvoice-tools.test.js dist/__tests__/einvoice-day4-tools.test.js dist/__tests__/stay-tools.test.js dist/__tests__/pos-tools.test.js dist/__tests__/kitchen-tools.test.js dist/__tests__/banking-tools.test.js dist/__tests__/banking-client-contract.test.js dist/__tests__/pagination-cursor-param.test.js dist/__tests__/fiscal-tools.test.js dist/__tests__/time-tools.test.js dist/__tests__/recurring-tools.test.js dist/__tests__/team-tools.test.js dist/__tests__/d4b-hr-payroll-onboarding-tools.test.js dist/__tests__/audit-server-version.test.js dist/__tests__/openai-grouped-compose.test.js dist/__tests__/contract.test.js dist/__tests__/observability-redaction.test.js dist/__tests__/intelligence-duplicate-invoice.test.js dist/__tests__/get-envelope-unwrap-regression.test.js dist/__tests__/mutation-unwrap-and-schema-regression.test.js dist/__tests__/schema-envelope-guard.test.js dist/__tests__/demo-mode.test.js dist/__tests__/idempotency-key-contract.test.js",
12
+ "test": "npm run build && node --test dist/__tests__/openai-profile.test.js dist/__tests__/tool-exposure.test.js dist/__tests__/einvoice-tools.test.js dist/__tests__/einvoice-day4-tools.test.js dist/__tests__/stay-tools.test.js dist/__tests__/pos-tools.test.js dist/__tests__/kitchen-tools.test.js dist/__tests__/banking-tools.test.js dist/__tests__/banking-client-contract.test.js dist/__tests__/pagination-cursor-param.test.js dist/__tests__/fiscal-tools.test.js dist/__tests__/time-tools.test.js dist/__tests__/recurring-tools.test.js dist/__tests__/team-tools.test.js dist/__tests__/d4b-hr-payroll-onboarding-tools.test.js dist/__tests__/audit-server-version.test.js dist/__tests__/openai-grouped-compose.test.js dist/__tests__/openai-review-descriptor.test.js dist/__tests__/contract.test.js dist/__tests__/observability-redaction.test.js dist/__tests__/intelligence-duplicate-invoice.test.js dist/__tests__/get-envelope-unwrap-regression.test.js dist/__tests__/mutation-unwrap-and-schema-regression.test.js dist/__tests__/schema-envelope-guard.test.js dist/__tests__/demo-mode.test.js dist/__tests__/idempotency-key-contract.test.js dist/__tests__/source-header-contract.test.js",
13
+ "test:openai-review-descriptor": "npm run build && node --test dist/__tests__/openai-review-descriptor.test.js",
14
+ "gate:openai-review-descriptor": "npm run build && node scripts/check-openai-review-descriptor.mjs",
13
15
  "start": "node dist/index.js",
14
16
  "postinstall": "node scripts/postinstall.js || true",
15
17
  "prepublishOnly": "npm run build && npm run audit:mcp-refs -- --repo frihet-mcp && npm run gate:no-legacy-region",