From f592355f2ffdd3fac8a8b848d4aa550854da4727 Mon Sep 17 00:00:00 2001 From: Luke Cichocki <999622+luke-cf@users.noreply.github.com> Date: Wed, 27 May 2026 11:33:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20saveDir=20option=20for=20sync=5Fin?= =?UTF-8?q?voices=20tool=20=E2=80=94=20writes=20FA=20XML=20to=20disk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in `saveDir` parameter to the sync_invoices MCP tool. When set, the tool fetches the full FA(2)/FA(3) XML for each invoice (via the existing fetchInvoiceXml HTTP path) and writes one file per invoice to ${saveDir}/${ksefReferenceNumber}.xml. mkdir -p semantics, absolute or relative paths accepted (relative resolved against cwd of the MCP host). Plumbing for the new option: shared FetchInvoicesOpts.includeXml: documents the flag + that rawXml lands on Invoice.rawXml when set (was already handled by adapter). core KsefClient.fetchInvoices gains optional includeXml. KsefAdapter forwards it to the underlying client. http KsefHttpClient.fetchInvoices forwards includeXml. Adds fetchInvoiceXml(token, ksefNumber) public method backed by the existing http/invoices.ts helper so non-MCP consumers can also grab single XMLs. Default behavior unchanged — fetchInvoices without includeXml still returns metadata only. --- packages/core/src/ksef/ksef.adapter.ts | 1 + packages/core/src/ksef/types.ts | 1 + packages/http/src/client.ts | 22 +++++++++++++- packages/mcp/src/tools/sync-invoices.ts | 35 ++++++++++++++++++++++- packages/shared/src/types/ksef-adapter.ts | 6 ++++ 5 files changed, 63 insertions(+), 2 deletions(-) diff --git a/packages/core/src/ksef/ksef.adapter.ts b/packages/core/src/ksef/ksef.adapter.ts index fbd5271..3f37638 100644 --- a/packages/core/src/ksef/ksef.adapter.ts +++ b/packages/core/src/ksef/ksef.adapter.ts @@ -52,6 +52,7 @@ export class KsefAdapterImpl implements KsefAdapter { subjectType, pageSize: opts.pageSize, pageOffset: opts.pageOffset, + includeXml: opts.includeXml, }) return result.invoices.map((raw) => ({ diff --git a/packages/core/src/ksef/types.ts b/packages/core/src/ksef/types.ts index 1e046f4..0130d67 100644 --- a/packages/core/src/ksef/types.ts +++ b/packages/core/src/ksef/types.ts @@ -38,6 +38,7 @@ export interface KsefClient { subjectType?: KsefSubjectType pageSize?: number pageOffset?: number + includeXml?: boolean }): Promise<{ invoices: KsefRawInvoice[]; total: number }> sendInvoice(params: { token: string diff --git a/packages/http/src/client.ts b/packages/http/src/client.ts index 757114f..ccc2e10 100644 --- a/packages/http/src/client.ts +++ b/packages/http/src/client.ts @@ -15,7 +15,7 @@ import { shouldRefresh, type ActiveSession, } from './session.js' -import { fetchInvoices as fetchInvoicesHttp } from './invoices.js' +import { fetchInvoices as fetchInvoicesHttp, fetchInvoiceXml as fetchInvoiceXmlHttp } from './invoices.js' import { fetchUpoXml } from './upo.js' import { KsefApiError } from './errors.js' import { fetchKsefTokenEncryptionKey } from './public-key.js' @@ -129,6 +129,7 @@ export class KsefHttpClient implements KsefClient { subjectType?: 'Subject1' | 'Subject2' | 'Subject3' pageSize?: number pageOffset?: number + includeXml?: boolean }): Promise<{ invoices: KsefRawInvoice[]; total: number }> { let session = decodeSessionToken(params.token) if (!session) { @@ -151,12 +152,31 @@ export class KsefHttpClient implements KsefClient { pageSize: params.pageSize, pageOffset: params.pageOffset, subjectType: params.subjectType ?? 'Subject2', + includeXml: params.includeXml, }), this.retryOpts, ) return result } + async fetchInvoiceXml(params: { token: string; ksefNumber: string }): Promise { + let session = decodeSessionToken(params.token) + if (!session) { + throw new Error('KsefHttpClient.fetchInvoiceXml: invalid session token') + } + if (shouldRefresh(session)) { + session = await withHttpRetry( + () => refreshAccessToken(this.http, session as ActiveSession), + this.retryOpts, + ) + } + return withHttpRetry( + () => + fetchInvoiceXmlHttp(this.http, (session as ActiveSession).accessToken, params.ksefNumber), + this.retryOpts, + ) + } + async sendInvoice(): Promise<{ ksefReferenceNumber: string; timestamp: string }> { throw new Error( 'KsefHttpClient.sendInvoice: not implemented in HTTP client MVP — see http_plan.md §H05.5', diff --git a/packages/mcp/src/tools/sync-invoices.ts b/packages/mcp/src/tools/sync-invoices.ts index 8f393c0..f529e6a 100644 --- a/packages/mcp/src/tools/sync-invoices.ts +++ b/packages/mcp/src/tools/sync-invoices.ts @@ -1,4 +1,6 @@ import type { Ksefnik } from '@ksefnik/core' +import { writeFile, mkdir } from 'node:fs/promises' +import { resolve, isAbsolute, join } from 'node:path' import { z } from 'zod' export const syncInvoicesSchema = z.object({ @@ -11,17 +13,48 @@ export const syncInvoicesSchema = z.object({ * Defaults to `cost` for backwards compatibility with the MVP. */ subject: z.enum(['sales', 'cost']).optional(), + /** + * When provided, downloads the full FA(2)/FA(3) XML for each invoice and + * saves it to `${saveDir}/${ksefReference}.xml`. Path must be absolute. + * Creates the directory if it doesn't exist. + */ + saveDir: z.string().optional(), }) export type SyncInvoicesInput = z.infer export async function syncInvoices(ksef: Ksefnik, input: SyncInvoicesInput) { const subjectType = input.subject === 'sales' ? 'Subject1' : 'Subject2' + const includeXml = Boolean(input.saveDir) + const invoices = await ksef.invoices.fetch({ from: input.dateFrom, to: input.dateTo, nip: input.nip, subjectType, + includeXml, }) - return { invoices, count: invoices.length, subject: input.subject ?? 'cost' } + + let savedFiles: string[] = [] + if (input.saveDir) { + const dir = isAbsolute(input.saveDir) ? input.saveDir : resolve(input.saveDir) + await mkdir(dir, { recursive: true }) + savedFiles = await Promise.all( + invoices + .filter((inv) => inv.ksefReference && inv.rawXml) + .map(async (inv) => { + const path = join(dir, `${inv.ksefReference}.xml`) + await writeFile(path, inv.rawXml ?? '', 'utf8') + return path + }), + ) + } + + return { + invoices, + count: invoices.length, + subject: input.subject ?? 'cost', + savedFiles: input.saveDir ? savedFiles : undefined, + savedCount: input.saveDir ? savedFiles.length : undefined, + } } diff --git a/packages/shared/src/types/ksef-adapter.ts b/packages/shared/src/types/ksef-adapter.ts index 4054d52..8b62868 100644 --- a/packages/shared/src/types/ksef-adapter.ts +++ b/packages/shared/src/types/ksef-adapter.ts @@ -14,6 +14,12 @@ export interface FetchInvoicesOpts { subjectType?: InvoiceSubjectType pageSize?: number pageOffset?: number + /** + * When true, also fetch the full FA(2)/FA(3) XML body for each invoice via + * `GET /invoices/ksef/{ksefNumber}` and put it on `Invoice.rawXml`. Off by + * default — metadata alone covers grossAmount/NIPs/dates. + */ + includeXml?: boolean } export interface SendInvoiceInput {