@frihet/mcp-server
Advanced tools
@@ -107,3 +107,3 @@ /** | ||
| issueDate?: string; | ||
| }): Promise<Record<string, unknown>>; | ||
| }, idempotencyKey?: string): Promise<Record<string, unknown>>; | ||
| applyLateFee(invoiceId: string, data?: { | ||
@@ -110,0 +110,0 @@ amount?: number; |
+13
-1
@@ -156,2 +156,14 @@ /** | ||
| getInvoiceEInvoice(invoiceId: string): Promise<any>; | ||
| /** | ||
| * `POST /v1/invoices/:id/credit-note` — creates a rectificativa DRAFT. | ||
| * | ||
| * The backend REQUIRES an `Idempotency-Key` header (`400 | ||
| * IDEMPOTENCY_KEY_REQUIRED` without it). `request` mints one for every | ||
| * mutation, so passing `idempotencyKey` is optional: supply it to make a | ||
| * caller-driven retry replay the stored 201 instead of creating a second | ||
| * draft. The backend marks that replay with `X-Idempotent-Replayed: true`, | ||
| * but this client reads no response headers, so the replayed 201 and the | ||
| * original are indistinguishable to the caller — both are the same draft, | ||
| * which is the property that matters here. | ||
| */ | ||
| createCreditNote(invoiceId: string, data: { | ||
@@ -162,3 +174,3 @@ reason: string; | ||
| issueDate?: string; | ||
| }): Promise<Record<string, unknown>>; | ||
| }, idempotencyKey?: string): Promise<Record<string, unknown>>; | ||
| applyLateFee(invoiceId: string, data?: { | ||
@@ -165,0 +177,0 @@ amount?: number; |
+70
-6
@@ -19,2 +19,44 @@ /** | ||
| const REQUEST_TIMEOUT_MS = 30000; | ||
| /** | ||
| * HTTP methods for which the backend treats the request as a mutation and | ||
| * therefore accepts (and for some endpoints REQUIRES) an `Idempotency-Key` | ||
| * header. `POST /v1/invoices/:id/credit-note` rejects a keyless request with | ||
| * `400 IDEMPOTENCY_KEY_REQUIRED`, so a client that never sends one fails 100% | ||
| * of the time — see src/__tests__/idempotency-key-contract.test.ts. | ||
| */ | ||
| const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); | ||
| /** | ||
| * Fresh idempotency key, always a syntactically valid UUID v4. | ||
| * | ||
| * `crypto.randomUUID` is a global on the Cloudflare Workers runtime and on | ||
| * Node >= 19. On Node 18 — our declared `engines` floor — `globalThis.crypto` | ||
| * is behind `--experimental-global-webcrypto`, so the fallback is a REAL code | ||
| * path there, not a theoretical one. It therefore has to produce a UUID and | ||
| * not an ad-hoc string: the backend documents "UUID v4 recommended", and | ||
| * src/__tests__/idempotency-key-contract.test.ts asserts the shape. | ||
| */ | ||
| function newIdempotencyKey() { | ||
| const c = globalThis.crypto; | ||
| if (typeof c?.randomUUID === "function") { | ||
| return c.randomUUID(); | ||
| } | ||
| // RFC 4122 v4 layout from Math.random. Weaker entropy than the CSPRNG, but | ||
| // the key only has to be unique per caller, never unguessable. | ||
| return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (ch) => { | ||
| const r = (Math.random() * 16) | 0; | ||
| const v = ch === "x" ? r : (r & 0x3) | 0x8; | ||
| return v.toString(16); | ||
| }); | ||
| } | ||
| /** | ||
| * A caller-supplied key counts only if it carries a value. An empty or | ||
| * whitespace-only string is what an LLM client emits for "I have nothing to | ||
| * put here" on an optional string param — treating it as PRESENT would leave | ||
| * the request keyless and reproduce the very `400 IDEMPOTENCY_KEY_REQUIRED` | ||
| * this client exists to prevent. | ||
| */ | ||
| function normalizeIdempotencyKey(key) { | ||
| const trimmed = key?.trim(); | ||
| return trimmed ? trimmed : undefined; | ||
| } | ||
| export class FrihetApiError extends Error { | ||
@@ -44,4 +86,11 @@ statusCode; | ||
| // ------------------------------------------------------------------ | ||
| async request(method, path, body, query, retryCount = 0) { | ||
| async request(method, path, body, query, retryCount = 0, idempotencyKey) { | ||
| const url = new URL(`${this.baseUrl}${path}`); | ||
| // Resolved once, at the top of the call chain: a caller-supplied key wins, | ||
| // otherwise mutations get a freshly minted one. The resolved value is then | ||
| // threaded through the 429 recursion below so a retry replays the SAME key | ||
| // — retrying with a new key is exactly the duplicate the key exists to | ||
| // prevent (a retried credit-note would create a second draft). | ||
| const resolvedIdempotencyKey = normalizeIdempotencyKey(idempotencyKey) ?? | ||
| (MUTATING_METHODS.has(method) ? newIdempotencyKey() : undefined); | ||
| if (query) { | ||
@@ -59,2 +108,5 @@ for (const [key, value] of Object.entries(query)) { | ||
| }; | ||
| if (resolvedIdempotencyKey) { | ||
| headers["Idempotency-Key"] = resolvedIdempotencyKey; | ||
| } | ||
| const controller = new AbortController(); | ||
@@ -97,3 +149,3 @@ const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); | ||
| await this.sleep(delayMs); | ||
| return this.request(method, path, body, query, retryCount + 1); | ||
| return this.request(method, path, body, query, retryCount + 1, resolvedIdempotencyKey); | ||
| } | ||
@@ -157,4 +209,4 @@ // Error responses | ||
| */ | ||
| async requestUnwrapped(method, path, body, query) { | ||
| const raw = await this.request(method, path, body, query); | ||
| async requestUnwrapped(method, path, body, query, idempotencyKey) { | ||
| const raw = await this.request(method, path, body, query, 0, idempotencyKey); | ||
| if (raw !== null && | ||
@@ -366,4 +418,16 @@ typeof raw === "object" && | ||
| } | ||
| async createCreditNote(invoiceId, data) { | ||
| return this.requestUnwrapped("POST", `/invoices/${encodeURIComponent(invoiceId)}/credit-note`, data); | ||
| /** | ||
| * `POST /v1/invoices/:id/credit-note` — creates a rectificativa DRAFT. | ||
| * | ||
| * The backend REQUIRES an `Idempotency-Key` header (`400 | ||
| * IDEMPOTENCY_KEY_REQUIRED` without it). `request` mints one for every | ||
| * mutation, so passing `idempotencyKey` is optional: supply it to make a | ||
| * caller-driven retry replay the stored 201 instead of creating a second | ||
| * draft. The backend marks that replay with `X-Idempotent-Replayed: true`, | ||
| * but this client reads no response headers, so the replayed 201 and the | ||
| * original are indistinguishable to the caller — both are the same draft, | ||
| * which is the property that matters here. | ||
| */ | ||
| async createCreditNote(invoiceId, data, idempotencyKey) { | ||
| return this.requestUnwrapped("POST", `/invoices/${encodeURIComponent(invoiceId)}/credit-note`, data, undefined, idempotencyKey); | ||
| } | ||
@@ -370,0 +434,0 @@ async applyLateFee(invoiceId, data) { |
@@ -85,3 +85,3 @@ /** | ||
| issueDate?: string; | ||
| }): Promise<Rec>; | ||
| }, _idempotencyKey?: string): Promise<Rec>; | ||
| applyLateFee(invoiceId: string, data?: { | ||
@@ -88,0 +88,0 @@ amount?: number; |
+23
-3
@@ -152,3 +152,20 @@ /** | ||
| } | ||
| async createCreditNote(invoiceId, data) { | ||
| // Mirrors the live 201 body: a DRAFT rectificativa, always by differences | ||
| // (`rectificationMethod: "I"`), R-type derived from `reason` server-side | ||
| // (`error` → R1, everything else → R4 — R2/R3/R5 are unreachable via the API). | ||
| async createCreditNote(invoiceId, data, _idempotencyKey) { | ||
| // Shape rules taken from the live 201 (functions/src/publicApi.ts) rather | ||
| // than invented: | ||
| // - documentNumber is `CN-<original document number>` and NEVER encodes | ||
| // the R-type (creditNoteService.ts: `CN-${originalDocNumber}`). An | ||
| // earlier fixture emitted `R1-DEMO-001`, teaching an agent to parse a | ||
| // field that carries no R-type live. | ||
| // - fullCredit is hardcoded `true` in the live body: `false` never | ||
| // reaches a 201, it is refused with 400 PARTIAL_CREDIT_NOT_IMPLEMENTED. | ||
| // Echoing the input would contradict this tool's own description. | ||
| // Documented divergence: demo mode never throws (the seam's contract), so | ||
| // `fullCredit: false` returns the same simulated draft instead of the live | ||
| // 400. Pinned in src/__tests__/demo-mode.test.ts. | ||
| const original = findOrStub(demoInvoices, invoiceId); | ||
| const originalNumber = typeof original.documentNumber === "string" ? original.documentNumber : "DEMO-001"; | ||
| return { | ||
@@ -158,6 +175,9 @@ success: true, | ||
| id: demoId("demo_cn"), | ||
| documentNumber: "R4-DEMO-001", | ||
| documentNumber: `CN-${originalNumber}`, | ||
| originalInvoiceId: invoiceId, | ||
| reason: data.reason, | ||
| fullCredit: data.fullCredit ?? true, | ||
| fullCredit: true, | ||
| status: "draft", | ||
| rectificationMethod: "I", | ||
| totalCredited: 1210.0, | ||
| }, | ||
@@ -164,0 +184,0 @@ ...FISCAL_STAMP, |
+24
-11
@@ -389,9 +389,10 @@ /** | ||
| title: "Create Credit Note", | ||
| description: "Create a credit note (factura rectificativa) for an existing invoice. " + | ||
| "This reverses all or part of an invoice for compliance. " + | ||
| "Spanish market: generates VeriFactu-compliant R1-R5 rectificativa. " + | ||
| "Other markets: standard credit note with negative amounts. " + | ||
| "/ Crea una factura rectificativa para una factura existente. " + | ||
| "Mercado espanol: genera rectificativa R1-R5 conforme a VeriFactu. " + | ||
| "Otros mercados: nota de credito estandar con importes negativos.", | ||
| description: "Create a full credit note (factura rectificativa) for an existing invoice, as a DRAFT. " + | ||
| "It does NOT issue: the draft carries no fiscal number, no hash and is not sent to VeriFactu — " + | ||
| "issue it from the app when you are ready. Always rectifies by differences (TipoRectificativa = I). " + | ||
| "Spanish market: R-type is derived from `reason` (error -> R1, anything else -> R4). " + | ||
| "Partial credits are not supported. Requires the `pro` plan. " + | ||
| "/ Crea una factura rectificativa completa como BORRADOR. No emite: sin numero fiscal, sin hash, " + | ||
| "sin envio a VeriFactu. Siempre rectifica por diferencias (tipo I). El tipo R se deriva de `reason` " + | ||
| "(error -> R1, resto -> R4). No admite abonos parciales. Requiere plan `pro`.", | ||
| annotations: CREATE_ANNOTATIONS, | ||
@@ -413,4 +414,7 @@ inputSchema: { | ||
| .optional() | ||
| .describe("true = full credit (tipo S, sustitucion), false = partial (tipo I, diferencias). Default: true " + | ||
| "/ true = abono total (tipo S), false = parcial (tipo I). Por defecto: true"), | ||
| .describe("Must be true (the default). `false` is rejected with 400 PARTIAL_CREDIT_NOT_IMPLEMENTED — " + | ||
| "the API has no line-level or partial credit. It does NOT select the rectification method: " + | ||
| "that is always I (por diferencias). " + | ||
| "/ Debe ser true (por defecto). `false` devuelve 400 PARTIAL_CREDIT_NOT_IMPLEMENTED. " + | ||
| "No elige el metodo de rectificacion: siempre es I (por diferencias)."), | ||
| issueDate: z | ||
@@ -420,5 +424,14 @@ .string() | ||
| .describe("ISO date for the credit note (YYYY-MM-DD). Defaults to today. / Fecha de emision (YYYY-MM-DD). Por defecto hoy."), | ||
| idempotencyKey: z | ||
| .string() | ||
| .max(64) | ||
| .optional() | ||
| .describe("Optional idempotency key, max 64 chars. One is generated per call when omitted or blank; " + | ||
| "pass your own to make a retry replay the stored result instead of creating a second draft. " + | ||
| "Reusing a key with a different body returns 409 IDEMPOTENCY_KEY_REUSED — reconcile, do not " + | ||
| "retry with a new key. " + | ||
| "/ Clave de idempotencia opcional (max 64). Se genera una por llamada si se omite o va vacia."), | ||
| }, | ||
| outputSchema: creditNoteResultOutput, | ||
| }, async ({ invoiceId, reason, reasonDescription, fullCredit, issueDate }) => withToolLogging("create_credit_note", async () => { | ||
| }, async ({ invoiceId, reason, reasonDescription, fullCredit, issueDate, idempotencyKey }) => withToolLogging("create_credit_note", async () => { | ||
| const result = await client.createCreditNote(invoiceId, { | ||
@@ -429,3 +442,3 @@ reason, | ||
| issueDate, | ||
| }); | ||
| }, idempotencyKey); | ||
| const hints = enrichResponse("invoices", "create", result); | ||
@@ -432,0 +445,0 @@ return { |
+6
-3
| { | ||
| "name": "@frihet/mcp-server", | ||
| "version": "1.16.4", | ||
| "version": "1.16.5", | ||
| "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,3 @@ "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", | ||
| "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", | ||
| "start": "node dist/index.js", | ||
@@ -20,3 +20,6 @@ "postinstall": "node scripts/postinstall.js || true", | ||
| "gate:no-leak": "bash scripts/no-public-leak.sh", | ||
| "gate:no-legacy-region": "bash scripts/no-legacy-region.sh" | ||
| "gate:no-legacy-region": "bash scripts/no-legacy-region.sh", | ||
| "sync:openapi": "node scripts/sync-openapi.mjs", | ||
| "gate:openapi-fresh": "node scripts/sync-openapi.mjs --check", | ||
| "gate:openapi-fresh:live": "node scripts/sync-openapi.mjs --check --live" | ||
| }, | ||
@@ -23,0 +26,0 @@ "keywords": [ |
+1
-1
@@ -50,3 +50,3 @@ <p align="center"> | ||
| > **Tool count:** npm `latest` (1.16.4) ships all 157 tools, same as the remote endpoint (`mcp.frihet.io`). | ||
| > **Tool count:** the package (1.16.5) 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.
821817
0.86%15308
0.72%