@kirjapro/mcp-server 0.16.1 → 0.17.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.17.0] - Unreleased
4
+
5
+ Needs database migration `20261013030000` before publication: until it is deployed the new
6
+ tool answers a permission error and its telemetry is refused. Every other tool keeps its
7
+ fingerprint; 0.16.x keeps working unchanged against the migrated database.
8
+
9
+ - New read tool `kirjapro_get_report_entries` (full surface): the ledger entries behind the
10
+ income statement's expense or revenue lines for one fiscal year, largest first, optionally
11
+ for one account and a date range inside the year, 1-20 per page with the whole-selection
12
+ `total` and `total_count` and `next_offset`. It reads the database function that AI
13
+ Chat's `getReportEntries` uses, so the same selection sums to the income statement; the
14
+ closing voucher of a closed year is excluded. Account names, voucher text and voucher
15
+ numbers are wrapped in `<untrusted-external>`.
16
+ - `kirjapro_get_dashboard` counts every invoice of the fiscal year. The invoice statistics
17
+ read the year's invoices without a limit, so a year above the Data API row cap was
18
+ silently undercounted; they are now read in pages with an exact count, and a page that
19
+ fails or changes mid-read fails the tool instead of returning partial statistics.
20
+ - `kirjapro_create_offer` checks each line's VAT treatment with the same check as
21
+ `kirjapro_create_invoice` and AI Chat, before an offer number is allocated, and answers
22
+ its `CODE: viesti` (for example `VAT_SELLER_NOT_REGISTERED` or `VAT_RATE_NOT_IN_FORCE`)
23
+ instead of the earlier English sentence.
24
+ - `kirjapro_create_recurring_invoice` and `kirjapro_update_recurring_invoice` (with new
25
+ `lines`) refuse a template whose invoices the database would refuse: each line's
26
+ generated treatment (the standard treatment of its rate, `exempt_other` for 0 %) goes
27
+ through the same check for the seller, the customer and the next generation date. A
28
+ seller outside the VAT register gets `VAT_SELLER_NOT_REGISTERED`.
29
+
3
30
  ## [0.16.1] - 2026-10-11
4
31
 
5
32
  No database migration, and no tool definition changes: every fingerprint is unchanged and
package/README.md CHANGED
@@ -135,7 +135,7 @@ A few prompts to feel out the daily workflows the agent handles cleanly:
135
135
 
136
136
  ## What's included
137
137
 
138
- **29 tools** in the default `core` surface, focused on the daily-use workflows: read your books, invoice flow, transactions + banking, contract create / send / convert, Finnish validators. The full 55-tool catalog (booking shortcuts, recurring invoices, templates, batch tools, etc.) is available via opt-in:
138
+ **29 tools** in the default `core` surface, focused on the daily-use workflows: read your books, invoice flow, transactions + banking, contract create / send / convert, Finnish validators. The full 56-tool catalog (booking shortcuts, recurring invoices, templates, batch tools, etc.) is available via opt-in:
139
139
 
140
140
  ```jsonc
141
141
  {
@@ -145,14 +145,14 @@ A few prompts to feel out the daily workflows the agent handles cleanly:
145
145
  "args": ["-y", "@kirjapro/mcp-server"],
146
146
  "env": {
147
147
  "KIRJAPRO_API_KEY": "kp_your_key_here",
148
- "KIRJAPRO_MCP_SURFACE": "full" // 55 tools instead of 29
148
+ "KIRJAPRO_MCP_SURFACE": "full" // 56 tools instead of 29
149
149
  }
150
150
  }
151
151
  }
152
152
  }
153
153
  ```
154
154
 
155
- **Why default to 29 instead of 55:** large catalogs with overlapping tools cost
155
+ **Why default to 29 instead of 56:** large catalogs with overlapping tools cost
156
156
  more context and can make selection harder, but there is no universal tool-count
157
157
  cliff that applies to every model and task. Twenty-seven is a conservative
158
158
  launch default for Kirjapro's daily workflows. Its own usage and error data,
package/dist/index.js CHANGED
@@ -33089,6 +33089,9 @@ var Protocol = class {
33089
33089
  this.setRequestHandler(GetTaskPayloadRequestSchema, async (request, extra) => {
33090
33090
  const handleTaskResult = async () => {
33091
33091
  const taskId = request.params.taskId;
33092
+ if (!await this._taskStore.getTask(taskId, extra.sessionId)) {
33093
+ throw new McpError(ErrorCode.InvalidParams, `Task not found: ${taskId}`);
33094
+ }
33092
33095
  if (this._taskMessageQueue) {
33093
33096
  let queuedMessage;
33094
33097
  while (queuedMessage = await this._taskMessageQueue.dequeue(taskId, extra.sessionId)) {
@@ -33119,12 +33122,12 @@ var Protocol = class {
33119
33122
  throw new McpError(ErrorCode.InvalidParams, `Task not found: ${taskId}`);
33120
33123
  }
33121
33124
  if (!isTerminal(task.status)) {
33122
- await this._waitForTaskUpdate(taskId, extra.signal);
33125
+ await this._waitForTaskUpdate(taskId, extra.signal, extra.sessionId);
33123
33126
  return await handleTaskResult();
33124
33127
  }
33125
33128
  if (isTerminal(task.status)) {
33126
33129
  const result = await this._taskStore.getTaskResult(taskId, extra.sessionId);
33127
- this._clearTaskQueue(taskId);
33130
+ this._clearTaskQueue(taskId, extra.sessionId);
33128
33131
  return {
33129
33132
  ...result,
33130
33133
  _meta: {
@@ -33161,7 +33164,7 @@ var Protocol = class {
33161
33164
  throw new McpError(ErrorCode.InvalidParams, `Cannot cancel task in terminal status: ${task.status}`);
33162
33165
  }
33163
33166
  await this._taskStore.updateTaskStatus(request.params.taskId, "cancelled", "Client cancelled task execution.", extra.sessionId);
33164
- this._clearTaskQueue(request.params.taskId);
33167
+ this._clearTaskQueue(request.params.taskId, extra.sessionId);
33165
33168
  const cancelledTask = await this._taskStore.getTask(request.params.taskId, extra.sessionId);
33166
33169
  if (!cancelledTask) {
33167
33170
  throw new McpError(ErrorCode.InvalidParams, `Task not found after cancellation: ${request.params.taskId}`);
@@ -33289,6 +33292,19 @@ var Protocol = class {
33289
33292
  const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler;
33290
33293
  const capturedTransport = this._transport;
33291
33294
  const relatedTaskId = request.params?._meta?.[RELATED_TASK_META_KEY]?.taskId;
33295
+ const sessionId = capturedTransport?.sessionId;
33296
+ const store = this._taskStore;
33297
+ let relatedTaskFound = true;
33298
+ let relatedTaskLookup;
33299
+ if (relatedTaskId && store && this._taskMessageQueue && sessionId !== void 0) {
33300
+ relatedTaskFound = false;
33301
+ relatedTaskLookup = (async () => {
33302
+ if (!await store.getTask(relatedTaskId, sessionId)) {
33303
+ throw new McpError(ErrorCode.InvalidParams, `Task not found: ${relatedTaskId}`);
33304
+ }
33305
+ relatedTaskFound = true;
33306
+ })();
33307
+ }
33292
33308
  if (handler === void 0) {
33293
33309
  const errorResponse = {
33294
33310
  jsonrpc: "2.0",
@@ -33298,7 +33314,10 @@ var Protocol = class {
33298
33314
  message: "Method not found"
33299
33315
  }
33300
33316
  };
33301
- if (relatedTaskId && this._taskMessageQueue) {
33317
+ if (relatedTaskId && relatedTaskLookup) {
33318
+ const queuedError = { type: "error", message: errorResponse, timestamp: Date.now() };
33319
+ relatedTaskLookup.then(() => this._enqueueTaskMessage(relatedTaskId, queuedError, sessionId), () => capturedTransport?.send(errorResponse)).catch((error61) => this._onerror(new Error(`Failed to send an error response: ${error61}`)));
33320
+ } else if (relatedTaskId && this._taskMessageQueue) {
33302
33321
  this._enqueueTaskMessage(relatedTaskId, {
33303
33322
  type: "error",
33304
33323
  message: errorResponse,
@@ -33349,7 +33368,10 @@ var Protocol = class {
33349
33368
  closeSSEStream: extra?.closeSSEStream,
33350
33369
  closeStandaloneSSEStream: extra?.closeStandaloneSSEStream
33351
33370
  };
33352
- Promise.resolve().then(() => {
33371
+ (relatedTaskLookup ?? Promise.resolve()).then(() => {
33372
+ if (relatedTaskLookup && abortController.signal.aborted) {
33373
+ throw new McpError(ErrorCode.ConnectionClosed, "Request was cancelled");
33374
+ }
33353
33375
  if (taskCreationParams) {
33354
33376
  this.assertTaskHandlerCapability(request.method);
33355
33377
  }
@@ -33384,7 +33406,7 @@ var Protocol = class {
33384
33406
  ...error61["data"] !== void 0 && { data: error61["data"] }
33385
33407
  }
33386
33408
  };
33387
- if (relatedTaskId && this._taskMessageQueue) {
33409
+ if (relatedTaskId && this._taskMessageQueue && relatedTaskFound) {
33388
33410
  await this._enqueueTaskMessage(relatedTaskId, {
33389
33411
  type: "error",
33390
33412
  message: errorResponse,
@@ -33864,7 +33886,7 @@ var Protocol = class {
33864
33886
  throw new Error("Cannot enqueue task message: taskStore and taskMessageQueue are not configured");
33865
33887
  }
33866
33888
  const maxQueueSize = this._options?.maxTaskQueueSize;
33867
- await this._taskMessageQueue.enqueue(taskId, message, sessionId, maxQueueSize);
33889
+ await this._taskMessageQueue.enqueue(taskId, message, sessionId ?? this._transport?.sessionId, maxQueueSize);
33868
33890
  }
33869
33891
  /**
33870
33892
  * Clears the message queue for a task and rejects any pending request resolvers.
@@ -33893,12 +33915,13 @@ var Protocol = class {
33893
33915
  * Uses polling to check for updates at the task's configured poll interval.
33894
33916
  * @param taskId The task ID to wait for
33895
33917
  * @param signal Abort signal to cancel the wait
33918
+ * @param sessionId Session of the request that waits, passed to the task store
33896
33919
  * @returns Promise that resolves when an update occurs or rejects if aborted
33897
33920
  */
33898
- async _waitForTaskUpdate(taskId, signal) {
33921
+ async _waitForTaskUpdate(taskId, signal, sessionId) {
33899
33922
  let interval = this._options?.defaultTaskPollInterval ?? 1e3;
33900
33923
  try {
33901
- const task = await this._taskStore?.getTask(taskId);
33924
+ const task = await this._taskStore?.getTask(taskId, sessionId);
33902
33925
  if (task?.pollInterval) {
33903
33926
  interval = task.pollInterval;
33904
33927
  }
@@ -34777,6 +34800,42 @@ var ExperimentalMcpServerTasks = class {
34777
34800
  };
34778
34801
 
34779
34802
  // node_modules/@modelcontextprotocol/sdk/dist/esm/server/mcp.js
34803
+ function toolInputElementCount(value, max) {
34804
+ let count = 0;
34805
+ const stack = [value];
34806
+ while (stack.length > 0) {
34807
+ const node2 = stack.pop();
34808
+ if (node2 === null || typeof node2 !== "object")
34809
+ continue;
34810
+ if (Array.isArray(node2)) {
34811
+ for (const child of node2) {
34812
+ if (++count > max)
34813
+ return count;
34814
+ if (child !== null && typeof child === "object")
34815
+ stack.push(child);
34816
+ }
34817
+ } else {
34818
+ for (const key in node2) {
34819
+ if (!Object.prototype.hasOwnProperty.call(node2, key))
34820
+ continue;
34821
+ if (++count > max)
34822
+ return count;
34823
+ const child = node2[key];
34824
+ if (child !== null && typeof child === "object")
34825
+ stack.push(child);
34826
+ }
34827
+ }
34828
+ }
34829
+ return count;
34830
+ }
34831
+ function resolveMaxToolInputElements(value) {
34832
+ if (value === void 0 || value === Infinity)
34833
+ return void 0;
34834
+ if (typeof value !== "number" || Number.isNaN(value) || value < 1) {
34835
+ throw new RangeError(`maxToolInputElements must be a number of at least 1, or Infinity, got ${String(value)}`);
34836
+ }
34837
+ return value;
34838
+ }
34780
34839
  var McpServer = class {
34781
34840
  constructor(serverInfo, options) {
34782
34841
  this._registeredResources = {};
@@ -34788,6 +34847,7 @@ var McpServer = class {
34788
34847
  this._resourceHandlersInitialized = false;
34789
34848
  this._promptHandlersInitialized = false;
34790
34849
  this.server = new Server(serverInfo, options);
34850
+ this._maxToolInputElements = resolveMaxToolInputElements(options?.maxToolInputElements);
34791
34851
  }
34792
34852
  /**
34793
34853
  * Access experimental features.
@@ -34918,12 +34978,15 @@ var McpServer = class {
34918
34978
  * Validates tool input arguments against the tool's input schema.
34919
34979
  */
34920
34980
  async validateToolInput(tool2, args, toolName) {
34981
+ if (this._maxToolInputElements !== void 0 && toolInputElementCount(args, this._maxToolInputElements) > this._maxToolInputElements) {
34982
+ throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for tool ${toolName}: arguments contain more than the maximum of ${this._maxToolInputElements} elements`);
34983
+ }
34921
34984
  if (!tool2.inputSchema) {
34922
34985
  return void 0;
34923
34986
  }
34924
34987
  const inputObj = normalizeObjectSchema(tool2.inputSchema);
34925
34988
  const schemaToParse = inputObj ?? tool2.inputSchema;
34926
- const parseResult = await safeParseAsync2(schemaToParse, args);
34989
+ const parseResult = await safeParseAsync2(schemaToParse, args ?? {});
34927
34990
  if (!parseResult.success) {
34928
34991
  const error61 = "error" in parseResult ? parseResult.error : "Unknown error";
34929
34992
  const errorMessage = getParseErrorMessage(error61);
@@ -35161,7 +35224,7 @@ var McpServer = class {
35161
35224
  }
35162
35225
  if (prompt.argsSchema) {
35163
35226
  const argsObj = normalizeObjectSchema(prompt.argsSchema);
35164
- const parseResult = await safeParseAsync2(argsObj, request.params.arguments);
35227
+ const parseResult = await safeParseAsync2(argsObj, request.params.arguments ?? {});
35165
35228
  if (!parseResult.success) {
35166
35229
  const error61 = "error" in parseResult ? parseResult.error : "Unknown error";
35167
35230
  const errorMessage = getParseErrorMessage(error61);
@@ -35679,7 +35742,7 @@ var StdioServerTransport = class {
35679
35742
  import { createClient } from "@supabase/supabase-js";
35680
35743
 
35681
35744
  // src/version.ts
35682
- var VERSION = "0.16.1";
35745
+ var VERSION = "0.17.0";
35683
35746
 
35684
35747
  // src/auth.ts
35685
35748
  var SUPABASE_PROJECT_URL = "https://tifewzsimmyoiepblhld.supabase.co";
@@ -36175,6 +36238,31 @@ function parseDashboardStats(data) {
36175
36238
  return null;
36176
36239
  return { revenue, expenses, transaction_count: transactionCount };
36177
36240
  }
36241
+ var DASHBOARD_INVOICE_PAGE_SIZE = 1e3;
36242
+ var DASHBOARD_INVOICE_MAX_PAGES = 50;
36243
+ function inconsistentPage(state, page) {
36244
+ const { data, count } = page;
36245
+ return !data || count === null || !Number.isInteger(count) || count < 0 || state.expectedCount !== void 0 && count !== state.expectedCount || state.offset + data.length > count || data.length === 0 && state.offset < count;
36246
+ }
36247
+ function addDashboardInvoicePage(state, page) {
36248
+ if (inconsistentPage(state, page) || !page.data || page.count === null) {
36249
+ return {
36250
+ kind: "error",
36251
+ message: "The invoice statistics could not be read completely. Try again."
36252
+ };
36253
+ }
36254
+ const rows = [...state.rows, ...page.data];
36255
+ const offset = state.offset + page.data.length;
36256
+ const pages = (state.pages ?? 0) + 1;
36257
+ if (offset === page.count) return { kind: "done", rows };
36258
+ if (pages >= DASHBOARD_INVOICE_MAX_PAGES || page.count > DASHBOARD_INVOICE_MAX_PAGES * DASHBOARD_INVOICE_PAGE_SIZE) {
36259
+ return {
36260
+ kind: "error",
36261
+ message: `The fiscal year has too many invoices (${page.count}) for the dashboard statistics. Use kirjapro_list_invoices with a status filter.`
36262
+ };
36263
+ }
36264
+ return { kind: "next", state: { offset, rows, expectedCount: page.count, pages } };
36265
+ }
36178
36266
  function toDashboardReport(stats, invoices, bankAccounts, today) {
36179
36267
  const { revenue, expenses, transaction_count } = stats;
36180
36268
  const invoiceStats = {
@@ -36243,7 +36331,7 @@ function tagPayments(payments) {
36243
36331
  var CREDIT_PAGE_SIZE = 500;
36244
36332
  function addCreditPage(state, page) {
36245
36333
  const { data, count } = page;
36246
- if (!data || count === null || !Number.isInteger(count) || count < 0 || state.expectedCount !== void 0 && count !== state.expectedCount || state.offset + data.length > count || data.length === 0 && state.offset < count) {
36334
+ if (inconsistentPage(state, page) || !data || count === null) {
36247
36335
  return {
36248
36336
  kind: "error",
36249
36337
  message: "The complete finalized credit balance could not be read. Refresh the invoice and try again."
@@ -39222,6 +39310,17 @@ async function resolveDefaultIncomeStatementTemplate(supabase2) {
39222
39310
  cachedDefaultTemplateId = data.id;
39223
39311
  return data.id;
39224
39312
  }
39313
+ async function readDashboardInvoices(supabase2, companyId2, from, to) {
39314
+ let state = { offset: 0, rows: [] };
39315
+ for (; ; ) {
39316
+ const { data, count, error: error61 } = await supabase2.from("invoices").select(DASHBOARD_INVOICE_COLUMNS, { count: "exact" }).eq("company_id", companyId2).gte("invoice_date", from).lte("invoice_date", to).order("id", { ascending: true }).range(state.offset, state.offset + DASHBOARD_INVOICE_PAGE_SIZE - 1);
39317
+ if (error61) return { rows: null, error: sanitizeDbError("Failed to read dashboard", error61) };
39318
+ const step = addDashboardInvoicePage(state, { data, count });
39319
+ if (step.kind === "error") return { rows: null, error: step.message };
39320
+ if (step.kind === "done") return { rows: step.rows, error: null };
39321
+ state = step.state;
39322
+ }
39323
+ }
39225
39324
  var INVOICE_STAT_BUCKET = external_exports.object({
39226
39325
  count: external_exports.number(),
39227
39326
  total: external_exports.number()
@@ -39405,6 +39504,59 @@ var GENERAL_LEDGER_OUTPUT_SHAPE = {
39405
39504
  snapshot_at: external_exports.string()
39406
39505
  })
39407
39506
  };
39507
+ var REPORT_ENTRY_SHAPE = external_exports.object({
39508
+ transaction_id: external_exports.string(),
39509
+ date: external_exports.string(),
39510
+ document_number: external_exports.string().nullable(),
39511
+ description: external_exports.string().nullable(),
39512
+ account_number: external_exports.string(),
39513
+ account_name: external_exports.string(),
39514
+ amount: external_exports.number()
39515
+ });
39516
+ var REPORT_ENTRIES_OUTPUT_SHAPE = {
39517
+ kind: external_exports.enum(["expense", "revenue"]),
39518
+ fiscal_year_id: external_exports.string(),
39519
+ account: external_exports.object({ account_number: external_exports.string(), account_name: external_exports.string() }).nullable(),
39520
+ period: external_exports.object({ from: external_exports.string(), to: external_exports.string() }),
39521
+ total: external_exports.number(),
39522
+ total_formatted: external_exports.string(),
39523
+ total_count: external_exports.number(),
39524
+ shown_total: external_exports.number(),
39525
+ shown_count: external_exports.number(),
39526
+ offset: external_exports.number(),
39527
+ limit: external_exports.number(),
39528
+ truncated: external_exports.boolean(),
39529
+ next_offset: external_exports.number().nullable(),
39530
+ items: external_exports.array(REPORT_ENTRY_SHAPE)
39531
+ };
39532
+ var REPORT_ENTRIES_RPC = external_exports.object({
39533
+ status: external_exports.literal("ok"),
39534
+ companyId: external_exports.string(),
39535
+ fiscalYearId: external_exports.string(),
39536
+ kind: external_exports.enum(["expense", "revenue"]),
39537
+ account: external_exports.object({ accountNumber: external_exports.string(), accountName: external_exports.string() }).nullable(),
39538
+ period: external_exports.object({ from: external_exports.string(), to: external_exports.string() }),
39539
+ offset: external_exports.number().int(),
39540
+ limit: external_exports.number().int(),
39541
+ total: external_exports.number(),
39542
+ totalCount: external_exports.number().int(),
39543
+ shownTotal: external_exports.number(),
39544
+ shownCount: external_exports.number().int(),
39545
+ truncated: external_exports.boolean(),
39546
+ nextOffset: external_exports.number().int().nullable(),
39547
+ items: external_exports.array(
39548
+ external_exports.object({
39549
+ transactionId: external_exports.string(),
39550
+ date: external_exports.string(),
39551
+ documentNumber: external_exports.string().nullable(),
39552
+ description: external_exports.string().nullable(),
39553
+ accountNumber: external_exports.string(),
39554
+ accountName: external_exports.string(),
39555
+ amount: external_exports.number()
39556
+ })
39557
+ )
39558
+ });
39559
+ var REPORT_ENTRIES_DESCRIPTION = "Get the ledger entries behind the expense or revenue lines of the income statement for one fiscal year, largest amount first: optionally only one expense or revenue account (accountNumber) and a date range inside the year (dateFrom and dateTo together). total and total_count cover the whole selection; items is one page of 1-20 entries whose sum and count are shown_total and shown_count. For the next page pass next_offset as offset, and tell the user if total changed between pages. Amounts are the expense or revenue account amounts (deductible VAT is on its own account), so the same selection sums to the income statement. The closing voucher of a closed year is excluded, unlike in kirjapro_get_general_ledger. Each item names its voucher (transaction_id) for kirjapro_get_transaction. If fiscalYearId is omitted, the single open fiscal year is used.";
39408
39560
  function registerReportTools(server2) {
39409
39561
  server2.registerTool(
39410
39562
  DASHBOARD_TOOL.name,
@@ -39438,18 +39590,19 @@ function registerReportTools(server2) {
39438
39590
  p_company_id: companyId2,
39439
39591
  p_fiscal_year_id: fiscalYearId
39440
39592
  }),
39441
- // Invoice stats scoped to fiscal year date range
39442
- supabase2.from("invoices").select(DASHBOARD_INVOICE_COLUMNS).eq("company_id", companyId2).gte("invoice_date", fiscalYear.start_date).lte("invoice_date", fiscalYear.end_date),
39593
+ // Invoice stats scoped to the fiscal year date range, every page (U-64)
39594
+ readDashboardInvoices(supabase2, companyId2, fiscalYear.start_date, fiscalYear.end_date),
39443
39595
  // Bank balances
39444
39596
  supabase2.from("bank_accounts").select(BANK_BALANCE_COLUMNS).eq("company_id", companyId2).eq("is_active", true)
39445
39597
  ]);
39446
39598
  const stats = parseDashboardStats(statsResult.data);
39447
- const readError = statsResult.error || invoiceResult.error || bankResult.error;
39599
+ const readError = statsResult.error || bankResult.error;
39448
39600
  if (readError || !stats) {
39449
39601
  return toErrorContent(readError ? sanitizeDbError("Failed to read dashboard", readError) : "Invalid dashboard response");
39450
39602
  }
39603
+ if (invoiceResult.error !== null) return toErrorContent(invoiceResult.error);
39451
39604
  return toResult(
39452
- toDashboardReport(stats, invoiceResult.data || [], bankResult.data || [], helsinkiDateString())
39605
+ toDashboardReport(stats, invoiceResult.rows, bankResult.data || [], helsinkiDateString())
39453
39606
  );
39454
39607
  }
39455
39608
  );
@@ -39672,6 +39825,97 @@ function registerReportTools(server2) {
39672
39825
  );
39673
39826
  }
39674
39827
  );
39828
+ server2.registerTool(
39829
+ "kirjapro_get_report_entries",
39830
+ {
39831
+ description: REPORT_ENTRIES_DESCRIPTION,
39832
+ inputSchema: {
39833
+ kind: external_exports.enum(["expense", "revenue"]).default("expense").describe("expense = the expense lines (kulut), revenue = the revenue lines (tuotot)"),
39834
+ accountNumber: external_exports.string().regex(/^\d{3,8}$/).optional().describe('Only this expense or revenue account (e.g. "4000"); all of the kind when omitted'),
39835
+ fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved to the open fiscal year if omitted)"),
39836
+ dateFrom: ISO_DATE_SCHEMA.optional().describe("Start date inside the fiscal year (YYYY-MM-DD); give dateTo too"),
39837
+ dateTo: ISO_DATE_SCHEMA.optional().describe("End date inside the fiscal year (YYYY-MM-DD); give dateFrom too"),
39838
+ limit: external_exports.number().int().min(1).max(20).default(10).describe("Entries per page (default 10, max 20)"),
39839
+ offset: external_exports.number().int().min(0).max(1e6).default(0).describe("Entries to skip; next_offset of the previous page")
39840
+ },
39841
+ outputSchema: REPORT_ENTRIES_OUTPUT_SHAPE,
39842
+ annotations: READ_ONLY_ANNOTATIONS
39843
+ },
39844
+ async (params) => {
39845
+ const supabase2 = getSupabase();
39846
+ const companyId2 = getCompanyId();
39847
+ if (params.dateFrom === void 0 !== (params.dateTo === void 0)) {
39848
+ return toErrorContent("Give both dateFrom and dateTo, or neither (the whole fiscal year).");
39849
+ }
39850
+ if (params.dateFrom && params.dateTo) {
39851
+ const rangeError = assertDateRange(params.dateFrom, params.dateTo);
39852
+ if (rangeError) return rangeError;
39853
+ }
39854
+ let fiscalYearId;
39855
+ if (params.fiscalYearId) {
39856
+ fiscalYearId = params.fiscalYearId;
39857
+ } else {
39858
+ const resolved = await resolveOpenFiscalYear(supabase2, companyId2);
39859
+ if ("isError" in resolved) return resolved;
39860
+ fiscalYearId = resolved.id;
39861
+ }
39862
+ const { data, error: error61 } = await supabase2.rpc("get_report_entries", {
39863
+ p_company_id: companyId2,
39864
+ p_fiscal_year_id: fiscalYearId,
39865
+ p_kind: params.kind,
39866
+ p_account_number: params.accountNumber,
39867
+ p_date_from: params.dateFrom,
39868
+ p_date_to: params.dateTo,
39869
+ p_limit: params.limit,
39870
+ p_offset: params.offset
39871
+ });
39872
+ if (error61) {
39873
+ if (error61.code === "P0002" && params.accountNumber) {
39874
+ return toErrorContent(
39875
+ `Account "${params.accountNumber}" is not ${params.kind === "revenue" ? "a revenue" : "an expense"} account of this company. Use kirjapro_list_accounts to see the accounts and their types.`
39876
+ );
39877
+ }
39878
+ if (error61.code === "22023") {
39879
+ return toErrorContent(
39880
+ `Failed to get report entries: ${error61.message}. Read kirjapro://fiscal-years for the fiscal years and their dates.`
39881
+ );
39882
+ }
39883
+ return toErrorContent(sanitizeDbError("Failed to get report entries", error61));
39884
+ }
39885
+ const parsed = REPORT_ENTRIES_RPC.safeParse(data);
39886
+ if (!parsed.success || parsed.data.companyId !== companyId2 || parsed.data.fiscalYearId !== fiscalYearId) {
39887
+ return toErrorContent("Failed to get report entries: unexpected response. Please try again.");
39888
+ }
39889
+ const report = parsed.data;
39890
+ return toResult({
39891
+ kind: report.kind,
39892
+ fiscal_year_id: report.fiscalYearId,
39893
+ account: report.account ? {
39894
+ account_number: report.account.accountNumber,
39895
+ account_name: tagUntrusted(report.account.accountName)
39896
+ } : null,
39897
+ period: { from: report.period.from, to: report.period.to },
39898
+ total: report.total,
39899
+ total_formatted: formatCurrency(report.total),
39900
+ total_count: report.totalCount,
39901
+ shown_total: report.shownTotal,
39902
+ shown_count: report.shownCount,
39903
+ offset: report.offset,
39904
+ limit: report.limit,
39905
+ truncated: report.truncated,
39906
+ next_offset: report.nextOffset,
39907
+ items: report.items.map((item) => ({
39908
+ transaction_id: item.transactionId,
39909
+ date: item.date,
39910
+ document_number: tagUntrusted(item.documentNumber),
39911
+ description: tagUntrustedText(item.description),
39912
+ account_number: item.accountNumber,
39913
+ account_name: tagUntrusted(item.accountName),
39914
+ amount: item.amount
39915
+ }))
39916
+ });
39917
+ }
39918
+ );
39675
39919
  }
39676
39920
 
39677
39921
  // src/tools/banking.ts
@@ -41953,82 +42197,8 @@ function registerBookingTools(server2) {
41953
42197
  );
41954
42198
  }
41955
42199
 
41956
- // src/vat.ts
41957
- var EU_COUNTRY_CODES2 = /* @__PURE__ */ new Set([
41958
- "AT",
41959
- "BE",
41960
- "BG",
41961
- "HR",
41962
- "CY",
41963
- "CZ",
41964
- "DE",
41965
- "DK",
41966
- "EE",
41967
- "ES",
41968
- "FI",
41969
- "FR",
41970
- "GR",
41971
- "HU",
41972
- "IE",
41973
- "IT",
41974
- "LT",
41975
- "LU",
41976
- "LV",
41977
- "MT",
41978
- "NL",
41979
- "PL",
41980
- "PT",
41981
- "RO",
41982
- "SE",
41983
- "SI",
41984
- "SK"
41985
- ]);
41986
- function normalizedVatNumber(value) {
41987
- return (value ?? "").toUpperCase().replace(/[^A-Z0-9]/g, "");
41988
- }
41989
- function validateVatTreatmentEligibility({
41990
- code,
41991
- sellerAlvRegistered,
41992
- customer,
41993
- documentDate
41994
- }) {
41995
- if (!sellerAlvRegistered && code !== "not_vat_registered") {
41996
- return "A seller outside the VAT register can only use not_vat_registered.";
41997
- }
41998
- if (sellerAlvRegistered && code === "not_vat_registered") {
41999
- return "A VAT-registered seller cannot use not_vat_registered.";
42000
- }
42001
- if (code === "standard_25_5" && documentDate < "2024-09-01") {
42002
- return "standard_25_5 is not valid before 2024-09-01.";
42003
- }
42004
- if (code === "standard_13_5" && documentDate < "2026-01-01") {
42005
- return "standard_13_5 is not valid before 2026-01-01.";
42006
- }
42007
- const requiresBusiness = code.startsWith("reverse_") || code === "intra_eu_goods" || code === "service_outside_eu";
42008
- if (requiresBusiness && customer?.customer_kind !== "business") {
42009
- return "The selected VAT treatment requires a business customer.";
42010
- }
42011
- if ((code === "reverse_construction" || code === "reverse_scrap") && !isValidFinnishBusinessId(customer?.business_id ?? "")) {
42012
- return "Domestic reverse charge requires a Finnish buyer business ID in the form 1234567-8.";
42013
- }
42014
- if (code === "reverse_eu_service" || code === "intra_eu_goods") {
42015
- const country = (customer?.country ?? "").trim().toUpperCase();
42016
- const vatNumber = normalizedVatNumber(customer?.vat_number);
42017
- const expectedPrefix = country === "GR" ? "EL" : country;
42018
- if (!EU_COUNTRY_CODES2.has(country) || country === "FI" || !/^[A-Z]{2}[A-Z0-9]{2,12}$/.test(vatNumber) || !vatNumber.startsWith(expectedPrefix)) {
42019
- return "EU sale requires a non-Finnish EU country and a matching buyer VAT number.";
42020
- }
42021
- }
42022
- if (code === "export_outside_eu" || code === "service_outside_eu") {
42023
- const country = (customer?.country ?? "").trim().toUpperCase();
42024
- if (!country || EU_COUNTRY_CODES2.has(country)) {
42025
- return "Export outside the EU requires a buyer country outside the EU.";
42026
- }
42027
- }
42028
- return null;
42029
- }
42030
-
42031
42200
  // src/tools/offers.ts
42201
+ var NO_CUSTOMER = { customer_kind: "none", business_id: null, vat_number: null, country: null };
42032
42202
  var OFFER_DETAIL_OUTPUT_SHAPE = {
42033
42203
  id: external_exports.string(),
42034
42204
  offer_number: external_exports.string(),
@@ -42239,16 +42409,6 @@ function registerOfferTools(server2) {
42239
42409
  `validUntil (${params.validUntil}) must not be earlier than validFrom (${params.validFrom}).`
42240
42410
  );
42241
42411
  }
42242
- const { data: offerNumberRaw, error: numberError } = await supabase2.rpc(
42243
- "get_next_offer_number",
42244
- { p_company_id: companyId2, p_type: params.type }
42245
- );
42246
- if (numberError || !offerNumberRaw) {
42247
- return toErrorContent(
42248
- numberError ? sanitizeDbError("Failed to generate offer number", numberError) : "Failed to generate offer number"
42249
- );
42250
- }
42251
- const offerNumber = offerNumberRaw;
42252
42412
  const resolveTreatment = (line) => {
42253
42413
  const inputRate = parseFloat(line.vatRate);
42254
42414
  return line.vatTreatmentCode ?? (inputRate === 25.5 ? "standard_25_5" : inputRate === 13.5 ? "standard_13_5" : inputRate === 10 ? "standard_10" : null);
@@ -42259,14 +42419,24 @@ function registerOfferTools(server2) {
42259
42419
  for (const line of params.lines) {
42260
42420
  const code = resolveTreatment(line);
42261
42421
  if (!code) continue;
42262
- const eligibilityError = validateVatTreatmentEligibility({
42422
+ const ineligible = invoiceVatTreatmentError(
42263
42423
  code,
42264
- sellerAlvRegistered: company.alv_registered,
42265
- customer,
42266
- documentDate: params.validFrom ?? helsinkiDateString()
42267
- });
42268
- if (eligibilityError) return toErrorContent(eligibilityError);
42424
+ company.alv_registered,
42425
+ customer ?? NO_CUSTOMER,
42426
+ params.validFrom ?? helsinkiDateString()
42427
+ );
42428
+ if (ineligible) return toErrorContent(`${ineligible.code}: ${ineligible.message}`);
42429
+ }
42430
+ const { data: offerNumberRaw, error: numberError } = await supabase2.rpc(
42431
+ "get_next_offer_number",
42432
+ { p_company_id: companyId2, p_type: params.type }
42433
+ );
42434
+ if (numberError || !offerNumberRaw) {
42435
+ return toErrorContent(
42436
+ numberError ? sanitizeDbError("Failed to generate offer number", numberError) : "Failed to generate offer number"
42437
+ );
42269
42438
  }
42439
+ const offerNumber = offerNumberRaw;
42270
42440
  const calculatedLines = params.lines.map((line, i) => {
42271
42441
  const vatTreatmentCode = resolveTreatment(line) ?? "exempt_other";
42272
42442
  const vatRate = vatTreatmentCode === "standard_25_5" ? 25.5 : vatTreatmentCode === "standard_13_5" ? 13.5 : vatTreatmentCode === "standard_10" ? 10 : 0;
@@ -42600,6 +42770,36 @@ function registerOfferTools(server2) {
42600
42770
  }
42601
42771
 
42602
42772
  // src/tools/recurring-invoices.ts
42773
+ function generatedTreatment(line, supplyDate) {
42774
+ const stored = typeof line.vat_rate === "number" ? line.vat_rate : Number.NaN;
42775
+ const rate = stored === 24 ? supplyDate <= "2024-08-31" ? 24 : 25.5 : stored === 14 ? supplyDate <= "2025-12-31" ? 14 : 13.5 : [25.5, 13.5, 10, 0].includes(stored) ? stored : 25.5;
42776
+ const code = typeof line.vat_treatment_code === "string" ? line.vat_treatment_code : void 0;
42777
+ if (code === "legacy_standard_14" && rate === 13.5) return "standard_13_5";
42778
+ if (code === "legacy_standard_24" && rate === 25.5) return "standard_25_5";
42779
+ if (code) return code;
42780
+ if (rate === 25.5) return "standard_25_5";
42781
+ if (rate === 13.5) return "standard_13_5";
42782
+ if (rate === 10) return "standard_10";
42783
+ return "exempt_other";
42784
+ }
42785
+ var toTemplateLines = (lines) => lines.map((line) => ({ vat_rate: parseFloat(line.vatRate) }));
42786
+ async function recurringVatError(supabase2, companyId2, customer, lines, supplyDate) {
42787
+ const { data: company, error: error61 } = await supabase2.from("companies").select("alv_registered").eq("id", companyId2).single();
42788
+ if (error61 || !company) return "Failed to verify the seller VAT registration status.";
42789
+ for (const line of lines) {
42790
+ const ineligible = invoiceVatTreatmentError(
42791
+ generatedTreatment(line, supplyDate),
42792
+ company.alv_registered,
42793
+ customer,
42794
+ supplyDate
42795
+ );
42796
+ if (ineligible?.code === "VAT_SELLER_NOT_REGISTERED") {
42797
+ return `${ineligible.code}: ${ineligible.message} Toistuvan laskun pohja ALV-rekisteriin kuulumattomalle myyj\xE4lle tehd\xE4\xE4n Kirjapron verkkosovelluksessa (Laskut \u2192 Toistuvat laskut).`;
42798
+ }
42799
+ if (ineligible) return `${ineligible.code}: ${ineligible.message}`;
42800
+ }
42801
+ return null;
42802
+ }
42603
42803
  var RECURRING_INVOICE_DETAIL_OUTPUT_SHAPE = {
42604
42804
  id: external_exports.string(),
42605
42805
  name: external_exports.string(),
@@ -42727,7 +42927,7 @@ function registerRecurringInvoiceTools(server2) {
42727
42927
  params.idempotencyKey,
42728
42928
  params,
42729
42929
  async () => {
42730
- const { data: customer, error: custError } = await supabase2.from("customers").select("id, name").eq("id", params.customerId).eq("company_id", companyId2).maybeSingle();
42930
+ const { data: customer, error: custError } = await supabase2.from("customers").select("id, name, customer_kind, business_id, vat_number, country").eq("id", params.customerId).eq("company_id", companyId2).maybeSingle();
42731
42931
  if (custError || !customer) {
42732
42932
  return toErrorContent(
42733
42933
  `Customer ${params.customerId} not found. Use kirjapro_list_customers to find customer IDs.`
@@ -42738,6 +42938,14 @@ function registerRecurringInvoiceTools(server2) {
42738
42938
  `endDate (${params.endDate}) must not be earlier than nextDate (${params.nextDate}). A template with endDate before nextDate would never generate an invoice.`
42739
42939
  );
42740
42940
  }
42941
+ const vatError = await recurringVatError(
42942
+ supabase2,
42943
+ companyId2,
42944
+ customer,
42945
+ toTemplateLines(params.lines),
42946
+ params.nextDate
42947
+ );
42948
+ if (vatError) return toErrorContent(vatError);
42741
42949
  const templateData = {
42742
42950
  lines: params.lines.map((l, i) => ({
42743
42951
  line_number: i + 1,
@@ -42800,7 +43008,7 @@ function registerRecurringInvoiceTools(server2) {
42800
43008
  async (params) => {
42801
43009
  const supabase2 = getSupabase();
42802
43010
  const companyId2 = getCompanyId();
42803
- const { data: existing, error: fetchError } = await supabase2.from("recurring_invoices").select("id, name, template_data, next_date, end_date").eq("id", params.recurringInvoiceId).eq("company_id", companyId2).single();
43011
+ const { data: existing, error: fetchError } = await supabase2.from("recurring_invoices").select("id, name, customer_id, template_data, next_date, end_date").eq("id", params.recurringInvoiceId).eq("company_id", companyId2).single();
42804
43012
  if (fetchError || !existing) {
42805
43013
  return toErrorContent(
42806
43014
  `Recurring invoice ${params.recurringInvoiceId} not found. Use kirjapro_list_recurring_invoices to find IDs.`
@@ -42813,6 +43021,24 @@ function registerRecurringInvoiceTools(server2) {
42813
43021
  `endDate (${effectiveEnd}) must not be earlier than nextDate (${effectiveNext}). Either clear endDate (set to null) or advance nextDate first.`
42814
43022
  );
42815
43023
  }
43024
+ const storedLines = existing.template_data?.lines;
43025
+ const checkedLines = params.lines !== void 0 ? toTemplateLines(params.lines) : params.nextDate !== void 0 && Array.isArray(storedLines) ? storedLines : null;
43026
+ if (checkedLines && checkedLines.length > 0) {
43027
+ const { data: customer, error: custError } = await supabase2.from("customers").select("customer_kind, business_id, vat_number, country").eq("id", existing.customer_id).eq("company_id", companyId2).maybeSingle();
43028
+ if (custError || !customer) {
43029
+ return toErrorContent(
43030
+ "The customer of this recurring invoice was not found. Use kirjapro_get_recurring_invoice to check it."
43031
+ );
43032
+ }
43033
+ const vatError = await recurringVatError(
43034
+ supabase2,
43035
+ companyId2,
43036
+ customer,
43037
+ checkedLines,
43038
+ effectiveNext ?? helsinkiDateString()
43039
+ );
43040
+ if (vatError) return toErrorContent(vatError);
43041
+ }
42816
43042
  const updates = {};
42817
43043
  if (params.name !== void 0) updates.name = params.name;
42818
43044
  if (params.frequency !== void 0) updates.frequency = params.frequency;
@@ -43330,7 +43556,8 @@ var TOOL_REGISTRATION_ORDER = [
43330
43556
  "kirjapro_get_received_invoice",
43331
43557
  "kirjapro_get_financial_notes",
43332
43558
  "kirjapro_get_general_ledger",
43333
- "kirjapro_unreconcile_bank_transaction"
43559
+ "kirjapro_unreconcile_bank_transaction",
43560
+ "kirjapro_get_report_entries"
43334
43561
  ];
43335
43562
  var CORE_TOOL_NAMES = new Set(
43336
43563
  TOOL_REGISTRATION_ORDER.slice(0, 29)
@@ -43342,7 +43569,7 @@ function resolveSurface(explicit) {
43342
43569
  if (env === "core") return "core";
43343
43570
  if (env && env !== "core") {
43344
43571
  console.error(
43345
- `[kirjapro-mcp] Unknown KIRJAPRO_MCP_SURFACE="${env}" \u2014 falling back to "core". Valid values: "core" (default, 29 tools), "full" (55 tools).`
43572
+ `[kirjapro-mcp] Unknown KIRJAPRO_MCP_SURFACE="${env}" \u2014 falling back to "core". Valid values: "core" (default, 29 tools), "full" (56 tools).`
43346
43573
  );
43347
43574
  }
43348
43575
  return "core";
@@ -43411,7 +43638,7 @@ function registerTools(server2, options = {}) {
43411
43638
  );
43412
43639
  } else {
43413
43640
  console.error(
43414
- `[kirjapro-mcp] surface=full \u2014 registered all 55 tools (CORE_TOOL_NAMES bypass active).`
43641
+ `[kirjapro-mcp] surface=full \u2014 registered all 56 tools (CORE_TOOL_NAMES bypass active).`
43415
43642
  );
43416
43643
  }
43417
43644
  }
@@ -43959,6 +44186,7 @@ var DATA_HANDLING = {
43959
44186
  { tool: "kirjapro_list_transactions / kirjapro_search_transactions", fields: ["description", "document_number", "entries[].account (the account name part)"], source: "Typed or imported by users; a bank reconciliation match persists the counterparty name into the description" },
43960
44187
  { tool: "kirjapro_get_transaction", fields: ["description", "document_number", "journal_entries[].description", "journal_entries[].accounts.name"], source: "As above" },
43961
44188
  { tool: "kirjapro_get_general_ledger", fields: ["account.name", "entries[].description", "entries[].document_number"], source: "As above" },
44189
+ { tool: "kirjapro_get_report_entries", fields: ["account.account_name", "items[].account_name", "items[].description", "items[].document_number"], source: "As above" },
43962
44190
  { tool: "kirjapro_list_bank_accounts", fields: ["account_name", "bank_name", "bic"], source: "Typed by users or provided by the bank (the IBAN is validated by the database and not wrapped)" },
43963
44191
  { tool: "kirjapro_list_bank_transactions", fields: ["counterparty_name", "counterparty_iban", "reference", "message", "currency"], source: "Paying party via CAMT.053 bank feed (the currency has no database constraint, as on received invoices)" },
43964
44192
  { tool: "kirjapro_list_bank_link_candidates", fields: ["label", "document_number"], source: "Purchase-invoice sender name and number, invoice number or voucher description and number of the link candidate" },
@@ -44244,6 +44472,7 @@ Faster than composing journal entries via \`kirjapro_create_transaction\`:
44244
44472
 
44245
44473
  ### Extended Reports
44246
44474
  - \`kirjapro_get_general_ledger\` (p\xE4\xE4kirja) \u2014 exact full-period summary plus a creation-time-pinned seek-cursor page (max 100 entries); pass \`next_cursor\` unchanged while \`has_more\`
44475
+ - \`kirjapro_get_report_entries\` \u2014 the entries behind the income statement's expense or revenue lines (largest first, 1-20 per page, optionally one account or a date range); pass \`next_offset\` as \`offset\`
44247
44476
  - \`kirjapro_search_transactions\` \u2014 text + date + amount range search
44248
44477
  - \`kirjapro_get_transaction\` \u2014 single transaction with all journal entries
44249
44478
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kirjapro/mcp-server",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
4
4
  "description": "MCP server for Finnish accounting — exposes Kirjapro bookkeeping, invoicing, and reporting tools to Claude, ChatGPT, Mistral, and other MCP clients.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -52,7 +52,7 @@
52
52
  "node": ">=20"
53
53
  },
54
54
  "dependencies": {
55
- "@modelcontextprotocol/sdk": "^1.29.0",
55
+ "@modelcontextprotocol/sdk": "^1.31.0",
56
56
  "@supabase/supabase-js": "^2.105.4",
57
57
  "zod": "^4.4.3"
58
58
  },
package/server.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@kirjapro/mcp-server",
3
3
  "displayName": "Kirjapro - Finnish Accounting",
4
4
  "description": "Finnish double-entry bookkeeping, invoicing, and financial reporting for growing businesses. Create transactions, generate invoices with VAT calculation, produce income statements and balance sheets, and validate Finnish business identifiers.",
5
- "version": "0.16.1",
5
+ "version": "0.17.0",
6
6
  "homepage": "https://kirjapro.fi",
7
7
  "repository": "https://github.com/Jaollo/kirja-pro",
8
8
  "license": "MIT",
@@ -16,7 +16,7 @@
16
16
  "required": true
17
17
  },
18
18
  "KIRJAPRO_MCP_SURFACE": {
19
- "description": "Tool surface: \"core\" (default, 29 daily-use tools) or \"full\" (all 55 tools).",
19
+ "description": "Tool surface: \"core\" (default, 29 daily-use tools) or \"full\" (all 56 tools).",
20
20
  "required": false
21
21
  },
22
22
  "KIRJAPRO_TELEMETRY": {
@@ -112,6 +112,10 @@
112
112
  "name": "kirjapro_get_recurring_invoice",
113
113
  "hash": "0e6c6a29c1f06fc6"
114
114
  },
115
+ {
116
+ "name": "kirjapro_get_report_entries",
117
+ "hash": "bd721bb442565288"
118
+ },
115
119
  {
116
120
  "name": "kirjapro_get_transaction",
117
121
  "hash": "676af0ec73d52cba"