@frihet/mcp-server 1.16.4 → 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 +1 -1
- package/dist/client-interface.d.ts +1 -1
- package/dist/client.d.ts +13 -1
- package/dist/client.js +102 -8
- package/dist/demo-client.d.ts +1 -1
- package/dist/demo-client.js +61 -15
- package/dist/openai-profile.d.ts +8 -0
- package/dist/openai-profile.js +53 -17
- package/dist/openai-review-contract.d.ts +40 -0
- package/dist/openai-review-contract.js +245 -0
- package/dist/openai-review-oauth.d.ts +51 -0
- package/dist/openai-review-oauth.js +66 -0
- package/dist/tools/intelligence.js +95 -4
- package/dist/tools/invoices.js +24 -11
- package/package.json +8 -3
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:**
|
|
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
|
|
|
@@ -104,7 +104,7 @@ export interface IFrihetClient {
|
|
|
104
104
|
reasonDescription?: string;
|
|
105
105
|
fullCredit?: boolean;
|
|
106
106
|
issueDate?: string;
|
|
107
|
-
}): Promise<Record<string, unknown>>;
|
|
107
|
+
}, idempotencyKey?: string): Promise<Record<string, unknown>>;
|
|
108
108
|
applyLateFee(invoiceId: string, data?: {
|
|
109
109
|
amount?: number;
|
|
110
110
|
daysOverdue?: number;
|
package/dist/client.d.ts
CHANGED
|
@@ -153,12 +153,24 @@ export declare class FrihetClient {
|
|
|
153
153
|
markInvoicePaid(id: string, paidDate?: string): Promise<Record<string, unknown>>;
|
|
154
154
|
getInvoicePdf(id: string): Promise<Record<string, unknown>>;
|
|
155
155
|
getInvoiceEInvoice(invoiceId: string): Promise<any>;
|
|
156
|
+
/**
|
|
157
|
+
* `POST /v1/invoices/:id/credit-note` — creates a rectificativa DRAFT.
|
|
158
|
+
*
|
|
159
|
+
* The backend REQUIRES an `Idempotency-Key` header (`400
|
|
160
|
+
* IDEMPOTENCY_KEY_REQUIRED` without it). `request` mints one for every
|
|
161
|
+
* mutation, so passing `idempotencyKey` is optional: supply it to make a
|
|
162
|
+
* caller-driven retry replay the stored 201 instead of creating a second
|
|
163
|
+
* draft. The backend marks that replay with `X-Idempotent-Replayed: true`,
|
|
164
|
+
* but this client reads no response headers, so the replayed 201 and the
|
|
165
|
+
* original are indistinguishable to the caller — both are the same draft,
|
|
166
|
+
* which is the property that matters here.
|
|
167
|
+
*/
|
|
156
168
|
createCreditNote(invoiceId: string, data: {
|
|
157
169
|
reason: string;
|
|
158
170
|
reasonDescription?: string;
|
|
159
171
|
fullCredit?: boolean;
|
|
160
172
|
issueDate?: string;
|
|
161
|
-
}): Promise<Record<string, unknown>>;
|
|
173
|
+
}, idempotencyKey?: string): Promise<Record<string, unknown>>;
|
|
162
174
|
applyLateFee(invoiceId: string, data?: {
|
|
163
175
|
amount?: number;
|
|
164
176
|
daysOverdue?: number;
|
package/dist/client.js
CHANGED
|
@@ -16,6 +16,76 @@ const BASE_URL = "https://api.frihet.io/v1";
|
|
|
16
16
|
const MAX_RETRIES = 3;
|
|
17
17
|
const DEFAULT_RETRY_DELAY_MS = 1000;
|
|
18
18
|
const REQUEST_TIMEOUT_MS = 30000;
|
|
19
|
+
/**
|
|
20
|
+
* HTTP methods for which the backend treats the request as a mutation and
|
|
21
|
+
* therefore accepts (and for some endpoints REQUIRES) an `Idempotency-Key`
|
|
22
|
+
* header. `POST /v1/invoices/:id/credit-note` rejects a keyless request with
|
|
23
|
+
* `400 IDEMPOTENCY_KEY_REQUIRED`, so a client that never sends one fails 100%
|
|
24
|
+
* of the time — see src/__tests__/idempotency-key-contract.test.ts.
|
|
25
|
+
*/
|
|
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";
|
|
55
|
+
/**
|
|
56
|
+
* Fresh idempotency key, always a syntactically valid UUID v4.
|
|
57
|
+
*
|
|
58
|
+
* `crypto.randomUUID` is a global on the Cloudflare Workers runtime and on
|
|
59
|
+
* Node >= 19. On Node 18 — our declared `engines` floor — `globalThis.crypto`
|
|
60
|
+
* is behind `--experimental-global-webcrypto`, so the fallback is a REAL code
|
|
61
|
+
* path there, not a theoretical one. It therefore has to produce a UUID and
|
|
62
|
+
* not an ad-hoc string: the backend documents "UUID v4 recommended", and
|
|
63
|
+
* src/__tests__/idempotency-key-contract.test.ts asserts the shape.
|
|
64
|
+
*/
|
|
65
|
+
function newIdempotencyKey() {
|
|
66
|
+
const c = globalThis.crypto;
|
|
67
|
+
if (typeof c?.randomUUID === "function") {
|
|
68
|
+
return c.randomUUID();
|
|
69
|
+
}
|
|
70
|
+
// RFC 4122 v4 layout from Math.random. Weaker entropy than the CSPRNG, but
|
|
71
|
+
// the key only has to be unique per caller, never unguessable.
|
|
72
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (ch) => {
|
|
73
|
+
const r = (Math.random() * 16) | 0;
|
|
74
|
+
const v = ch === "x" ? r : (r & 0x3) | 0x8;
|
|
75
|
+
return v.toString(16);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A caller-supplied key counts only if it carries a value. An empty or
|
|
80
|
+
* whitespace-only string is what an LLM client emits for "I have nothing to
|
|
81
|
+
* put here" on an optional string param — treating it as PRESENT would leave
|
|
82
|
+
* the request keyless and reproduce the very `400 IDEMPOTENCY_KEY_REQUIRED`
|
|
83
|
+
* this client exists to prevent.
|
|
84
|
+
*/
|
|
85
|
+
function normalizeIdempotencyKey(key) {
|
|
86
|
+
const trimmed = key?.trim();
|
|
87
|
+
return trimmed ? trimmed : undefined;
|
|
88
|
+
}
|
|
19
89
|
export class FrihetApiError extends Error {
|
|
20
90
|
statusCode;
|
|
21
91
|
errorCode;
|
|
@@ -40,8 +110,15 @@ export class FrihetClient {
|
|
|
40
110
|
}
|
|
41
111
|
// ------------------------------------------------------------------ HTTP
|
|
42
112
|
// ------------------------------------------------------------------
|
|
43
|
-
async request(method, path, body, query, retryCount = 0) {
|
|
113
|
+
async request(method, path, body, query, retryCount = 0, idempotencyKey) {
|
|
44
114
|
const url = new URL(`${this.baseUrl}${path}`);
|
|
115
|
+
// Resolved once, at the top of the call chain: a caller-supplied key wins,
|
|
116
|
+
// otherwise mutations get a freshly minted one. The resolved value is then
|
|
117
|
+
// threaded through the 429 recursion below so a retry replays the SAME key
|
|
118
|
+
// — retrying with a new key is exactly the duplicate the key exists to
|
|
119
|
+
// prevent (a retried credit-note would create a second draft).
|
|
120
|
+
const resolvedIdempotencyKey = normalizeIdempotencyKey(idempotencyKey) ??
|
|
121
|
+
(MUTATING_METHODS.has(method) ? newIdempotencyKey() : undefined);
|
|
45
122
|
if (query) {
|
|
46
123
|
for (const [key, value] of Object.entries(query)) {
|
|
47
124
|
if (value !== undefined) {
|
|
@@ -53,7 +130,12 @@ export class FrihetClient {
|
|
|
53
130
|
"X-API-Key": this.apiKey,
|
|
54
131
|
"Content-Type": "application/json",
|
|
55
132
|
Accept: "application/json",
|
|
133
|
+
"X-Frihet-Source": SOURCE_MARKER,
|
|
134
|
+
"User-Agent": SOURCE_USER_AGENT,
|
|
56
135
|
};
|
|
136
|
+
if (resolvedIdempotencyKey) {
|
|
137
|
+
headers["Idempotency-Key"] = resolvedIdempotencyKey;
|
|
138
|
+
}
|
|
57
139
|
const controller = new AbortController();
|
|
58
140
|
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
59
141
|
const startTime = Date.now();
|
|
@@ -91,7 +173,7 @@ export class FrihetClient {
|
|
|
91
173
|
: DEFAULT_RETRY_DELAY_MS * Math.pow(2, retryCount);
|
|
92
174
|
logRetry(method, path, retryCount, delayMs);
|
|
93
175
|
await this.sleep(delayMs);
|
|
94
|
-
return this.request(method, path, body, query, retryCount + 1);
|
|
176
|
+
return this.request(method, path, body, query, retryCount + 1, resolvedIdempotencyKey);
|
|
95
177
|
}
|
|
96
178
|
// Error responses
|
|
97
179
|
if (!response.ok) {
|
|
@@ -150,8 +232,8 @@ export class FrihetClient {
|
|
|
150
232
|
* not an envelope (legacy endpoints that return the item directly), it is
|
|
151
233
|
* returned unchanged so existing callers keep working.
|
|
152
234
|
*/
|
|
153
|
-
async requestUnwrapped(method, path, body, query) {
|
|
154
|
-
const raw = await this.request(method, path, body, query);
|
|
235
|
+
async requestUnwrapped(method, path, body, query, idempotencyKey) {
|
|
236
|
+
const raw = await this.request(method, path, body, query, 0, idempotencyKey);
|
|
155
237
|
if (raw !== null &&
|
|
156
238
|
typeof raw === "object" &&
|
|
157
239
|
!Array.isArray(raw) &&
|
|
@@ -358,8 +440,20 @@ export class FrihetClient {
|
|
|
358
440
|
// (e.g. a bare XML string), so this is safe either way.
|
|
359
441
|
return this.requestUnwrapped("GET", `/invoices/${encodeURIComponent(invoiceId)}/xml`);
|
|
360
442
|
}
|
|
361
|
-
|
|
362
|
-
|
|
443
|
+
/**
|
|
444
|
+
* `POST /v1/invoices/:id/credit-note` — creates a rectificativa DRAFT.
|
|
445
|
+
*
|
|
446
|
+
* The backend REQUIRES an `Idempotency-Key` header (`400
|
|
447
|
+
* IDEMPOTENCY_KEY_REQUIRED` without it). `request` mints one for every
|
|
448
|
+
* mutation, so passing `idempotencyKey` is optional: supply it to make a
|
|
449
|
+
* caller-driven retry replay the stored 201 instead of creating a second
|
|
450
|
+
* draft. The backend marks that replay with `X-Idempotent-Replayed: true`,
|
|
451
|
+
* but this client reads no response headers, so the replayed 201 and the
|
|
452
|
+
* original are indistinguishable to the caller — both are the same draft,
|
|
453
|
+
* which is the property that matters here.
|
|
454
|
+
*/
|
|
455
|
+
async createCreditNote(invoiceId, data, idempotencyKey) {
|
|
456
|
+
return this.requestUnwrapped("POST", `/invoices/${encodeURIComponent(invoiceId)}/credit-note`, data, undefined, idempotencyKey);
|
|
363
457
|
}
|
|
364
458
|
async applyLateFee(invoiceId, data) {
|
|
365
459
|
return this.requestUnwrapped("POST", `/invoices/${encodeURIComponent(invoiceId)}/late-fee`, data ?? {});
|
|
@@ -599,10 +693,10 @@ export class FrihetClient {
|
|
|
599
693
|
// ---------------------------------------------------------------- Intelligence
|
|
600
694
|
// ----------------------------------------------------------------
|
|
601
695
|
async getBusinessContext() {
|
|
602
|
-
return this.
|
|
696
|
+
return this.requestUnwrapped("GET", "/context");
|
|
603
697
|
}
|
|
604
698
|
async getMonthlySummary(month) {
|
|
605
|
-
return this.
|
|
699
|
+
return this.requestUnwrapped("GET", "/monthly", undefined, {
|
|
606
700
|
month,
|
|
607
701
|
});
|
|
608
702
|
}
|
package/dist/demo-client.d.ts
CHANGED
|
@@ -82,7 +82,7 @@ export declare class DemoFrihetClient implements IFrihetClient {
|
|
|
82
82
|
reasonDescription?: string;
|
|
83
83
|
fullCredit?: boolean;
|
|
84
84
|
issueDate?: string;
|
|
85
|
-
}): Promise<Rec>;
|
|
85
|
+
}, _idempotencyKey?: string): Promise<Rec>;
|
|
86
86
|
applyLateFee(invoiceId: string, data?: {
|
|
87
87
|
amount?: number;
|
|
88
88
|
daysOverdue?: number;
|
package/dist/demo-client.js
CHANGED
|
@@ -149,15 +149,35 @@ export class DemoFrihetClient {
|
|
|
149
149
|
...FISCAL_STAMP,
|
|
150
150
|
};
|
|
151
151
|
}
|
|
152
|
-
|
|
152
|
+
// Mirrors the live 201 body: a DRAFT rectificativa, always by differences
|
|
153
|
+
// (`rectificationMethod: "I"`), R-type derived from `reason` server-side
|
|
154
|
+
// (`error` → R1, everything else → R4 — R2/R3/R5 are unreachable via the API).
|
|
155
|
+
async createCreditNote(invoiceId, data, _idempotencyKey) {
|
|
156
|
+
// Shape rules taken from the live 201 (functions/src/publicApi.ts) rather
|
|
157
|
+
// than invented:
|
|
158
|
+
// - documentNumber is `CN-<original document number>` and NEVER encodes
|
|
159
|
+
// the R-type (creditNoteService.ts: `CN-${originalDocNumber}`). An
|
|
160
|
+
// earlier fixture emitted `R1-DEMO-001`, teaching an agent to parse a
|
|
161
|
+
// field that carries no R-type live.
|
|
162
|
+
// - fullCredit is hardcoded `true` in the live body: `false` never
|
|
163
|
+
// reaches a 201, it is refused with 400 PARTIAL_CREDIT_NOT_IMPLEMENTED.
|
|
164
|
+
// Echoing the input would contradict this tool's own description.
|
|
165
|
+
// Documented divergence: demo mode never throws (the seam's contract), so
|
|
166
|
+
// `fullCredit: false` returns the same simulated draft instead of the live
|
|
167
|
+
// 400. Pinned in src/__tests__/demo-mode.test.ts.
|
|
168
|
+
const original = findOrStub(demoInvoices, invoiceId);
|
|
169
|
+
const originalNumber = typeof original.documentNumber === "string" ? original.documentNumber : "DEMO-001";
|
|
153
170
|
return {
|
|
154
171
|
success: true,
|
|
155
172
|
creditNote: {
|
|
156
173
|
id: demoId("demo_cn"),
|
|
157
|
-
documentNumber:
|
|
174
|
+
documentNumber: `CN-${originalNumber}`,
|
|
158
175
|
originalInvoiceId: invoiceId,
|
|
159
176
|
reason: data.reason,
|
|
160
|
-
fullCredit:
|
|
177
|
+
fullCredit: true,
|
|
178
|
+
status: "draft",
|
|
179
|
+
rectificationMethod: "I",
|
|
180
|
+
totalCredited: 1210.0,
|
|
161
181
|
},
|
|
162
182
|
...FISCAL_STAMP,
|
|
163
183
|
};
|
|
@@ -237,23 +257,49 @@ export class DemoFrihetClient {
|
|
|
237
257
|
// ---------------------------------------------------------------- Intelligence
|
|
238
258
|
async getBusinessContext() {
|
|
239
259
|
return {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
+
},
|
|
245
290
|
...READ_STAMP,
|
|
246
291
|
};
|
|
247
292
|
}
|
|
248
293
|
async getMonthlySummary(month) {
|
|
249
294
|
return {
|
|
250
|
-
|
|
251
|
-
revenue: 3960.4,
|
|
252
|
-
expenses: 405.6,
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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 },
|
|
257
303
|
...READ_STAMP,
|
|
258
304
|
};
|
|
259
305
|
}
|
package/dist/openai-profile.d.ts
CHANGED
|
@@ -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
|
package/dist/openai-profile.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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 (
|
|
231
|
-
const inner =
|
|
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
|
|
237
|
-
rebuilt = rebuilt.describe(
|
|
258
|
+
if (typeof schema.description === "string")
|
|
259
|
+
rebuilt = rebuilt.describe(schema.description);
|
|
238
260
|
return rebuilt;
|
|
239
261
|
}
|
|
240
|
-
if (
|
|
241
|
-
const inner =
|
|
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 (
|
|
246
|
-
const inner =
|
|
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:
|
|
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:
|
|
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:
|
|
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/dist/tools/invoices.js
CHANGED
|
@@ -386,13 +386,14 @@ export function registerInvoiceTools(server, client) {
|
|
|
386
386
|
// -- create_credit_note --
|
|
387
387
|
server.registerTool("create_credit_note", {
|
|
388
388
|
title: "Create Credit Note",
|
|
389
|
-
description: "Create a credit note (factura rectificativa) for an existing invoice. " +
|
|
390
|
-
"
|
|
391
|
-
"
|
|
392
|
-
"
|
|
393
|
-
"
|
|
394
|
-
"
|
|
395
|
-
"
|
|
389
|
+
description: "Create a full credit note (factura rectificativa) for an existing invoice, as a DRAFT. " +
|
|
390
|
+
"It does NOT issue: the draft carries no fiscal number, no hash and is not sent to VeriFactu — " +
|
|
391
|
+
"issue it from the app when you are ready. Always rectifies by differences (TipoRectificativa = I). " +
|
|
392
|
+
"Spanish market: R-type is derived from `reason` (error -> R1, anything else -> R4). " +
|
|
393
|
+
"Partial credits are not supported. Requires the `pro` plan. " +
|
|
394
|
+
"/ Crea una factura rectificativa completa como BORRADOR. No emite: sin numero fiscal, sin hash, " +
|
|
395
|
+
"sin envio a VeriFactu. Siempre rectifica por diferencias (tipo I). El tipo R se deriva de `reason` " +
|
|
396
|
+
"(error -> R1, resto -> R4). No admite abonos parciales. Requiere plan `pro`.",
|
|
396
397
|
annotations: CREATE_ANNOTATIONS,
|
|
397
398
|
inputSchema: {
|
|
398
399
|
invoiceId: z
|
|
@@ -409,21 +410,33 @@ export function registerInvoiceTools(server, client) {
|
|
|
409
410
|
fullCredit: z
|
|
410
411
|
.boolean()
|
|
411
412
|
.optional()
|
|
412
|
-
.describe("
|
|
413
|
-
"
|
|
413
|
+
.describe("Must be true (the default). `false` is rejected with 400 PARTIAL_CREDIT_NOT_IMPLEMENTED — " +
|
|
414
|
+
"the API has no line-level or partial credit. It does NOT select the rectification method: " +
|
|
415
|
+
"that is always I (por diferencias). " +
|
|
416
|
+
"/ Debe ser true (por defecto). `false` devuelve 400 PARTIAL_CREDIT_NOT_IMPLEMENTED. " +
|
|
417
|
+
"No elige el metodo de rectificacion: siempre es I (por diferencias)."),
|
|
414
418
|
issueDate: z
|
|
415
419
|
.string()
|
|
416
420
|
.optional()
|
|
417
421
|
.describe("ISO date for the credit note (YYYY-MM-DD). Defaults to today. / Fecha de emision (YYYY-MM-DD). Por defecto hoy."),
|
|
422
|
+
idempotencyKey: z
|
|
423
|
+
.string()
|
|
424
|
+
.max(64)
|
|
425
|
+
.optional()
|
|
426
|
+
.describe("Optional idempotency key, max 64 chars. One is generated per call when omitted or blank; " +
|
|
427
|
+
"pass your own to make a retry replay the stored result instead of creating a second draft. " +
|
|
428
|
+
"Reusing a key with a different body returns 409 IDEMPOTENCY_KEY_REUSED — reconcile, do not " +
|
|
429
|
+
"retry with a new key. " +
|
|
430
|
+
"/ Clave de idempotencia opcional (max 64). Se genera una por llamada si se omite o va vacia."),
|
|
418
431
|
},
|
|
419
432
|
outputSchema: creditNoteResultOutput,
|
|
420
|
-
}, async ({ invoiceId, reason, reasonDescription, fullCredit, issueDate }) => withToolLogging("create_credit_note", async () => {
|
|
433
|
+
}, async ({ invoiceId, reason, reasonDescription, fullCredit, issueDate, idempotencyKey }) => withToolLogging("create_credit_note", async () => {
|
|
421
434
|
const result = await client.createCreditNote(invoiceId, {
|
|
422
435
|
reason,
|
|
423
436
|
reasonDescription,
|
|
424
437
|
fullCredit: fullCredit ?? true,
|
|
425
438
|
issueDate,
|
|
426
|
-
});
|
|
439
|
+
}, idempotencyKey);
|
|
427
440
|
const hints = enrichResponse("invoices", "create", result);
|
|
428
441
|
return {
|
|
429
442
|
content: [mutateContent(formatRecord("Credit note created", result))],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frihet/mcp-server",
|
|
3
|
-
"version": "1.16.
|
|
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,14 +9,19 @@
|
|
|
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",
|
|
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",
|
|
16
18
|
"audit:mcp-refs": "node scripts/audit-mcp-refs.mjs",
|
|
17
19
|
"audit:mcp-refs:fix": "node scripts/audit-mcp-refs.mjs --fix",
|
|
18
20
|
"gate:no-leak": "bash scripts/no-public-leak.sh",
|
|
19
|
-
"gate:no-legacy-region": "bash scripts/no-legacy-region.sh"
|
|
21
|
+
"gate:no-legacy-region": "bash scripts/no-legacy-region.sh",
|
|
22
|
+
"sync:openapi": "node scripts/sync-openapi.mjs",
|
|
23
|
+
"gate:openapi-fresh": "node scripts/sync-openapi.mjs --check",
|
|
24
|
+
"gate:openapi-fresh:live": "node scripts/sync-openapi.mjs --check --live"
|
|
20
25
|
},
|
|
21
26
|
"keywords": [
|
|
22
27
|
"accounting",
|