@frihet/mcp-server
Advanced tools
| /** | ||
| * Fail-closed contract capture for the ChatGPT-reviewed MCP surface. | ||
| * | ||
| * This intentionally uses a real McpServer, real Client, in-memory MCP | ||
| * transport, and the production registration path. It therefore freezes what | ||
| * tools/list serializes, not an approximation of registration config objects. | ||
| */ | ||
| export declare const OPENAI_REVIEW_CONTRACT_VERSION = 1; | ||
| export declare const OPENAI_REVIEW_BUSINESS_TOOL_COUNT = 53; | ||
| export declare const OPENAI_REVIEW_DISCOVERY_TOOLS: readonly ["describe_tool", "list_tool_groups", "search_tools"]; | ||
| export declare const OPENAI_REVIEW_TOTAL_TOOL_COUNT = 56; | ||
| export type JsonValue = null | boolean | number | string | JsonValue[] | { | ||
| [key: string]: JsonValue; | ||
| }; | ||
| export interface OpenAIReviewTool extends Record<string, JsonValue> { | ||
| name: string; | ||
| } | ||
| export interface OpenAIReviewContract { | ||
| contractVersion: number; | ||
| tools: OpenAIReviewTool[]; | ||
| prompts: JsonValue[]; | ||
| resources: JsonValue[]; | ||
| oauth: Record<string, JsonValue>; | ||
| } | ||
| export interface OpenAIReviewMcpSurface { | ||
| tools: OpenAIReviewTool[]; | ||
| prompts: JsonValue[]; | ||
| resources: JsonValue[]; | ||
| } | ||
| /** Capture the exact OpenAI grouped surface over a real tools/list request. */ | ||
| export declare function captureOpenAIReviewMcpSurface(): Promise<OpenAIReviewMcpSurface>; | ||
| export declare function buildOpenAIReviewContract(surface: OpenAIReviewMcpSurface, oauth: Record<string, JsonValue>): OpenAIReviewContract; | ||
| /** Canonicalize object-key and tools/list order only; arrays remain semantic. */ | ||
| export declare function canonicalizeOpenAIReviewContract(contract: OpenAIReviewContract): OpenAIReviewContract; | ||
| export declare function serializeOpenAIReviewContract(contract: OpenAIReviewContract): string; | ||
| /** | ||
| * Fail closed on any semantic drift from the reviewed descriptor snapshot. | ||
| */ | ||
| export declare function assertOpenAIReviewContract(actual: OpenAIReviewContract, expected: OpenAIReviewContract): void; | ||
| //# sourceMappingURL=openai-review-contract.d.ts.map |
| /** | ||
| * Fail-closed contract capture for the ChatGPT-reviewed MCP surface. | ||
| * | ||
| * This intentionally uses a real McpServer, real Client, in-memory MCP | ||
| * transport, and the production registration path. It therefore freezes what | ||
| * tools/list serializes, not an approximation of registration config objects. | ||
| */ | ||
| import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | ||
| import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; | ||
| import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| import { applyOpenAIReviewProfiles, OPENAI_REVIEWED_TOOL_ALLOWLIST, } from "./openai-profile.js"; | ||
| import { SENSITIVE_FIELD_NAMES } from "./redaction.js"; | ||
| import { registerAllPrompts } from "./prompts/register-all.js"; | ||
| import { registerAllResources } from "./resources/register-all.js"; | ||
| import { registerAllTools } from "./tools/register-all.js"; | ||
| export const OPENAI_REVIEW_CONTRACT_VERSION = 1; | ||
| export const OPENAI_REVIEW_BUSINESS_TOOL_COUNT = 53; | ||
| export const OPENAI_REVIEW_DISCOVERY_TOOLS = [ | ||
| "describe_tool", | ||
| "list_tool_groups", | ||
| "search_tools", | ||
| ]; | ||
| export const OPENAI_REVIEW_TOTAL_TOOL_COUNT = 56; | ||
| function makeRegistrationClient() { | ||
| return new Proxy({}, { | ||
| get: () => async () => ({ data: [], total: 0, limit: 0, offset: 0 }), | ||
| }); | ||
| } | ||
| function isMethodNotFound(error) { | ||
| return (typeof error === "object" && | ||
| error !== null && | ||
| "code" in error && | ||
| error.code === -32601); | ||
| } | ||
| async function listPromptsOrEmpty(client) { | ||
| const prompts = []; | ||
| let cursor; | ||
| try { | ||
| do { | ||
| const page = await client.listPrompts(cursor ? { cursor } : undefined); | ||
| prompts.push(...page.prompts); | ||
| cursor = page.nextCursor; | ||
| } while (cursor); | ||
| } | ||
| catch (error) { | ||
| if (isMethodNotFound(error)) | ||
| return []; | ||
| throw error; | ||
| } | ||
| return prompts; | ||
| } | ||
| async function listResourcesOrEmpty(client) { | ||
| const resources = []; | ||
| let cursor; | ||
| try { | ||
| do { | ||
| const page = await client.listResources(cursor ? { cursor } : undefined); | ||
| resources.push(...page.resources); | ||
| cursor = page.nextCursor; | ||
| } while (cursor); | ||
| } | ||
| catch (error) { | ||
| if (isMethodNotFound(error)) | ||
| return []; | ||
| throw error; | ||
| } | ||
| return resources; | ||
| } | ||
| /** Capture the exact OpenAI grouped surface over a real tools/list request. */ | ||
| export async function captureOpenAIReviewMcpSurface() { | ||
| const server = new McpServer({ | ||
| name: "frihet-openai-review-freeze", | ||
| version: "1.0.0", | ||
| }); | ||
| applyOpenAIReviewProfiles(server); | ||
| registerAllTools(server, makeRegistrationClient()); | ||
| registerAllResources(server); | ||
| registerAllPrompts(server); | ||
| const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); | ||
| const client = new Client({ name: "frihet-openai-review-freeze-client", version: "1.0.0" }, { capabilities: {} }); | ||
| try { | ||
| await Promise.all([ | ||
| server.connect(serverTransport), | ||
| client.connect(clientTransport), | ||
| ]); | ||
| const tools = []; | ||
| let cursor; | ||
| do { | ||
| const page = await client.listTools(cursor ? { cursor } : undefined); | ||
| tools.push(...page.tools); | ||
| cursor = page.nextCursor; | ||
| } while (cursor); | ||
| const [prompts, resources] = await Promise.all([ | ||
| listPromptsOrEmpty(client), | ||
| listResourcesOrEmpty(client), | ||
| ]); | ||
| return { tools, prompts, resources }; | ||
| } | ||
| finally { | ||
| await Promise.allSettled([client.close(), server.close()]); | ||
| } | ||
| } | ||
| export function buildOpenAIReviewContract(surface, oauth) { | ||
| return { | ||
| contractVersion: OPENAI_REVIEW_CONTRACT_VERSION, | ||
| tools: surface.tools, | ||
| prompts: surface.prompts, | ||
| resources: surface.resources, | ||
| oauth, | ||
| }; | ||
| } | ||
| function canonicalizeValue(value) { | ||
| if (Array.isArray(value)) | ||
| return value.map(canonicalizeValue); | ||
| if (value !== null && typeof value === "object") { | ||
| return Object.fromEntries(Object.entries(value) | ||
| .sort(([left], [right]) => left.localeCompare(right)) | ||
| .map(([key, child]) => [key, canonicalizeValue(child)])); | ||
| } | ||
| return value; | ||
| } | ||
| /** Canonicalize object-key and tools/list order only; arrays remain semantic. */ | ||
| export function canonicalizeOpenAIReviewContract(contract) { | ||
| const canonical = canonicalizeValue(contract); | ||
| canonical.tools.sort((left, right) => left.name.localeCompare(right.name)); | ||
| return canonical; | ||
| } | ||
| export function serializeOpenAIReviewContract(contract) { | ||
| return `${JSON.stringify(canonicalizeOpenAIReviewContract(contract), null, 2)}\n`; | ||
| } | ||
| function schemaSensitivePaths(tools) { | ||
| const sensitive = new Set(SENSITIVE_FIELD_NAMES.map((field) => field.toLowerCase())); | ||
| // `documentNumber` is an invoice sequence identifier on these two tools, | ||
| // not the guest/customer identity-document field covered by the shared | ||
| // redaction policy. Keep the exception exact so any future occurrence is | ||
| // still rejected by default. | ||
| const nonSensitiveBusinessPaths = new Set([ | ||
| "create_invoice.inputSchema.properties.documentNumber", | ||
| "update_invoice.inputSchema.properties.documentNumber", | ||
| ]); | ||
| const paths = new Set(); | ||
| const visit = (value, path) => { | ||
| if (value === undefined || value === null || typeof value !== "object") | ||
| return; | ||
| if (Array.isArray(value)) { | ||
| value.forEach((child, index) => visit(child, `${path}[${index}]`)); | ||
| return; | ||
| } | ||
| for (const [key, child] of Object.entries(value)) { | ||
| const childPath = `${path}.${key}`; | ||
| if (sensitive.has(key.toLowerCase()) && | ||
| !nonSensitiveBusinessPaths.has(childPath)) { | ||
| paths.add(childPath); | ||
| } | ||
| visit(child, childPath); | ||
| } | ||
| }; | ||
| for (const tool of tools) { | ||
| visit(tool.inputSchema, `${tool.name}.inputSchema`); | ||
| visit(tool.outputSchema, `${tool.name}.outputSchema`); | ||
| } | ||
| return paths; | ||
| } | ||
| function firstDifference(expected, actual, path = "$") { | ||
| if (Object.is(expected, actual)) | ||
| return undefined; | ||
| if (Array.isArray(expected) || Array.isArray(actual)) { | ||
| if (!Array.isArray(expected) || !Array.isArray(actual)) { | ||
| return { path, expected, actual }; | ||
| } | ||
| if (expected.length !== actual.length) | ||
| return { path: `${path}.length`, expected: expected.length, actual: actual.length }; | ||
| for (let index = 0; index < expected.length; index += 1) { | ||
| const difference = firstDifference(expected[index], actual[index], `${path}[${index}]`); | ||
| if (difference) | ||
| return difference; | ||
| } | ||
| return undefined; | ||
| } | ||
| if (expected !== null && | ||
| actual !== null && | ||
| typeof expected === "object" && | ||
| typeof actual === "object") { | ||
| const keys = [...new Set([...Object.keys(expected), ...Object.keys(actual)])].sort(); | ||
| for (const key of keys) { | ||
| const difference = firstDifference(expected[key], actual[key], `${path}.${key}`); | ||
| if (difference) | ||
| return difference; | ||
| } | ||
| return undefined; | ||
| } | ||
| return { path, expected, actual }; | ||
| } | ||
| function preview(value) { | ||
| const serialized = JSON.stringify(value); | ||
| if (serialized === undefined) | ||
| return "<missing>"; | ||
| return serialized.length > 180 ? `${serialized.slice(0, 177)}...` : serialized; | ||
| } | ||
| /** | ||
| * Fail closed on any semantic drift from the reviewed descriptor snapshot. | ||
| */ | ||
| export function assertOpenAIReviewContract(actual, expected) { | ||
| if (actual.contractVersion !== OPENAI_REVIEW_CONTRACT_VERSION) { | ||
| throw new Error(`Unsupported OpenAI review contract version: ${actual.contractVersion}`); | ||
| } | ||
| if (actual.tools.length !== OPENAI_REVIEW_TOTAL_TOOL_COUNT) { | ||
| throw new Error(`OpenAI review surface must expose exactly ${OPENAI_REVIEW_TOTAL_TOOL_COUNT} tools; got ${actual.tools.length}`); | ||
| } | ||
| const names = actual.tools.map((tool) => tool.name); | ||
| if (new Set(names).size !== names.length) { | ||
| throw new Error("OpenAI review surface contains duplicate tool names"); | ||
| } | ||
| const discovery = new Set(OPENAI_REVIEW_DISCOVERY_TOOLS); | ||
| const businessNames = names.filter((name) => !discovery.has(name)); | ||
| if (businessNames.length !== OPENAI_REVIEW_BUSINESS_TOOL_COUNT) { | ||
| throw new Error(`OpenAI review surface must expose exactly ${OPENAI_REVIEW_BUSINESS_TOOL_COUNT} business tools; got ${businessNames.length}`); | ||
| } | ||
| const missingDiscovery = OPENAI_REVIEW_DISCOVERY_TOOLS.filter((name) => !names.includes(name)); | ||
| if (missingDiscovery.length > 0) { | ||
| throw new Error(`Missing discovery tools: ${missingDiscovery.join(", ")}`); | ||
| } | ||
| const leaks = businessNames.filter((name) => !OPENAI_REVIEWED_TOOL_ALLOWLIST.has(name)); | ||
| if (leaks.length > 0) { | ||
| throw new Error(`Non-reviewed tools leaked into OpenAI surface: ${leaks.join(", ")}`); | ||
| } | ||
| if (actual.prompts.length !== 0 || actual.resources.length !== 0) { | ||
| throw new Error(`OpenAI review surface must expose 0 prompts and 0 resources; got ${actual.prompts.length} prompts and ${actual.resources.length} resources`); | ||
| } | ||
| const sensitivePaths = [...schemaSensitivePaths(actual.tools)]; | ||
| if (sensitivePaths.length > 0) { | ||
| throw new Error(`Sensitive schema fields are forbidden in the OpenAI review surface: ${sensitivePaths.join(", ")}`); | ||
| } | ||
| const expectedSensitivePaths = [...schemaSensitivePaths(expected.tools)]; | ||
| if (expectedSensitivePaths.length > 0) { | ||
| throw new Error(`Frozen OpenAI review snapshot contains sensitive schema fields: ${expectedSensitivePaths.join(", ")}`); | ||
| } | ||
| const canonicalExpected = canonicalizeOpenAIReviewContract(expected); | ||
| const canonicalActual = canonicalizeOpenAIReviewContract(actual); | ||
| const difference = firstDifference(canonicalExpected, canonicalActual); | ||
| if (difference) { | ||
| throw new Error(`OpenAI review descriptor drift at ${difference.path}: expected ${preview(difference.expected)}, got ${preview(difference.actual)}`); | ||
| } | ||
| } | ||
| //# sourceMappingURL=openai-review-contract.js.map |
| /** | ||
| * OAuth contract inputs shared by the real Worker and the OpenAI descriptor | ||
| * freeze gate. Values in this module are public protocol metadata, not secrets. | ||
| */ | ||
| export declare const OPENAI_REVIEW_ORIGIN = "https://openai-mcp.frihet.io"; | ||
| /** | ||
| * Byte-compatible extraction of the options previously inlined in the Worker. | ||
| * index.ts spreads this object into the real OAuthProvider constructor. | ||
| */ | ||
| export declare const OAUTH_PROVIDER_REVIEW_OPTIONS: { | ||
| apiRoute: string; | ||
| authorizeEndpoint: string; | ||
| tokenEndpoint: string; | ||
| clientRegistrationEndpoint: string; | ||
| scopesSupported: string[]; | ||
| accessTokenTTL: number; | ||
| refreshTokenTTL: number; | ||
| allowPlainPKCE: false; | ||
| }; | ||
| /** | ||
| * Materialize the public metadata generated by workers-oauth-provider 0.3.0 | ||
| * from the same options used by the Worker. The dependency version is pinned | ||
| * separately in the canonical descriptor snapshot. | ||
| */ | ||
| export declare function buildOpenAIReviewOAuthContract(origin?: string): { | ||
| authorizationServer: { | ||
| issuer: string; | ||
| authorization_endpoint: string; | ||
| token_endpoint: string; | ||
| registration_endpoint: string; | ||
| scopes_supported: string[]; | ||
| response_types_supported: string[]; | ||
| response_modes_supported: string[]; | ||
| grant_types_supported: string[]; | ||
| token_endpoint_auth_methods_supported: string[]; | ||
| revocation_endpoint: string; | ||
| code_challenge_methods_supported: string[]; | ||
| client_id_metadata_document_supported: boolean; | ||
| }; | ||
| protectedResource: { | ||
| resource: string; | ||
| authorization_servers: string[]; | ||
| scopes_supported: string[]; | ||
| bearer_methods_supported: string[]; | ||
| }; | ||
| wwwAuthenticate: { | ||
| resourceMetadataUrl: string; | ||
| missingTokenHeader: string; | ||
| }; | ||
| }; | ||
| //# sourceMappingURL=openai-review-oauth.d.ts.map |
| /** | ||
| * OAuth contract inputs shared by the real Worker and the OpenAI descriptor | ||
| * freeze gate. Values in this module are public protocol metadata, not secrets. | ||
| */ | ||
| export const OPENAI_REVIEW_ORIGIN = "https://openai-mcp.frihet.io"; | ||
| /** | ||
| * Byte-compatible extraction of the options previously inlined in the Worker. | ||
| * index.ts spreads this object into the real OAuthProvider constructor. | ||
| */ | ||
| export const OAUTH_PROVIDER_REVIEW_OPTIONS = { | ||
| apiRoute: "/mcp", | ||
| authorizeEndpoint: "/authorize", | ||
| tokenEndpoint: "/token", | ||
| clientRegistrationEndpoint: "/register", | ||
| scopesSupported: ["read", "write"], | ||
| accessTokenTTL: 3600, | ||
| refreshTokenTTL: 2592000, | ||
| allowPlainPKCE: false, | ||
| }; | ||
| function endpoint(origin, path) { | ||
| return new URL(path, new URL(origin).origin).toString(); | ||
| } | ||
| /** | ||
| * Materialize the public metadata generated by workers-oauth-provider 0.3.0 | ||
| * from the same options used by the Worker. The dependency version is pinned | ||
| * separately in the canonical descriptor snapshot. | ||
| */ | ||
| export function buildOpenAIReviewOAuthContract(origin = OPENAI_REVIEW_ORIGIN) { | ||
| const normalizedOrigin = new URL(origin).origin; | ||
| const authorizationEndpoint = endpoint(normalizedOrigin, OAUTH_PROVIDER_REVIEW_OPTIONS.authorizeEndpoint); | ||
| const tokenEndpoint = endpoint(normalizedOrigin, OAUTH_PROVIDER_REVIEW_OPTIONS.tokenEndpoint); | ||
| const registrationEndpoint = endpoint(normalizedOrigin, OAUTH_PROVIDER_REVIEW_OPTIONS.clientRegistrationEndpoint); | ||
| const resourceMetadataUrl = endpoint(normalizedOrigin, "/.well-known/oauth-protected-resource"); | ||
| return { | ||
| authorizationServer: { | ||
| issuer: normalizedOrigin, | ||
| authorization_endpoint: authorizationEndpoint, | ||
| token_endpoint: tokenEndpoint, | ||
| registration_endpoint: registrationEndpoint, | ||
| scopes_supported: [...OAUTH_PROVIDER_REVIEW_OPTIONS.scopesSupported], | ||
| response_types_supported: ["code"], | ||
| response_modes_supported: ["query"], | ||
| grant_types_supported: ["authorization_code", "refresh_token"], | ||
| token_endpoint_auth_methods_supported: [ | ||
| "client_secret_basic", | ||
| "client_secret_post", | ||
| "none", | ||
| ], | ||
| revocation_endpoint: tokenEndpoint, | ||
| code_challenge_methods_supported: ["S256"], | ||
| client_id_metadata_document_supported: false, | ||
| }, | ||
| protectedResource: { | ||
| resource: normalizedOrigin, | ||
| authorization_servers: [normalizedOrigin], | ||
| scopes_supported: [...OAUTH_PROVIDER_REVIEW_OPTIONS.scopesSupported], | ||
| bearer_methods_supported: ["header"], | ||
| }, | ||
| wwwAuthenticate: { | ||
| resourceMetadataUrl, | ||
| missingTokenHeader: `Bearer realm="OAuth", resource_metadata="${resourceMetadataUrl}", ` + | ||
| 'error="invalid_token", error_description="Missing or invalid access token"', | ||
| }, | ||
| }; | ||
| } | ||
| //# sourceMappingURL=openai-review-oauth.js.map |
+32
-2
@@ -28,2 +28,30 @@ /** | ||
| /** | ||
| * Origin marker sent on every request so the backend can tell an MCP-driven | ||
| * create apart from a direct-API one. | ||
| * | ||
| * The backend classifier (`detectApiInvoiceSource`, Frihet-ERP | ||
| * functions/src/publicApi.ts) matches `mcp` / `frihet-mcp` / `@frihet/mcp` | ||
| * across three headers: `x-frihet-source`, `x-frihet-client` and `user-agent`. | ||
| * We send TWO markers because they survive different network paths: | ||
| * | ||
| * - `X-Frihet-Source: mcp` is the explicit, documented marker. It reaches the | ||
| * backend when the client talks to the Cloud Function directly (custom | ||
| * `baseUrl`, self-hosted proxy). | ||
| * - `User-Agent` is what actually reaches the backend on the DEFAULT baseUrl: | ||
| * `api.frihet.io` is fronted by workers/api-proxy/worker.js, whose | ||
| * `ALLOWED_REQUEST_HEADERS` allowlist forwards `user-agent` but drops | ||
| * `x-frihet-source` (verified live: /agents.json answers 200 at | ||
| * api.frihet.io and 401 at the Cloud Function, so the Worker is in path). | ||
| * Without the UA the source header is a phantom — set, then stripped at the | ||
| * edge, and every MCP invoice still lands as `source: 'api'`. | ||
| * | ||
| * Deliberately carries no version: the version lives in package.json only | ||
| * (see src/index.ts PKG_VERSION) and a second hardcoded copy is exactly the | ||
| * drift `scripts/audit-mcp-refs.mjs` exists to catch. | ||
| * | ||
| * Pinned on the wire by src/__tests__/source-header-contract.test.ts. | ||
| */ | ||
| const SOURCE_MARKER = "mcp"; | ||
| const SOURCE_USER_AGENT = "frihet-mcp-server"; | ||
| /** | ||
| * Fresh idempotency key, always a syntactically valid UUID v4. | ||
@@ -106,2 +134,4 @@ * | ||
| Accept: "application/json", | ||
| "X-Frihet-Source": SOURCE_MARKER, | ||
| "User-Agent": SOURCE_USER_AGENT, | ||
| }; | ||
@@ -668,6 +698,6 @@ if (resolvedIdempotencyKey) { | ||
| async getBusinessContext() { | ||
| return this.request("GET", "/context"); | ||
| return this.requestUnwrapped("GET", "/context"); | ||
| } | ||
| async getMonthlySummary(month) { | ||
| return this.request("GET", "/monthly", undefined, { | ||
| return this.requestUnwrapped("GET", "/monthly", undefined, { | ||
| month, | ||
@@ -674,0 +704,0 @@ }); |
+38
-12
@@ -260,7 +260,32 @@ /** | ||
| return { | ||
| businessName: "Demo Studio SL", | ||
| fiscalRegime: "autonomo", | ||
| currency: "EUR", | ||
| country: "ES", | ||
| metrics: { openInvoices: 4, overdueInvoices: 1, totalClients: demoClients.length, totalProducts: demoProducts.length }, | ||
| business: { | ||
| name: "Demo Studio SL", | ||
| fiscalZone: "IVA", | ||
| currency: "EUR", | ||
| language: "es", | ||
| country: "ES", | ||
| }, | ||
| defaults: { taxRate: 21, irpfRate: 15, dueDays: 30, currency: "EUR" }, | ||
| plan: { | ||
| name: "free", | ||
| invoices: { used: 5, limit: 999 }, | ||
| expenses: { used: demoExpenses.length, limit: 5 }, | ||
| aiMessages: { used: 2, limit: 30 }, | ||
| }, | ||
| series: [{ id: "default", prefix: "F", current: 5, year: 2026 }], | ||
| recentActivity: { | ||
| lastInvoice: { number: "F-2026-005", date: "2026-07-18", client: "Acme Studio" }, | ||
| lastExpense: { date: "2026-07-17", vendor: "Demo Supplies", amount: 405.6 }, | ||
| overdueCount: 1, | ||
| overdueAmount: 640, | ||
| unpaidCount: 2, | ||
| }, | ||
| topClients: [{ name: "Acme Studio", totalRevenue: 2200, invoiceCount: 2 }], | ||
| currentMonth: { | ||
| revenue: 3960.4, | ||
| expenses: 405.6, | ||
| profit: 3554.8, | ||
| invoiceCount: 5, | ||
| expenseCount: demoExpenses.length, | ||
| }, | ||
| ...READ_STAMP, | ||
@@ -271,9 +296,10 @@ }; | ||
| return { | ||
| month: month ?? "2026-07", | ||
| revenue: 3960.4, | ||
| expenses: 405.6, | ||
| net: 3554.8, | ||
| invoiceCount: 5, | ||
| expenseCount: demoExpenses.length, | ||
| currency: "EUR", | ||
| period: month ?? "2026-07", | ||
| revenue: { total: 3960.4, taxBase: 3273.06, tax: 687.34, irpf: 0 }, | ||
| expenses: { total: 405.6, deductible: 405.6, tax: 70.4 }, | ||
| profit: { gross: 3554.8, net: 2937.86 }, | ||
| invoices: { created: 5, sent: 1, paid: 3, overdue: 1 }, | ||
| topClients: [{ name: "Acme Studio", totalRevenue: 2200, invoiceCount: 2 }], | ||
| byCategory: { supplies: 405.6 }, | ||
| taxLiability: { vatPayable: 616.94, irpfRetained: 0, estimatedModel303: 616.94 }, | ||
| ...READ_STAMP, | ||
@@ -280,0 +306,0 @@ }; |
@@ -66,4 +66,12 @@ /** | ||
| export declare const OPENAI_REVIEWED_TOOL_ALLOWLIST: ReadonlySet<string>; | ||
| /** | ||
| * Apply the exact profile composition used by the ChatGPT review Worker. | ||
| * | ||
| * Keep this helper as the single source for the security-sensitive ordering: | ||
| * grouped exposure first (with the frozen allow-list), OpenAI profile second. | ||
| * Tool registration happens afterwards through registerAllTools(). | ||
| */ | ||
| export declare function applyOpenAIReviewProfiles(server: any): void; | ||
| /** Number of resources excluded in OpenAI mode (for logging). */ | ||
| export declare const OPENAI_EXCLUDED_RESOURCE_COUNT: number; | ||
| //# sourceMappingURL=openai-profile.d.ts.map |
+53
-17
@@ -23,5 +23,6 @@ /** | ||
| */ | ||
| import { z } from "zod"; | ||
| import { z } from "zod/v4"; | ||
| import { MCP_RESOURCE_COUNT } from "./resources/register-all.js"; | ||
| import { SENSITIVE_FIELD_NAMES, deepRedact, redactText } from "./redaction.js"; | ||
| import { applyToolExposureProfile } from "./tool-exposure.js"; | ||
| const PROFILE = { | ||
@@ -168,2 +169,13 @@ // -- OpenAI-reviewed core surface ---------------------------------------- | ||
| stripInputFields: { | ||
| // Projection is useful to direct MCP clients, but unnecessary in the | ||
| // reviewed ChatGPT surface. OpenAI's scanner treats a comma-delimited | ||
| // free-form string as ambiguous (array vs JSON vs CSV), so omit it and | ||
| // return the full, redacted record shape instead. | ||
| list_invoices: ["fields"], | ||
| search_invoices: ["fields"], | ||
| list_expenses: ["fields"], | ||
| list_clients: ["fields"], | ||
| list_products: ["fields"], | ||
| list_quotes: ["fields"], | ||
| list_vendors: ["fields"], | ||
| create_client: ["taxId"], // NIF/CIF/VAT — government-issued identifier | ||
@@ -207,7 +219,3 @@ update_client: ["taxId"], | ||
| function stripSensitiveOutputSchema(schema, fields) { | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const def = schema?._def; | ||
| const typeName = def?.typeName; | ||
| if (typeName === "ZodObject") { | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| if (schema instanceof z.ZodObject) { | ||
| const shape = schema.shape; | ||
@@ -228,9 +236,23 @@ const newShape = {}; | ||
| return schema; | ||
| let rebuilt = z.object(newShape); | ||
| if (typeof def.description === "string") | ||
| rebuilt = rebuilt.describe(def.description); | ||
| const base = z.object(newShape); | ||
| // Zod v4 stores passthrough/strict behavior in `catchall`. Preserve it so | ||
| // redacting one declared field never changes validation of unrelated API | ||
| // fields. The previous implementation inspected Zod v3's `_def.typeName`, | ||
| // which is absent in v4 and silently returned the original schema. | ||
| const catchall = schema._def.catchall; | ||
| let rebuilt; | ||
| if (catchall instanceof z.ZodUnknown) | ||
| rebuilt = base.passthrough(); | ||
| else if (catchall instanceof z.ZodNever) | ||
| rebuilt = base.strict(); | ||
| else if (catchall) | ||
| rebuilt = base.catchall(catchall); | ||
| else | ||
| rebuilt = base; | ||
| if (typeof schema.description === "string") | ||
| rebuilt = rebuilt.describe(schema.description); | ||
| return rebuilt; | ||
| } | ||
| if (typeName === "ZodArray") { | ||
| const inner = def.type; | ||
| if (schema instanceof z.ZodArray) { | ||
| const inner = schema.element; | ||
| const stripped = stripSensitiveOutputSchema(inner, fields); | ||
@@ -240,13 +262,13 @@ if (stripped === inner) | ||
| let rebuilt = z.array(stripped); | ||
| if (typeof def.description === "string") | ||
| rebuilt = rebuilt.describe(def.description); | ||
| if (typeof schema.description === "string") | ||
| rebuilt = rebuilt.describe(schema.description); | ||
| return rebuilt; | ||
| } | ||
| if (typeName === "ZodOptional") { | ||
| const inner = def.innerType; | ||
| if (schema instanceof z.ZodOptional) { | ||
| const inner = schema.unwrap(); | ||
| const stripped = stripSensitiveOutputSchema(inner, fields); | ||
| return stripped === inner ? schema : z.optional(stripped); | ||
| } | ||
| if (typeName === "ZodNullable") { | ||
| const inner = def.innerType; | ||
| if (schema instanceof z.ZodNullable) { | ||
| const inner = schema.unwrap(); | ||
| const stripped = stripSensitiveOutputSchema(inner, fields); | ||
@@ -440,2 +462,16 @@ return stripped === inner ? schema : z.nullable(stripped); | ||
| export const OPENAI_REVIEWED_TOOL_ALLOWLIST = PROFILE.includeTools; | ||
| /** | ||
| * Apply the exact profile composition used by the ChatGPT review Worker. | ||
| * | ||
| * Keep this helper as the single source for the security-sensitive ordering: | ||
| * grouped exposure first (with the frozen allow-list), OpenAI profile second. | ||
| * Tool registration happens afterwards through registerAllTools(). | ||
| */ | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| export function applyOpenAIReviewProfiles(server) { | ||
| applyToolExposureProfile(server, { | ||
| allowlist: OPENAI_REVIEWED_TOOL_ALLOWLIST, | ||
| }); | ||
| applyOpenAIProfile(server); | ||
| } | ||
| /** Number of resources excluded in OpenAI mode (for logging). */ | ||
@@ -442,0 +478,0 @@ export const OPENAI_EXCLUDED_RESOURCE_COUNT = PROFILE.excludeResources |
@@ -13,3 +13,94 @@ /** | ||
| import { z } from "zod/v4"; | ||
| import { withToolLogging, formatRecord, getContent, mutateContent, openObjectOutput, READ_ONLY_ANNOTATIONS, CREATE_ANNOTATIONS, } from "./shared.js"; | ||
| import { withToolLogging, formatRecord, getContent, mutateContent, invoiceItemOutput, openObjectOutput, READ_ONLY_ANNOTATIONS, CREATE_ANNOTATIONS, } from "./shared.js"; | ||
| const usageLimitOutput = z.union([z.number(), z.literal("unlimited")]); | ||
| const businessContextOutput = z.object({ | ||
| business: z.object({ | ||
| name: z.string(), | ||
| taxId: z.string().optional(), | ||
| fiscalZone: z.string(), | ||
| currency: z.string(), | ||
| language: z.string(), | ||
| country: z.string(), | ||
| }).passthrough(), | ||
| defaults: z.object({ | ||
| taxRate: z.number(), | ||
| irpfRate: z.number(), | ||
| dueDays: z.number(), | ||
| currency: z.string(), | ||
| }).passthrough(), | ||
| plan: z.object({ | ||
| name: z.string(), | ||
| invoices: z.object({ used: z.number(), limit: usageLimitOutput }), | ||
| expenses: z.object({ used: z.number(), limit: usageLimitOutput }), | ||
| aiMessages: z.object({ used: z.number(), limit: usageLimitOutput }), | ||
| }).passthrough(), | ||
| series: z.array(z.object({ | ||
| id: z.string().optional(), | ||
| prefix: z.string().optional(), | ||
| current: z.number().optional(), | ||
| year: z.number().optional(), | ||
| }).passthrough()), | ||
| recentActivity: z.object({ | ||
| lastInvoice: z.object({ | ||
| number: z.string().optional(), | ||
| date: z.string().optional(), | ||
| client: z.string().optional(), | ||
| }).passthrough().nullable(), | ||
| lastExpense: z.object({ | ||
| date: z.string().optional(), | ||
| vendor: z.string().optional(), | ||
| amount: z.number(), | ||
| }).passthrough().nullable(), | ||
| overdueCount: z.number(), | ||
| overdueAmount: z.number(), | ||
| unpaidCount: z.number(), | ||
| }).passthrough(), | ||
| topClients: z.array(z.object({ | ||
| name: z.string(), | ||
| totalRevenue: z.number(), | ||
| invoiceCount: z.number(), | ||
| }).passthrough()), | ||
| currentMonth: z.object({ | ||
| revenue: z.number(), | ||
| expenses: z.number(), | ||
| profit: z.number(), | ||
| invoiceCount: z.number(), | ||
| expenseCount: z.number(), | ||
| }).passthrough(), | ||
| }).passthrough().describe("Business context snapshot: workspace profile, fiscal setup, plan usage, recent activity and current-month performance"); | ||
| const monthlySummaryOutput = z.object({ | ||
| period: z.string(), | ||
| revenue: z.object({ | ||
| total: z.number(), | ||
| taxBase: z.number(), | ||
| tax: z.number(), | ||
| irpf: z.number(), | ||
| }).passthrough(), | ||
| expenses: z.object({ | ||
| total: z.number(), | ||
| deductible: z.number(), | ||
| tax: z.number(), | ||
| }).passthrough(), | ||
| profit: z.object({ | ||
| gross: z.number(), | ||
| net: z.number(), | ||
| }).passthrough(), | ||
| invoices: z.object({ | ||
| created: z.number(), | ||
| sent: z.number(), | ||
| paid: z.number(), | ||
| overdue: z.number(), | ||
| }).passthrough(), | ||
| topClients: z.array(z.object({ | ||
| name: z.string(), | ||
| totalRevenue: z.number(), | ||
| invoiceCount: z.number(), | ||
| }).passthrough()), | ||
| byCategory: z.record(z.string(), z.number()), | ||
| taxLiability: z.object({ | ||
| vatPayable: z.number(), | ||
| irpfRetained: z.number(), | ||
| estimatedModel303: z.number(), | ||
| }).passthrough(), | ||
| }).passthrough().describe("Monthly financial summary with revenue, expenses, profit, invoice status, top clients and estimated tax liability"); | ||
| export function registerIntelligenceTools(server, client) { | ||
@@ -26,3 +117,3 @@ // -- get_business_context -- | ||
| inputSchema: {}, | ||
| outputSchema: openObjectOutput("Business context snapshot: workspace profile, fiscal setup, recent activity / Contexto de negocio: perfil, configuración fiscal y actividad reciente"), | ||
| outputSchema: businessContextOutput, | ||
| }, async () => withToolLogging("get_business_context", async () => { | ||
@@ -51,3 +142,3 @@ const result = await client.getBusinessContext(); | ||
| }, | ||
| outputSchema: openObjectOutput("Monthly P&L summary: revenue, expenses, profit, tax liability, invoice stats / Resumen mensual: ingresos, gastos, beneficio, impuestos"), | ||
| outputSchema: monthlySummaryOutput, | ||
| }, async ({ month }) => withToolLogging("get_monthly_summary", async () => { | ||
@@ -107,3 +198,3 @@ const result = await client.getMonthlySummary(month); | ||
| }, | ||
| outputSchema: openObjectOutput("The newly created draft invoice, cloned from the original / La nueva factura borrador, clonada de la original"), | ||
| outputSchema: invoiceItemOutput.describe("The newly created draft invoice, cloned from the original / La nueva factura borrador, clonada de la original"), | ||
| }, async ({ id, newIssueDate, newDueDate }) => withToolLogging("duplicate_invoice", async () => { | ||
@@ -110,0 +201,0 @@ // 1. Fetch the original invoice (returns the FULL raw stored document — |
+4
-2
| { | ||
| "name": "@frihet/mcp-server", | ||
| "version": "1.16.5", | ||
| "version": "1.16.6", | ||
| "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.", | ||
@@ -12,3 +12,5 @@ "type": "module", | ||
| "build": "tsc", | ||
| "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", | ||
| "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", | ||
| "test:openai-review-descriptor": "npm run build && node --test dist/__tests__/openai-review-descriptor.test.js", | ||
| "gate:openai-review-descriptor": "npm run build && node scripts/check-openai-review-descriptor.mjs", | ||
| "start": "node dist/index.js", | ||
@@ -15,0 +17,0 @@ "postinstall": "node scripts/postinstall.js || true", |
+1
-1
@@ -50,3 +50,3 @@ <p align="center"> | ||
| > **Tool count:** the package (1.16.5) ships all 157 tools, same as the remote endpoint (`mcp.frihet.io`). | ||
| > **Tool count:** the package (1.16.6) ships all 157 tools, same as the remote endpoint (`mcp.frihet.io`). | ||
@@ -53,0 +53,0 @@ --- |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
847105
3.08%107
3.88%15897
3.85%