actual-mcp-server 0.24.15 → 0.25.1

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
@@ -21,6 +21,7 @@ A [Model Context Protocol](https://modelcontextprotocol.io/) server that lets an
21
21
 
22
22
  - **Claude Desktop, Claude Code, Cursor, VS Code or another local client:** run it over stdio with `npx`, no server to keep running. Follow your client's page in [docs/guides/clients/](docs/guides/clients/README.md), for example [Claude Desktop](docs/guides/clients/claude-desktop.md) or [Claude Code](docs/guides/clients/claude-code.md).
23
23
  - **A chat UI such as LibreChat or LobeChat, or several users:** run it over HTTP in Docker with the quick start below, then follow [LibreChat](docs/guides/clients/librechat.md) or [LobeChat](docs/guides/clients/lobechat.md).
24
+ - **A client that installs from the official [MCP Registry](https://registry.modelcontextprotocol.io/):** it is listed as `io.github.agigante80/actual-mcp-server`, with the npm package (stdio) and the Docker image (HTTP); the client asks for the same settings as the pages above.
24
25
  - **Unraid:** install it from [Community Applications](https://ca.unraid.net/apps/actual-mcp-server-0bghkvs0c7c8bg); see [Unraid](docs/guides/deployment.md#unraid-and-puidpgid-227).
25
26
 
26
27
  ## Quick start (Docker, HTTP)
@@ -168,4 +169,4 @@ MIT, see [LICENSE](LICENSE). Built on [Actual Budget](https://actualbudget.org/)
168
169
 
169
170
  ---
170
171
 
171
- **Version:** 0.24.15 | **Tool Count:** 84 (verified LibreChat-compatible)
172
+ **Version:** 0.25.1 | **Tool Count:** 84 (verified LibreChat-compatible)
@@ -2,6 +2,10 @@ import { z } from 'zod';
2
2
  import { parseIdentityMap } from './auth/identity-map.js';
3
3
  import { parseBudgetAcl } from './auth/budget-acl-parse.js';
4
4
  import { parseStaticTokensConfig, staticTokensConfigured } from './auth/static-tokens.js';
5
+ /** #612: MCP_ALLOWED_HOSTS as a list: comma-separated, trimmed, lowercased, empties dropped. */
6
+ export function parseAllowedHostsList(value) {
7
+ return (value ?? '').split(',').map((h) => h.trim().toLowerCase()).filter(Boolean);
8
+ }
5
9
  // #466: the byte count body-parser will ACTUALLY enforce for MCP_HTTP_BODY_LIMIT, or
6
10
  // null when the value must be refused. Mirrors `bytes.parse` (what body-parser uses)
7
11
  // without importing that transitive package: floor(number * unit), units b and kb
@@ -126,6 +130,31 @@ export const configSchema = z.object({
126
130
  // so a typo like 'yes'/'1' cannot silently leave the server open.
127
131
  MCP_ALLOW_UNAUTHENTICATED: z.string().optional().transform(val => val === 'true'),
128
132
  MCP_ENABLE_HTTPS: z.string().optional().transform(val => val === 'true'),
133
+ // #612: extra hostnames the HTTP server accepts as Host (and as Origin in open
134
+ // mode), comma-separated, additive to localhost, 127.0.0.1 and [::1]. Each entry
135
+ // must already be in the form new URL() normalises a hostname to: no port, path,
136
+ // scheme or wildcard, IPv6 bracketed, IPv4 dotted. Parsed to a lowercased array.
137
+ MCP_ALLOWED_HOSTS: z
138
+ .string()
139
+ .optional()
140
+ .superRefine((v, ctx) => {
141
+ for (const entry of parseAllowedHostsList(v)) {
142
+ let normalised = null;
143
+ try {
144
+ normalised = new URL(`http://${entry}`).hostname;
145
+ }
146
+ catch {
147
+ // reported below
148
+ }
149
+ if (entry.includes('*') || normalised !== entry) {
150
+ ctx.addIssue({
151
+ code: 'custom',
152
+ message: `MCP_ALLOWED_HOSTS entry "${entry}" is not a bare hostname (no port, path, scheme or wildcard; IPv6 in brackets)`,
153
+ });
154
+ }
155
+ }
156
+ })
157
+ .transform(parseAllowedHostsList),
129
158
  MCP_HTTPS_CERT: z.string().optional(),
130
159
  MCP_HTTPS_KEY: z.string().optional(),
131
160
  // Explicit cap on incoming JSON request bodies (#168). Passed to
package/dist/src/index.js CHANGED
@@ -191,7 +191,7 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
191
191
  import('./actualConnection.js'),
192
192
  import('./lib/ActualMCPConnection.js'),
193
193
  ]);
194
- const [{ startHttpServer }, { startStdioServer }, { enforceHttpAuthPosture }, loggerModule, utilsModule, actualToolsManagerModule,] = await Promise.all([
194
+ const [{ startHttpServer }, { startStdioServer }, { enforceHttpAuthPosture, findWeakTokenVars }, loggerModule, utilsModule, actualToolsManagerModule,] = await Promise.all([
195
195
  import('./server/httpServer.js'),
196
196
  import('./server/stdioServer.js'),
197
197
  import('./lib/authPosture.js'),
@@ -301,13 +301,16 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
301
301
  // explicit opt-out this refuses startup (exit 1) rather than silently serving
302
302
  // financial data open on the LAN. stdio mode never reaches this check.
303
303
  if (useHttp || useTestMcpClient) {
304
+ const oidcEnabled = process.env.AUTH_PROVIDER === 'oidc';
304
305
  const decision = enforceHttpAuthPosture({
305
306
  bindHost,
306
307
  // #572: MCP_STATIC_TOKENS is authentication too. A set-but-unresolvable value
307
308
  // still counts here: startHttpServer refuses it before binding.
308
309
  hasStaticToken: !!process.env.MCP_SSE_AUTHORIZATION || !!process.env.MCP_STATIC_TOKENS?.trim(),
309
- oidcEnabled: process.env.AUTH_PROVIDER === 'oidc',
310
+ oidcEnabled,
310
311
  allowUnauthenticated: process.env.MCP_ALLOW_UNAUTHENTICATED === 'true',
312
+ // #611: a short or placeholder token refuses a non-loopback bind.
313
+ weakTokenVars: findWeakTokenVars(process.env, { oidcEnabled }),
311
314
  });
312
315
  // enforceHttpAuthPosture already called process.exit(1) on 'refuse'. This
313
316
  // explicit return makes the open-start path structurally unreachable even
@@ -1,4 +1,5 @@
1
1
  import { createModuleLogger } from './loggerFactory.js';
2
+ import { MIN_TOKEN_LENGTH, parseStaticTokensConfig } from '../auth/static-tokens.js';
2
3
  /**
3
4
  * Auth posture gate (#242): make HTTP authentication required-by-default.
4
5
  *
@@ -40,14 +41,124 @@ export function isLoopbackHost(host) {
40
41
  return true;
41
42
  return false;
42
43
  }
44
+ /**
45
+ * #611: example values shipped in our docs and .env.example. A token equal to one
46
+ * of these (trimmed, case-insensitive) is weak whatever its length:
47
+ * `replace-with-openssl-rand-hex-32` is exactly 32 characters.
48
+ */
49
+ const PLACEHOLDER_TOKENS = new Set(['your-secure-token-here', 'replace-with-openssl-rand-hex-32', 'changeme']);
50
+ function isPlaceholder(value) {
51
+ return PLACEHOLDER_TOKENS.has(value.trim().toLowerCase());
52
+ }
53
+ /**
54
+ * #611: the names (never the values) of the configured bearer-token variables that
55
+ * are weak. Pure: reads only the `env` it is given.
56
+ *
57
+ * - Under OIDC nothing is checked: httpServer.ts builds no bearer entries there, so
58
+ * a stale MCP_SSE_AUTHORIZATION is not a credential.
59
+ * - MCP_SSE_AUTHORIZATION: weak when set, non-empty and shorter than
60
+ * MIN_TOKEN_LENGTH after trimming (whitespace-only included), or a placeholder.
61
+ * EMPTY stays the no-auth path (tests/e2e/compose.yaml runs open on it).
62
+ * - MCP_TOKEN_*: resolveStaticTokens already refuses a short value on any bind, so
63
+ * only placeholders are added here. An unparsable MCP_STATIC_TOKENS is left to
64
+ * resolveStaticTokens, which refuses it with its own message.
65
+ */
66
+ export function findWeakTokenVars(env, { oidcEnabled }) {
67
+ if (oidcEnabled)
68
+ return [];
69
+ const weak = [];
70
+ const legacy = env.MCP_SSE_AUTHORIZATION;
71
+ if (legacy && (legacy.trim().length < MIN_TOKEN_LENGTH || isPlaceholder(legacy))) {
72
+ weak.push('MCP_SSE_AUTHORIZATION');
73
+ }
74
+ let principals = {};
75
+ try {
76
+ principals = parseStaticTokensConfig(env.MCP_STATIC_TOKENS);
77
+ }
78
+ catch {
79
+ // Refused later by resolveStaticTokens, before bind.
80
+ }
81
+ for (const { tokenEnv } of Object.values(principals)) {
82
+ const value = env[tokenEnv];
83
+ if (value && isPlaceholder(value) && !weak.includes(tokenEnv))
84
+ weak.push(tokenEnv);
85
+ }
86
+ return weak;
87
+ }
88
+ /** #612: the hostnames every open-mode server accepts, in the SDK's bracketed form. */
89
+ const LOOPBACK_HOSTNAMES = ['localhost', '127.0.0.1', '[::1]'];
90
+ /**
91
+ * The hostname the SDK compares for `host` (a bind address or advertised name):
92
+ * lowercased, IPv6 bracketed and canonical. `undefined` for an empty or wildcard
93
+ * value, which names no single host.
94
+ */
95
+ function canonicalHostname(host) {
96
+ const h = host?.trim().toLowerCase();
97
+ if (!h || h === '0.0.0.0' || h === '::' || h === '[::]')
98
+ return undefined;
99
+ const bracketed = h.includes(':') && !h.startsWith('[') ? `[${h}]` : h;
100
+ // The SDK compares the URL-parsed Host, which is the canonical form
101
+ // ([2001:db8:0:0::5] arrives as [2001:db8::5]), so store the same form.
102
+ try {
103
+ return new URL(`http://${bracketed}`).hostname;
104
+ }
105
+ catch {
106
+ return bracketed;
107
+ }
108
+ }
109
+ /**
110
+ * #612: the Host (and, in open mode, Origin) hostnames the HTTP server accepts, or
111
+ * `null` to skip the check. DNS rebinding protection: a hostile page can rebind its
112
+ * own name to 127.0.0.1, but cannot make the browser send a Host it does not own.
113
+ *
114
+ * - Open mode (no bearer token, no OIDC): the loopback names, `allowedHosts`, the
115
+ * bind address itself when it is a concrete, non-wildcard address (an IP-literal
116
+ * Host cannot come out of rebinding), and (#615) the advertised host, the address
117
+ * the server prints as its own URL (MCP_BRIDGE_PUBLIC_HOST or the detected LAN
118
+ * IP): an operator-controlled name or an IP literal, never one a hostile page can
119
+ * make the browser send. Not on a loopback bind, which the LAN cannot reach.
120
+ * IPv6 values are added bracketed and canonical.
121
+ * - Authenticated: `null` when `allowedHosts` is empty (unchanged behaviour), else
122
+ * `allowedHosts` plus the loopback names. The bind and advertised hosts are not
123
+ * added: an operator list stays exact.
124
+ *
125
+ * Pure: `allowedHosts` is the already-validated MCP_ALLOWED_HOSTS list.
126
+ */
127
+ export function resolveAllowedHosts({ openMode, allowedHosts, bindHost, advertisedHost, }) {
128
+ if (!openMode && allowedHosts.length === 0)
129
+ return null;
130
+ const hosts = new Set([...LOOPBACK_HOSTNAMES, ...allowedHosts]);
131
+ if (openMode) {
132
+ // A loopback bind is unreachable from the LAN, so the advertised (LAN) host is
133
+ // left out there: adding it would only widen which Origins may call localhost.
134
+ const advertised = isLoopbackHost(bindHost) ? undefined : advertisedHost;
135
+ for (const h of [canonicalHostname(bindHost), canonicalHostname(advertised)]) {
136
+ if (h)
137
+ hosts.add(h);
138
+ }
139
+ }
140
+ return [...hosts];
141
+ }
43
142
  /**
44
143
  * Pure decision function (no side effects) so it is exhaustively unit-testable
45
- * without booting a server. Precedence: configured auth serves; otherwise a
46
- * loopback bind serves; otherwise the explicit opt-out serves (with a warning);
47
- * otherwise refuse.
144
+ * without booting a server. Precedence: a weak token refuses a non-loopback bind
145
+ * (#611, before and regardless of the opt-out, which cannot make a weak token
146
+ * safe) and serves with a warning on loopback; then configured auth serves;
147
+ * otherwise a loopback bind serves; otherwise the explicit opt-out serves (with a
148
+ * warning); otherwise refuse.
48
149
  */
49
150
  export function evaluateHttpAuthPosture(input) {
50
- const { bindHost, hasStaticToken, oidcEnabled, allowUnauthenticated } = input;
151
+ const { bindHost, hasStaticToken, oidcEnabled, allowUnauthenticated, weakTokenVars = [] } = input;
152
+ if (weakTokenVars.length > 0) {
153
+ if (isLoopbackHost(bindHost)) {
154
+ return { decision: 'serve', reason: `loopback bind (${bindHost}) with a weak token`, weakTokenVars };
155
+ }
156
+ return {
157
+ decision: 'refuse',
158
+ reason: `weak token on a non-loopback bind (${bindHost ?? '0.0.0.0'})`,
159
+ weakTokenVars,
160
+ };
161
+ }
51
162
  if (hasStaticToken || oidcEnabled) {
52
163
  return { decision: 'serve', reason: 'authentication is configured' };
53
164
  }
@@ -73,7 +184,24 @@ export function evaluateHttpAuthPosture(input) {
73
184
  * caller (or test that stubs exit to not throw) can observe it.
74
185
  */
75
186
  export function enforceHttpAuthPosture(input, log = logger, exit = process.exit) {
76
- const { decision, reason } = evaluateHttpAuthPosture(input);
187
+ const { decision, reason, weakTokenVars } = evaluateHttpAuthPosture(input);
188
+ if (weakTokenVars) {
189
+ // #611: names only, never values. A separate message from the no-auth refusal:
190
+ // MCP_ALLOW_UNAUTHENTICATED cannot rescue a weak token, so it is not offered.
191
+ if (decision === 'refuse') {
192
+ log.error(`Refusing to start: ${reason}. ${weakTokenVars.join(', ')} is shorter than ${MIN_TOKEN_LENGTH} ` +
193
+ 'characters or an example placeholder. Generate a token with openssl rand -hex 32.');
194
+ exit(1);
195
+ }
196
+ else {
197
+ for (const name of weakTokenVars) {
198
+ log.warn(`${name} is shorter than ${MIN_TOKEN_LENGTH} characters or an example placeholder. ` +
199
+ 'Allowed only on a loopback bind; a non-loopback bind refuses to start. ' +
200
+ 'Generate a token with openssl rand -hex 32.');
201
+ }
202
+ }
203
+ return decision;
204
+ }
77
205
  if (decision === 'refuse') {
78
206
  log.error(`Refusing to start: ${reason}. HTTP auth is required-by-default. ` +
79
207
  'Set MCP_SSE_AUTHORIZATION to a strong token (openssl rand -hex 32), or set ' +
@@ -25,6 +25,8 @@ import { parseScopeList } from '../lib/oidc-scopes.js';
25
25
  import { budgetAclMiddleware } from '../auth/budget-acl.js';
26
26
  import { resolveStaticTokens, matchBearerToken, LEGACY_STATIC_PRINCIPAL } from '../auth/static-tokens.js';
27
27
  import { runAclPreflight } from '../auth/budget-acl-dynamic.js';
28
+ import { hostHeaderValidation } from '@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js';
29
+ import { resolveAllowedHosts } from '../lib/authPosture.js';
28
30
  import * as https from 'node:https';
29
31
  import { finished } from 'node:stream';
30
32
  import * as fs from 'node:fs';
@@ -85,7 +87,78 @@ advertisedUrl) {
85
87
  ? []
86
88
  : resolveStaticTokens(config.MCP_STATIC_TOKENS, process.env, getBudgetRegistry(), config.MCP_SSE_AUTHORIZATION);
87
89
  const staticPrincipals = bearerEntries.filter((e) => e.allowedBudgets);
90
+ // #612: the ONE open-mode predicate, shared by the Host/Origin guard below and
91
+ // checkAuth's no-token branch, so the two can never disagree.
92
+ const openMode = config.AUTH_PROVIDER !== 'oidc' && bearerEntries.length === 0;
88
93
  const app = express();
94
+ // #612: DNS rebinding protection, mounted first so it covers every route (/health,
95
+ // /metrics, /.well-known/* and OPTIONS included). It reads req.headers.host only,
96
+ // never X-Forwarded-Host or req.hostname, so a proxy header cannot bypass it.
97
+ // #615: the host this server prints as its own URL is accepted too (open mode only).
98
+ let advertisedHost;
99
+ try {
100
+ advertisedHost = advertisedUrl ? new URL(advertisedUrl).hostname : undefined;
101
+ }
102
+ catch {
103
+ logger.warn(`[HTTP] Advertised URL ${advertisedUrl} does not parse; its host is not accepted. Check MCP_BRIDGE_PUBLIC_HOST (a bare host or IP, no scheme or port).`);
104
+ }
105
+ // MCP_BRIDGE_PUBLIC_HOST=https://nas.lan parses to the hostname "https": say so rather than
106
+ // accept it silently. A scheme or path always carries a "/"; a port already fails the parse.
107
+ const publicHost = process.env.MCP_BRIDGE_PUBLIC_HOST?.trim().toLowerCase();
108
+ if (advertisedHost && publicHost?.includes('/')) {
109
+ logger.warn(`[HTTP] MCP_BRIDGE_PUBLIC_HOST (${publicHost}) did not parse to a host name (got ${advertisedHost}). Set a bare host or IP, no scheme or port.`);
110
+ }
111
+ const allowedHosts = resolveAllowedHosts({ openMode, allowedHosts: config.MCP_ALLOWED_HOSTS, bindHost, advertisedHost });
112
+ if (allowedHosts) {
113
+ const rejectHost = (req, reason, value) => logger.warn('[HTTP] Rejected request: hostname not allowed (DNS rebinding protection, #612)', {
114
+ header: reason, hostname: value.slice(0, 253), path: req.path,
115
+ });
116
+ const sdkHostCheck = hostHeaderValidation(allowedHosts);
117
+ app.use((req, res, next) => {
118
+ let passed = false;
119
+ sdkHostCheck(req, res, () => { passed = true; });
120
+ if (passed) {
121
+ next();
122
+ return;
123
+ }
124
+ // The SDK middleware is synchronous: it already answered 403.
125
+ let hostname = String(req.headers.host ?? '');
126
+ try {
127
+ hostname = new URL(`http://${hostname}`).hostname;
128
+ }
129
+ catch { /* log the raw value */ }
130
+ rejectHost(req, 'host', hostname);
131
+ });
132
+ if (openMode) {
133
+ // The SDK checks Host only. In open mode a browser Origin must also be an allowed
134
+ // hostname; an absent Origin (non-browser clients) passes. Comparing Origin to Host
135
+ // would not help: under rebinding both carry the attacker's name. /.well-known/* is
136
+ // exempt because mcp-auth serves it with cors() for public cross-origin reads.
137
+ app.use((req, res, next) => {
138
+ const origin = req.headers.origin;
139
+ if (origin === undefined || req.path.startsWith('/.well-known/')) {
140
+ next();
141
+ return;
142
+ }
143
+ let hostname = null;
144
+ try {
145
+ hostname = origin === 'null' ? null : new URL(origin).hostname;
146
+ }
147
+ catch {
148
+ hostname = null;
149
+ }
150
+ if (hostname !== null && allowedHosts.includes(hostname)) {
151
+ next();
152
+ return;
153
+ }
154
+ rejectHost(req, 'origin', hostname ?? 'null');
155
+ res.status(403).json({ jsonrpc: '2.0', error: { code: -32000, message: `Invalid Origin: ${hostname ?? 'null'}` }, id: null });
156
+ });
157
+ }
158
+ if (openMode && config.MCP_ALLOWED_HOSTS.length === 0 && (bindHost === '0.0.0.0' || bindHost === '::')) {
159
+ logger.info(`[HTTP] Accepting Host ${allowedHosts.join(', ')} only (DNS rebinding protection, #612). Reach this server by another name? Add it to MCP_ALLOWED_HOSTS.`);
160
+ }
161
+ }
89
162
  // Explicit body-size cap (#168). An oversized payload is rejected with HTTP 413
90
163
  // rather than buffered unbounded. Tunable via MCP_HTTP_BODY_LIMIT (default 512kb).
91
164
  app.use(express.json({ limit: config.MCP_HTTP_BODY_LIMIT }));
@@ -366,7 +439,7 @@ advertisedUrl) {
366
439
  // (#572). Only when NEITHER is configured is every request allowed. Static tokens are
367
440
  // authentication, so MCP_ALLOW_UNAUTHENTICATED (a posture opt-out for the no-auth
368
441
  // case) does not relax this.
369
- if (bearerEntries.length === 0) {
442
+ if (openMode) {
370
443
  return true;
371
444
  }
372
445
  const authHeader = req.headers.authorization;
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.24.15",
4
+ "version": "0.25.1",
5
+ "mcpName": "io.github.agigante80/actual-mcp-server",
5
6
  "engines": {
6
7
  "node": ">=22.0.0",
7
8
  "npm": ">=10.0.0"
@@ -30,7 +31,7 @@
30
31
  "verify-tools": "npm run build && node scripts/verify-tools.js",
31
32
  "check:coverage": "node scripts/list-actual-api-methods.mjs",
32
33
  "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/install_snippets.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_acl_listing.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/multi_server_budget_selection.test.js && node tests/unit/static_tokens.test.js && node tests/unit/httpServer_static_tokens.test.js && node tests/unit/multi_server_budget_selection_stdio.test.js && node tests/unit/api_login_fetch.test.js && node tests/unit/multi_server_session_open_http.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/tool_content_logging.test.js && node tests/unit/adapter_error_logging.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/harness_cleanup.test.js && node tests/unit/harness_stdio_data_dir.test.js && node tests/unit/batch_uncategorized_cleanup.test.js && node tests/unit/rules_stage342_cleanup.test.js && node tests/unit/residue_unreadable.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/schedules_update_amount_guard.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 && node tests/unit/transactions_merge_adapter.test.js && node tests/unit/schedules_dead_conditions.test.js && node tests/unit/session_ownership_express.test.js && node tests/unit/pool_orphan_sweep.test.js && node tests/unit/session_budget_state_cleanup.test.js && node tests/unit/http_session_delete.test.js && node tests/unit/budget_acl_parse.test.js && node tests/unit/server_instructions.test.js && node tests/unit/query_run_empty_hint.test.js && node tests/unit/session_admission_race.test.js && node tests/unit/pool_admission_reservation.test.js && node tests/unit/config_max_sessions_per_principal.test.js && node tests/unit/session_principal_cap.test.js && node tests/unit/schema_published_parity.test.js && node tests/unit/manual_borrowed_category.test.js && node tests/unit/manual_budget_no_borrowed_writes.test.js && node tests/unit/refresh_test_budget_snapshot.test.js && node tests/unit/docs_check.test.js && node tests/unit/docs_markers.test.js && node tests/unit/docs_links_fixture.test.js && node tests/unit/docs_index.test.js && node tests/unit/docs_tools.test.js && node tests/unit/docs_readme_size.test.js && node tests/unit/template_lockstep.test.js && node tests/unit/client_examples.test.js",
34
+ "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/install_snippets.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_acl_listing.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/multi_server_budget_selection.test.js && node tests/unit/static_tokens.test.js && node tests/unit/httpServer_static_tokens.test.js && node tests/unit/httpServer_host_validation.test.js && node tests/unit/multi_server_budget_selection_stdio.test.js && node tests/unit/api_login_fetch.test.js && node tests/unit/multi_server_session_open_http.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/tool_content_logging.test.js && node tests/unit/adapter_error_logging.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/harness_cleanup.test.js && node tests/unit/harness_stdio_data_dir.test.js && node tests/unit/batch_uncategorized_cleanup.test.js && node tests/unit/rules_stage342_cleanup.test.js && node tests/unit/residue_unreadable.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/schedules_update_amount_guard.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 && node tests/unit/transactions_merge_adapter.test.js && node tests/unit/schedules_dead_conditions.test.js && node tests/unit/session_ownership_express.test.js && node tests/unit/pool_orphan_sweep.test.js && node tests/unit/session_budget_state_cleanup.test.js && node tests/unit/http_session_delete.test.js && node tests/unit/budget_acl_parse.test.js && node tests/unit/server_instructions.test.js && node tests/unit/query_run_empty_hint.test.js && node tests/unit/session_admission_race.test.js && node tests/unit/pool_admission_reservation.test.js && node tests/unit/config_max_sessions_per_principal.test.js && node tests/unit/session_principal_cap.test.js && node tests/unit/schema_published_parity.test.js && node tests/unit/manual_borrowed_category.test.js && node tests/unit/manual_budget_no_borrowed_writes.test.js && node tests/unit/refresh_test_budget_snapshot.test.js && node tests/unit/docs_check.test.js && node tests/unit/docs_markers.test.js && node tests/unit/docs_links_fixture.test.js && node tests/unit/docs_index.test.js && node tests/unit/docs_tools.test.js && node tests/unit/docs_readme_size.test.js && node tests/unit/template_lockstep.test.js && node tests/unit/client_examples.test.js && node tests/unit/mcp_server_json.test.js",
34
35
  "test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
35
36
  "test:e2e": "npx playwright test",
36
37
  "test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",
@@ -89,7 +90,7 @@
89
90
  "@playwright/test": "^1.64.1",
90
91
  "@types/express": "^5.0.3",
91
92
  "@types/node": "^26.6.5",
92
- "knip": "^6.42.1",
93
+ "knip": "^6.44.0",
93
94
  "node-fetch": "^3.3.2",
94
95
  "tsconfig-paths": "^4.2.0",
95
96
  "typescript": "^7.0.2",