actual-mcp-server 0.22.20 → 0.22.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -835,7 +835,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
835
835
 
836
836
  ---
837
837
 
838
- **Version:** 0.22.20 | **Tool Count:** 83 (verified LibreChat-compatible)
838
+ **Version:** 0.22.22 | **Tool Count:** 83 (verified LibreChat-compatible)
839
839
 
840
840
  ## Sponsor
841
841
 
@@ -119,6 +119,7 @@ const API_TOOL_MAP = {
119
119
  deletePayee: 'actual_payees_delete',
120
120
  mergePayees: 'actual_payees_merge',
121
121
  getPayeeRules: 'actual_payee_rules_get',
122
+ // #539: the tool covers this capability through per-item writes; it never calls batchBudgetUpdates.
122
123
  batchBudgetUpdates: 'actual_budget_updates_batch',
123
124
  holdBudgetForNextMonth: 'actual_budgets_holdForNextMonth',
124
125
  resetBudgetHold: 'actual_budgets_resetHold',
@@ -5,7 +5,7 @@ import api from '@actual-app/api';
5
5
  // cannot expose its named exports via static import syntax. At runtime the default import
6
6
  // IS module.exports, so all methods are accessible as properties.
7
7
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
- const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, importTransactions: rawImportTransactions, getTransactions: rawGetTransactions, getCategories: rawGetCategories, createCategory: rawCreateCategory, getPayees: rawGetPayees, getCommonPayees: rawGetCommonPayees, createPayee: rawCreatePayee, getBudgetMonths: rawGetBudgetMonths, getBudgetMonth: rawGetBudgetMonth, setBudgetAmount: rawSetBudgetAmount, createAccount: rawCreateAccount, updateAccount: rawUpdateAccount, getAccountBalance: rawGetAccountBalance, updateTransaction: rawUpdateTransaction, deleteTransaction: rawDeleteTransaction, updateCategory: rawUpdateCategory, deleteCategory: rawDeleteCategory, updatePayee: rawUpdatePayee, deletePayee: rawDeletePayee, deleteAccount: rawDeleteAccount, getRules: rawGetRules, createRule: rawCreateRule, updateRule: rawUpdateRule, deleteRule: rawDeleteRule, setBudgetCarryover: rawSetBudgetCarryover, closeAccount: rawCloseAccount, reopenAccount: rawReopenAccount, getCategoryGroups: rawGetCategoryGroups, createCategoryGroup: rawCreateCategoryGroup, updateCategoryGroup: rawUpdateCategoryGroup, deleteCategoryGroup: rawDeleteCategoryGroup, mergePayees: rawMergePayees, getPayeeRules: rawGetPayeeRules, batchBudgetUpdates: rawBatchBudgetUpdates, holdBudgetForNextMonth: rawHoldBudgetForNextMonth, resetBudgetHold: rawResetBudgetHold, runQuery: rawRunQuery, runBankSync: rawRunBankSync, getBudgets: rawGetBudgets, getIDByName: rawGetIDByName, getServerVersion: rawGetServerVersion, getSchedules: rawGetSchedules, createSchedule: rawCreateSchedule, updateSchedule: rawUpdateSchedule, deleteSchedule: rawDeleteSchedule, getTags: rawGetTags, createTag: rawCreateTag, updateTag: rawUpdateTag, deleteTag: rawDeleteTag, getNote: rawGetNote, updateNote: rawUpdateNote, exportBudget: rawExportBudget, importBudget: rawImportBudget, getPreferences: rawGetPreferences, getAccountGroups: rawGetAccountGroups, createAccountGroup: rawCreateAccountGroup, updateAccountGroup: rawUpdateAccountGroup, deleteAccountGroup: rawDeleteAccountGroup,
8
+ const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, importTransactions: rawImportTransactions, getTransactions: rawGetTransactions, getCategories: rawGetCategories, createCategory: rawCreateCategory, getPayees: rawGetPayees, getCommonPayees: rawGetCommonPayees, createPayee: rawCreatePayee, getBudgetMonths: rawGetBudgetMonths, getBudgetMonth: rawGetBudgetMonth, setBudgetAmount: rawSetBudgetAmount, createAccount: rawCreateAccount, updateAccount: rawUpdateAccount, getAccountBalance: rawGetAccountBalance, updateTransaction: rawUpdateTransaction, deleteTransaction: rawDeleteTransaction, updateCategory: rawUpdateCategory, deleteCategory: rawDeleteCategory, updatePayee: rawUpdatePayee, deletePayee: rawDeletePayee, deleteAccount: rawDeleteAccount, getRules: rawGetRules, createRule: rawCreateRule, updateRule: rawUpdateRule, deleteRule: rawDeleteRule, setBudgetCarryover: rawSetBudgetCarryover, closeAccount: rawCloseAccount, reopenAccount: rawReopenAccount, getCategoryGroups: rawGetCategoryGroups, createCategoryGroup: rawCreateCategoryGroup, updateCategoryGroup: rawUpdateCategoryGroup, deleteCategoryGroup: rawDeleteCategoryGroup, mergePayees: rawMergePayees, getPayeeRules: rawGetPayeeRules, holdBudgetForNextMonth: rawHoldBudgetForNextMonth, resetBudgetHold: rawResetBudgetHold, runQuery: rawRunQuery, runBankSync: rawRunBankSync, getBudgets: rawGetBudgets, getIDByName: rawGetIDByName, getServerVersion: rawGetServerVersion, getSchedules: rawGetSchedules, createSchedule: rawCreateSchedule, updateSchedule: rawUpdateSchedule, deleteSchedule: rawDeleteSchedule, getTags: rawGetTags, createTag: rawCreateTag, updateTag: rawUpdateTag, deleteTag: rawDeleteTag, getNote: rawGetNote, updateNote: rawUpdateNote, exportBudget: rawExportBudget, importBudget: rawImportBudget, getPreferences: rawGetPreferences, getAccountGroups: rawGetAccountGroups, createAccountGroup: rawCreateAccountGroup, updateAccountGroup: rawUpdateAccountGroup, deleteAccountGroup: rawDeleteAccountGroup,
9
9
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
10
  } = api;
11
11
  import { EventEmitter } from 'events';
@@ -1878,17 +1878,29 @@ export async function setBudgetAmount(month, categoryId, amount) {
1878
1878
  * full Playwright timeout when the upstream server's mutator queue stalled
1879
1879
  * between cycles. The month read and the funds check run before either write.
1880
1880
  *
1881
- * NOT atomic and no rollback (#518). Both writes run inside
1882
- * `rawBatchBudgetUpdates`, upstream's `batch-budget-start` /
1883
- * `batch-budget-end` bracket around `batchMessages`, which batches sync
1884
- * messages; it is not a database transaction. If the second write fails, the
1885
- * first stays applied, so a partial transfer is possible. A returned result
1886
- * means both writes were queued, not that both landed: `batch-budget-start`
1887
- * runs `batchMessages` un-awaited and `batch-budget-end` returns before the
1888
- * queued messages are applied, so a later apply failure is a detached
1889
- * rejection that is never returned to the caller (#539). The upstream trace is the
1890
- * `actual_budget_updates_batch` row of `docs/audit/write-effect-audit.md`,
1891
- * which shares this bracket (#516).
1881
+ * NOT atomic and no rollback (#518). The two writes are separate raw calls,
1882
+ * each awaited on its own: outside a batch bracket upstream applies each
1883
+ * write (`_sendMessages` then `applyMessages`) before the call returns and
1884
+ * rethrows an apply failure to us (read from the shipped upstream source, not
1885
+ * verified here by a read-back), so an apply failure is returned rather than
1886
+ * detached. If the second write fails or is not attempted, the first stays
1887
+ * applied and the thrown error says so (see below).
1888
+ *
1889
+ * COOPERATIVE DEADLINE between the two writes, for the same reason as
1890
+ * `setBudgetBatch`: `withOpTimeout` cannot cancel this callback, so without the
1891
+ * check an abandoned callback would still make the second write after the
1892
+ * caller was told the operation timed out.
1893
+ *
1894
+ * WHY NO `batchBudgetUpdates` BRACKET (#539). Upstream `api/batch-budget-start`
1895
+ * runs `batchMessages` un-awaited and `api/batch-budget-end` returns before the
1896
+ * queued messages are applied, so an apply failure under the bracket is a
1897
+ * rejection on a promise nobody holds: it reaches the `unhandledRejection`
1898
+ * handler and exits the server, after the tool already reported success. The
1899
+ * bracket did apply both writes in one sqlite transaction, so dropping it trades
1900
+ * that all-or-nothing apply for a partial transfer that is REPORTED; upstream
1901
+ * has no awaited bracket that keeps both. Do not reintroduce it (a unit test
1902
+ * guards this). The upstream trace is the `actual_budgets_transfer` and
1903
+ * `actual_budget_updates_batch` rows of `docs/audit/write-effect-audit.md`.
1892
1904
  */
1893
1905
  export async function transferBudgetAmount(month, fromCategoryId, toCategoryId, amount) {
1894
1906
  observability.incrementToolCall('actual.budgets.transfer').catch(() => { });
@@ -1896,6 +1908,9 @@ export async function transferBudgetAmount(month, fromCategoryId, toCategoryId,
1896
1908
  // Inside processWriteQueue we already hold the api mutex in apiLock.ts and the api is
1897
1909
  // initialised. Call raw functions only: adapter wrappers would re-enter
1898
1910
  // queueWriteOperation / withActualApi and defeat the single-cycle goal.
1911
+ // The deadline clock starts here, before the read, because withOpTimeout times the whole
1912
+ // callback: starting it at the first write would let a slow read hide (#539 review round 2).
1913
+ const startedAt = Date.now();
1899
1914
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1900
1915
  const budgetMonth = await rawGetBudgetMonth(month);
1901
1916
  if (!budgetMonth?.categoryGroups) {
@@ -1916,10 +1931,31 @@ export async function transferBudgetAmount(month, fromCategoryId, toCategoryId,
1916
1931
  if (prevFrom < amount) {
1917
1932
  throw new Error(`Insufficient budget in source category. Available: ${prevFrom}, Requested: ${amount}`);
1918
1933
  }
1919
- await rawBatchBudgetUpdates(async () => {
1920
- await rawSetBudgetAmount(month, fromCategoryId, prevFrom - amount);
1921
- await rawSetBudgetAmount(month, toCategoryId, prevTo + amount);
1922
- });
1934
+ // No retry on either write: a replayed amount write after a partial apply is not safe to
1935
+ // reason about, and the client re-reads instead.
1936
+ await withConcurrency(() => rawSetBudgetAmount(month, fromCategoryId, prevFrom - amount));
1937
+ const sourceDone = `Partial transfer: the source category ${fromCategoryId} was set from ${prevFrom} to ${prevFrom - amount}`;
1938
+ const readBack = 'Read back with actual_budgets_getMonth before retrying.';
1939
+ const deadlineMs = config.ACTUAL_OP_TIMEOUT_MS > 0 ? config.ACTUAL_OP_TIMEOUT_MS * BUDGET_BATCH_DEADLINE_FRACTION : 0;
1940
+ if (deadlineMs > 0 && Date.now() - startedAt > deadlineMs) {
1941
+ throw new Error(`${sourceDone}, but the target category ${toCategoryId} was not attempted (${BUDGET_BATCH_DEADLINE_ERROR}), ` +
1942
+ `so it is unchanged at ${prevTo}. ${readBack}`);
1943
+ }
1944
+ try {
1945
+ await withConcurrency(() => rawSetBudgetAmount(month, toCategoryId, prevTo + amount));
1946
+ }
1947
+ catch (error) {
1948
+ const msg = error instanceof Error ? error.message : String(error);
1949
+ // An infrastructure error says nothing about whether the write landed, so only a
1950
+ // deterministic (domain) rejection may claim the target is unchanged.
1951
+ const target = _shouldDropPoolOnError(error)
1952
+ ? `its outcome is unknown (it was ${prevTo})`
1953
+ : `it is unchanged at ${prevTo}`;
1954
+ // The original text stays INSIDE the message: isRetryableError classifies by message only
1955
+ // and never reads `cause`, and the write queue's self-heal (_shouldDropPoolOnError) depends
1956
+ // on that classification. Same reason as abortBatchOnInfrastructureError.
1957
+ throw new Error(`${sourceDone}, but setting the target category ${toCategoryId} failed, so ${target}: ${msg}. ${readBack}`, { cause: error });
1958
+ }
1923
1959
  return {
1924
1960
  transferred: amount,
1925
1961
  fromCategory: { id: fromCategoryId, previousAmount: prevFrom, newAmount: prevFrom - amount },
@@ -3013,23 +3049,32 @@ export async function getPayeeRules(payeeId) {
3013
3049
  }
3014
3050
  /** Error recorded for every item the cooperative deadline stopped before it was written. */
3015
3051
  export const BUDGET_BATCH_DEADLINE_ERROR = 'not attempted: batch time budget exhausted';
3016
- /** Fraction of ACTUAL_OP_TIMEOUT_MS the item loop may consume; the rest is left for batch-budget-end and the sync. */
3052
+ /** Fraction of ACTUAL_OP_TIMEOUT_MS the item loop may consume; the rest is left for the trailing sync. */
3017
3053
  const BUDGET_BATCH_DEADLINE_FRACTION = 0.75;
3018
3054
  /**
3019
3055
  * #516: set budget amounts and/or carryover flags for many (month, category) pairs in ONE
3020
3056
  * write cycle, with a verdict per item. NOT atomic and no rollback: items applied before a
3021
- * failure stay applied. The bracket is upstream's `batchMessages` (it batches sync messages),
3022
- * it is not a database transaction.
3057
+ * failure stay applied.
3058
+ *
3059
+ * NO `batchBudgetUpdates` BRACKET (#539). Each raw write is awaited on its own. Outside a
3060
+ * bracket upstream applies the write (`_sendMessages` then `applyMessages`) before the call
3061
+ * returns and rethrows an apply failure (read from the shipped upstream source, not verified by
3062
+ * a read-back), so `succeeded` means the write call returned without error and a failed
3063
+ * apply lands on that item's verdict. Under the bracket (#516 to #539) upstream applied the
3064
+ * queued writes AFTER `batch-budget-end` returned, on a promise it never awaits, so an apply
3065
+ * failure was an unhandled rejection that exited the server after we had reported success. The
3066
+ * cost is one apply per write instead of one per batch; the server sync is a debounced timer
3067
+ * (`scheduleFullSync`), so it still runs once, except under NODE_ENV=test where upstream syncs on
3068
+ * every write. Do not reintroduce the bracket (a unit test guards this).
3023
3069
  *
3024
3070
  * TWO PHASES inside one `queueWriteOperation`:
3025
- * 1. Before the bracket: read the categories listing and the budget months ONCE each, then
3071
+ * 1. Before any write: read the categories listing and the budget months ONCE each, then
3026
3072
  * decide a typed refusal per item. The guards apply to EVERY item, a carryover-only item
3027
3073
  * included, because upstream `api/budget-set-amount` validates neither category nor month
3028
3074
  * (it INSERTs an orphan `<month>-<category>` row) and the two fields should refuse alike.
3029
- * 2. ONE `rawBatchBudgetUpdates` whose callback holds only raw writes for the items that
3030
- * passed. Every item is caught INSIDE the callback so no item error escapes the bracket
3031
- * (upstream closes it in a `finally`, but an escaping error would also lose the verdicts).
3032
- * Only `raw*` calls run there: an `adapter.*` call would nest the api lock and deadlock.
3075
+ * 2. Raw writes for the items that passed, each caught per item so one failure does not lose
3076
+ * the other verdicts. Only `raw*` calls run here: an `adapter.*` call would nest the api
3077
+ * lock and deadlock.
3033
3078
  *
3034
3079
  * INCOME CATEGORIES (maintainer decision): a carryover item whose category is an income
3035
3080
  * category is refused in phase 1. Upstream `api/budget-set-carryover` rejects income
@@ -3037,18 +3082,18 @@ const BUDGET_BATCH_DEADLINE_FRACTION = 0.75;
3037
3082
  * this check an item setting both would half-apply. Upstream's own rejection stays as a
3038
3083
  * backstop and is reported per item.
3039
3084
  *
3040
- * RETRY: none. A refusal must never replay and a replayed partial bracket could double-apply;
3041
- * the client re-reads (`actual_budgets_getMonth`) and retries instead.
3085
+ * RETRY: none. A refusal must never replay, an apply failure is deterministic, a retry would
3086
+ * spend the deadline, and infrastructure errors abort the batch instead; the client re-reads
3087
+ * (`actual_budgets_getMonth`) and retries.
3042
3088
  *
3043
3089
  * COOPERATIVE DEADLINE. The whole operation runs under one `withOpTimeout`, a `Promise.race`
3044
- * that cannot cancel the callback. A callback abandoned mid-bracket keeps writing and leaks
3045
- * upstream's `IS_BATCHING` / `batchPromise` globals to later operations (a later batch-start
3046
- * throws "batch already started"). So the loop checks, before each item write, the time since
3047
- * this operation started against 75% of ACTUAL_OP_TIMEOUT_MS (skipped when it is 0, the
3048
- * documented "no bound" setting) and stops writing once it is exceeded; the remaining items go
3049
- * to `failed`. The 25% left over covers batch-budget-end and the sync.
3090
+ * that cannot cancel the callback, so an abandoned loop would keep writing after the caller was
3091
+ * told it timed out (as in `createRulesBatch`). So the loop checks, before each item write, the
3092
+ * time since this operation started against 75% of ACTUAL_OP_TIMEOUT_MS (skipped when it is 0,
3093
+ * the documented "no bound" setting) and stops writing once it is exceeded; the remaining items
3094
+ * go to `failed`. The 25% left over covers the drain's trailing sync.
3050
3095
  * WHY NOT `queueWriteOperation`'s `timeoutMs` option: it would LENGTHEN the hold on the
3051
- * process-global api lock, and it still would not close the bracket on abandonment.
3096
+ * process-global api lock, and it still would not stop an abandoned loop from writing.
3052
3097
  *
3053
3098
  * `preservesListings` claim is safe: only raw budget amount and carryover writes run here.
3054
3099
  */
@@ -3058,7 +3103,7 @@ export async function setBudgetBatch(items) {
3058
3103
  const startedAt = Date.now();
3059
3104
  const succeeded = [];
3060
3105
  const failed = [];
3061
- // Phase 1: reads, then refusal decisions. Both reads happen BEFORE the bracket opens.
3106
+ // Phase 1: reads, then refusal decisions. Both reads happen BEFORE the first write.
3062
3107
  const categories = await withConcurrency(() => retry(() => readDrainListing('categories', () => rawGetCategories()), { retries: 2, backoffMs: 200 }));
3063
3108
  const categoryById = new Map();
3064
3109
  for (const c of categories)
@@ -3085,43 +3130,38 @@ export async function setBudgetBatch(items) {
3085
3130
  else
3086
3131
  toWrite.push({ index, item });
3087
3132
  });
3088
- // Phase 2: one bracket, raw writes only.
3089
- if (toWrite.length > 0) {
3090
- const deadlineMs = config.ACTUAL_OP_TIMEOUT_MS > 0 ? config.ACTUAL_OP_TIMEOUT_MS * BUDGET_BATCH_DEADLINE_FRACTION : 0;
3091
- await withConcurrency(() => rawBatchBudgetUpdates(async () => {
3092
- for (const { index, item } of toWrite) {
3093
- const ref = { index, month: item.month, categoryId: item.categoryId };
3094
- if (deadlineMs > 0 && Date.now() - startedAt > deadlineMs) {
3095
- failed.push({ ...ref, error: BUDGET_BATCH_DEADLINE_ERROR });
3096
- continue;
3097
- }
3098
- let amountWritten = false;
3099
- try {
3100
- if (item.amount !== undefined) {
3101
- await rawSetBudgetAmount(item.month, item.categoryId, item.amount);
3102
- amountWritten = true;
3103
- }
3104
- if (item.carryover !== undefined) {
3105
- await rawSetBudgetCarryover(item.month, item.categoryId, item.carryover);
3106
- }
3107
- succeeded.push(ref);
3108
- }
3109
- catch (error) {
3110
- const msg = error instanceof Error ? error.message : String(error);
3111
- // Infrastructure errors stop the batch (see abortBatchOnInfrastructureError);
3112
- // the throw leaves the bracket and upstream closes it in a `finally`.
3113
- abortBatchOnInfrastructureError(error, {
3114
- label: 'Budget',
3115
- appliedCount: succeeded.length,
3116
- total: items.length,
3117
- applied: succeeded.map((x) => x.index).join(', '),
3118
- message: msg,
3119
- readBackTool: 'actual_budgets_getMonth',
3120
- });
3121
- failed.push({ ...ref, error: amountWritten ? `amount was applied, then the carryover failed: ${msg}` : msg });
3122
- }
3133
+ // Phase 2: raw writes only, each awaited on its own (no bracket, see #539 above).
3134
+ const deadlineMs = config.ACTUAL_OP_TIMEOUT_MS > 0 ? config.ACTUAL_OP_TIMEOUT_MS * BUDGET_BATCH_DEADLINE_FRACTION : 0;
3135
+ for (const { index, item } of toWrite) {
3136
+ const ref = { index, month: item.month, categoryId: item.categoryId };
3137
+ if (deadlineMs > 0 && Date.now() - startedAt > deadlineMs) {
3138
+ failed.push({ ...ref, error: BUDGET_BATCH_DEADLINE_ERROR });
3139
+ continue;
3140
+ }
3141
+ let amountWritten = false;
3142
+ try {
3143
+ if (item.amount !== undefined) {
3144
+ await withConcurrency(() => rawSetBudgetAmount(item.month, item.categoryId, item.amount));
3145
+ amountWritten = true;
3123
3146
  }
3124
- }));
3147
+ if (item.carryover !== undefined) {
3148
+ await withConcurrency(() => rawSetBudgetCarryover(item.month, item.categoryId, item.carryover));
3149
+ }
3150
+ succeeded.push(ref);
3151
+ }
3152
+ catch (error) {
3153
+ const msg = error instanceof Error ? error.message : String(error);
3154
+ // Infrastructure errors stop the batch (see abortBatchOnInfrastructureError).
3155
+ abortBatchOnInfrastructureError(error, {
3156
+ label: 'Budget',
3157
+ appliedCount: succeeded.length,
3158
+ total: items.length,
3159
+ applied: succeeded.map((x) => x.index).join(', '),
3160
+ message: msg,
3161
+ readBackTool: 'actual_budgets_getMonth',
3162
+ });
3163
+ failed.push({ ...ref, error: amountWritten ? `amount was applied, then the carryover failed: ${msg}` : msg });
3164
+ }
3125
3165
  }
3126
3166
  succeeded.sort((a, b) => a.index - b.index);
3127
3167
  failed.sort((a, b) => a.index - b.index);
@@ -0,0 +1,105 @@
1
+ // src/server/httpErrorHandler.ts
2
+ //
3
+ // #543: the final Express error layer for the HTTP transport. Without it, an error falls
4
+ // through to Express's default handler, and from express 5.3.0 that handler logs the WHOLE
5
+ // error object with console.error (5.2.1 logged err.stack). body-parser 2.3.0 attaches the
6
+ // raw request text to a JSON parse error as `err.body`, and express.json runs before any
7
+ // auth, so an unauthenticated malformed POST would put its raw body in the logs. The default
8
+ // handler also renders the stack into the response under NODE_ENV=development.
9
+ //
10
+ // Contract:
11
+ // - The response NEVER carries err.message, err.body or err.stack, whatever NODE_ENV is.
12
+ // - The log NEVER carries request body content: not err.body, and not err.message either,
13
+ // because the V8 JSON SyntaxError message quotes the input text. Only an allowlist of
14
+ // fields is logged.
15
+ // - next(err) is NEVER called: app.handle wires finalhandler with onerror: logerror, which
16
+ // is the console.error(err) path this module exists to close.
17
+ //
18
+ // The handler also absorbs any non-MCPAuth error that mcp-auth's bearerAuth rethrows, with
19
+ // the same fixed 500 and the same allowlisted log.
20
+ import { STATUS_CODES } from 'node:http';
21
+ import { createModuleLogger } from '../lib/loggerFactory.js';
22
+ import { resolveRequestId } from '../logger.js';
23
+ const log = createModuleLogger('HTTP');
24
+ const JSONRPC_PARSE_ERROR = -32700;
25
+ const JSONRPC_INVALID_REQUEST = -32600;
26
+ const JSONRPC_INTERNAL_ERROR = -32603;
27
+ /**
28
+ * True when the request targets the MCP endpoint: the pathname of `originalUrl` (the query
29
+ * string removed) equals `mcpPath` or starts with `mcpPath + '/'`. Never a bare prefix match:
30
+ * with an MCP path of `/mcp`, `/mcp-info` is NOT the MCP endpoint and gets the plain shape.
31
+ */
32
+ function isMcpRequestPath(originalUrl, mcpPath) {
33
+ const q = originalUrl.indexOf('?');
34
+ const pathname = q === -1 ? originalUrl : originalUrl.slice(0, q);
35
+ const base = mcpPath.length > 1 && mcpPath.endsWith('/') ? mcpPath.slice(0, -1) : mcpPath;
36
+ // A root MCP path ('/') makes every request an MCP request.
37
+ if (base === '/' || base === '')
38
+ return true;
39
+ return pathname === base || pathname.startsWith(`${base}/`);
40
+ }
41
+ export function createHttpErrorHandler({ mcpPath }) {
42
+ // Four parameters: Express identifies an error handler by its arity.
43
+ return function httpErrorHandler(err, req, res, _next) {
44
+ const e = err !== null && typeof err === 'object' ? err : {};
45
+ const type = typeof e.type === 'string' ? e.type : undefined;
46
+ // The ticket's first cut mapped everything except parse and size errors to 500. That was
47
+ // reversed in review: body-parser raises other CLIENT errors before auth (unsupported
48
+ // charset or encoding: 415; a corrupt gzip body or an aborted request: 400), each marked
49
+ // expose with its own 4xx status. Reporting those as 500 and logging them at error level
50
+ // let any caller fire 5xx alerts at will. So an exposed 4xx keeps its status; only the
51
+ // status is taken from the error, never its message.
52
+ let status = 500;
53
+ if (type === 'entity.parse.failed')
54
+ status = 400;
55
+ else if (type === 'entity.too.large')
56
+ status = 413;
57
+ else {
58
+ const s = typeof e.status === 'number' ? e.status : e.statusCode;
59
+ if (e.expose === true && Number.isInteger(s) && s >= 400 && s <= 499)
60
+ status = s;
61
+ }
62
+ const mcp = isMcpRequestPath(req.originalUrl || req.url || '', mcpPath);
63
+ const meta = {
64
+ type,
65
+ status,
66
+ method: req.method,
67
+ // The pathname only: a query string can carry anything.
68
+ path: (req.originalUrl || '').split('?')[0],
69
+ requestId: resolveRequestId(req.get('x-correlation-id')),
70
+ };
71
+ if (status === 500) {
72
+ if (typeof e.name === 'string')
73
+ meta.errorName = e.name;
74
+ if (typeof e.code === 'string' || typeof e.code === 'number')
75
+ meta.errorCode = e.code;
76
+ }
77
+ if (res.headersSent) {
78
+ log.warn('Request error after the response started; closing the connection', meta);
79
+ req.socket?.destroy();
80
+ return;
81
+ }
82
+ if (status === 500)
83
+ log.error('Unhandled request error', undefined, meta);
84
+ else
85
+ log.warn('Rejected request body', meta);
86
+ let message = 'Internal error';
87
+ if (type === 'entity.parse.failed')
88
+ message = mcp ? 'Parse error' : 'Invalid JSON body';
89
+ else if (status === 413)
90
+ message = 'Request body too large';
91
+ else if (status !== 500)
92
+ message = STATUS_CODES[status] ?? 'Bad Request';
93
+ if (mcp) {
94
+ let code = JSONRPC_INVALID_REQUEST;
95
+ if (type === 'entity.parse.failed')
96
+ code = JSONRPC_PARSE_ERROR;
97
+ else if (status === 500)
98
+ code = JSONRPC_INTERNAL_ERROR;
99
+ res.status(status).json({ jsonrpc: '2.0', error: { code, message }, id: null });
100
+ }
101
+ else {
102
+ res.status(status).json({ error: message });
103
+ }
104
+ };
105
+ }
@@ -35,6 +35,7 @@ import { buildToolListEntries } from '../lib/tool-list-entry.js';
35
35
  // transports can reach it (stdio could not, which was the whole of #452). Re-exported here
36
36
  // unchanged so existing importers and tests are unaffected.
37
37
  import { classifyInitFailure } from '../lib/init-failure.js';
38
+ import { createHttpErrorHandler } from './httpErrorHandler.js';
38
39
  export { classifyInitFailure };
39
40
  export { requestContext };
40
41
  // Resolve the authenticated principal for the per-principal budget preference
@@ -821,9 +822,17 @@ advertisedUrl) {
821
822
  res.status(204).end();
822
823
  return;
823
824
  }
824
- res.setHeader('Content-Type', 'text/plain; version=0.0.4');
825
+ // #543: the charset is spelled out because res.send appends it, and express 5.3.0
826
+ // appends it AFTER version=0.0.4 where 5.2.1 put it before. Stated in full, the header
827
+ // is passed through unchanged on both, so scrapers see the same value.
828
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8; version=0.0.4');
825
829
  res.send(txt);
826
830
  });
831
+ // #543: the FINAL layer, mounted globally (express.json above is global). Without it an
832
+ // error reaches Express's default handler, which from express 5.3.0 logs the whole error
833
+ // object, raw request body included, and under NODE_ENV=development renders the stack.
834
+ // Keep this the last app.use: anything registered after it is never reached on error.
835
+ app.use(createHttpErrorHandler({ mcpPath: httpPath }));
827
836
  // config validation (#169) guarantees both paths are set when HTTPS is on, so
828
837
  // the non-null assertions are safe. Wrap the reads so a missing/unreadable
829
838
  // file fails with an actionable message naming the env vars and paths instead
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.22.20",
4
+ "version": "0.22.22",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"
@@ -30,7 +30,7 @@
30
30
  "verify-tools": "npm run build && node scripts/verify-tools.js",
31
31
  "check:coverage": "node scripts/list-actual-api-methods.mjs",
32
32
  "direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
33
- "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.test.js && node tests/unit/generated_tools.smoke.test.js &&node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/new_tool_completeness.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/filter_id_resolution.test.js && node tests/unit/filter_id_tool_wiring.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/adapter_set_budget_batch.test.js && node tests/unit/budget_updates_batch.test.js && node tests/unit/rules_create.test.js && node tests/unit/adapter_rule_reference_guard.test.js && node tests/unit/rules_create_batch.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/httpServer_init_failure_report.test.js && node tests/unit/stdio_init_failure_report.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/session_ownership.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/oidc_resource_identifier.test.js && node tests/unit/httpServer_oidc_resource_metadata.test.js && node tests/unit/oidc_error_cause.test.js && node tests/unit/httpServer_oidc_error_body.test.js && node tests/unit/oidc_token_source.test.js && node tests/unit/config_oidc_assertion_refines.test.js && node tests/unit/httpServer_oidc_assertion_mode.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_batch_cap.test.js && node tests/unit/dotenv_stdout_quiet.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/transactions_update_split_convert.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/transactions_update_batch.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/playwright_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/batch_uncategorized_cleanup.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/server_version_probe_outside_op_timeout.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js && node tests/unit/oidc_scope_list.test.js && node tests/unit/get_context.test.js && node tests/unit/contributor_docs.test.js && node tests/unit/toolsets.test.js && node tests/unit/toolsets_entrypoint.test.js && node tests/unit/tools_list_size_budget.test.js && node tests/unit/rules_update_schedules_schema.test.js && node tests/unit/source_text_helper.test.js && node tests/unit/transport_onerror.test.js && node tests/unit/adapter_transaction_batch_abort.test.js",
33
+ "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.test.js && node tests/unit/generated_tools.smoke.test.js &&node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/new_tool_completeness.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/filter_id_resolution.test.js && node tests/unit/filter_id_tool_wiring.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/adapter_set_budget_batch.test.js && node tests/unit/budget_updates_batch.test.js && node tests/unit/rules_create.test.js && node tests/unit/adapter_rule_reference_guard.test.js && node tests/unit/rules_create_batch.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/httpServer_init_failure_report.test.js && node tests/unit/stdio_init_failure_report.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/session_ownership.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/oidc_resource_identifier.test.js && node tests/unit/httpServer_oidc_resource_metadata.test.js && node tests/unit/oidc_error_cause.test.js && node tests/unit/httpServer_oidc_error_body.test.js && node tests/unit/oidc_token_source.test.js && node tests/unit/config_oidc_assertion_refines.test.js && node tests/unit/httpServer_oidc_assertion_mode.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpErrorHandler.test.js && node tests/unit/httpServer_error_handler_wiring.test.js && node tests/unit/httpServer_trust_proxy.test.js && node tests/unit/httpServer_batch_cap.test.js && node tests/unit/dotenv_stdout_quiet.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/transactions_update_split_convert.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/transactions_update_batch.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/playwright_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/batch_uncategorized_cleanup.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/server_version_probe_outside_op_timeout.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js && node tests/unit/oidc_scope_list.test.js && node tests/unit/get_context.test.js && node tests/unit/contributor_docs.test.js && node tests/unit/toolsets.test.js && node tests/unit/toolsets_entrypoint.test.js && node tests/unit/tools_list_size_budget.test.js && node tests/unit/rules_update_schedules_schema.test.js && node tests/unit/source_text_helper.test.js && node tests/unit/transport_onerror.test.js && node tests/unit/adapter_transaction_batch_abort.test.js",
34
34
  "test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
35
35
  "test:e2e": "npx playwright test",
36
36
  "test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",
@@ -72,7 +72,7 @@
72
72
  "@modelcontextprotocol/sdk": "^1.32.1",
73
73
  "debug": "^4.4.3",
74
74
  "dotenv": "^18.0.6",
75
- "express": "^5.2.1",
75
+ "express": "^5.3.0",
76
76
  "fuse.js": "^7.4.2",
77
77
  "jose": "^6.2.12",
78
78
  "mcp-auth": "^0.2.0",
@@ -87,7 +87,7 @@
87
87
  "@playwright/test": "^1.63.0",
88
88
  "@types/express": "^5.0.3",
89
89
  "@types/node": "^26.6.4",
90
- "knip": "^6.40.0",
90
+ "knip": "^6.41.0",
91
91
  "node-fetch": "^3.3.2",
92
92
  "tsconfig-paths": "^4.2.0",
93
93
  "typescript": "^7.0.2"